feat(staples): add StaplesManager with optimistic toggle and debounced PATCH
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
80
frontend/src/lib/onboarding/StaplesManager.svelte
Normal file
80
frontend/src/lib/onboarding/StaplesManager.svelte
Normal file
@@ -0,0 +1,80 @@
|
||||
<script lang="ts">
|
||||
import CategorySection from './CategorySection.svelte';
|
||||
|
||||
type Ingredient = { id: string; name: string; isStaple: boolean };
|
||||
type Category = { id: string; name: string; ingredients: Ingredient[] };
|
||||
|
||||
let { categories, context }: {
|
||||
categories: Category[];
|
||||
context: 'onboarding' | 'settings';
|
||||
} = $props();
|
||||
|
||||
let stapleState = $state<Record<string, boolean>>({});
|
||||
let errorMessage = $state('');
|
||||
|
||||
$effect(() => {
|
||||
const initial: Record<string, boolean> = {};
|
||||
for (const cat of categories) {
|
||||
for (const ing of cat.ingredients) {
|
||||
initial[ing.id] = ing.isStaple;
|
||||
}
|
||||
}
|
||||
stapleState = initial;
|
||||
});
|
||||
|
||||
function debounce<T extends (...args: any[]) => void>(fn: T, ms: number): T {
|
||||
let timer: ReturnType<typeof setTimeout> | null = null;
|
||||
return ((...args: any[]) => {
|
||||
if (timer) clearTimeout(timer);
|
||||
timer = setTimeout(() => fn(...args), ms);
|
||||
}) as T;
|
||||
}
|
||||
|
||||
const debouncedPatchers: Record<string, (id: string, value: boolean) => void> = {};
|
||||
|
||||
function getPatcher(id: string) {
|
||||
if (!debouncedPatchers[id]) {
|
||||
debouncedPatchers[id] = debounce(async (ingredientId: string, value: boolean) => {
|
||||
const previous = !value;
|
||||
const res = await fetch(`/household/staples`, {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ id: ingredientId, isStaple: value })
|
||||
});
|
||||
if (!res.ok) {
|
||||
stapleState[ingredientId] = previous;
|
||||
errorMessage = 'Vorrat konnte nicht gespeichert werden.';
|
||||
}
|
||||
}, 300);
|
||||
}
|
||||
return debouncedPatchers[id];
|
||||
}
|
||||
|
||||
function handleToggle(ingredientId: string, newValue: boolean) {
|
||||
errorMessage = '';
|
||||
stapleState[ingredientId] = newValue;
|
||||
getPatcher(ingredientId)(ingredientId, newValue);
|
||||
}
|
||||
</script>
|
||||
|
||||
<div>
|
||||
{#if errorMessage}
|
||||
<p class="mb-[12px] text-[12px] text-[var(--color-error)]">{errorMessage}</p>
|
||||
{/if}
|
||||
|
||||
<div
|
||||
data-testid="category-grid"
|
||||
class="grid grid-cols-1 gap-[24px_32px] {context === 'settings' ? 'md:grid-cols-3' : 'md:grid-cols-2'}"
|
||||
>
|
||||
{#each categories as category (category.id)}
|
||||
<CategorySection
|
||||
name={category.name}
|
||||
ingredients={category.ingredients.map(ing => ({
|
||||
...ing,
|
||||
isStaple: stapleState[ing.id] ?? ing.isStaple
|
||||
}))}
|
||||
onToggle={handleToggle}
|
||||
/>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
Reference in New Issue
Block a user