innercontext/frontend/src/routes/routines/[id]/+page.svelte
Piotr Oleszczyk 3aa03b412b feat(frontend): group product selector by category in routine step forms
Products in the Add/Edit step dropdowns are now grouped by category
(Cleanser → Serum → Moisturizer → …) using SelectGroup/SelectGroupHeading,
and sorted alphabetically by brand then name within each group.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-01 18:22:51 +01:00

378 lines
13 KiB
Svelte
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<script lang="ts">
import { enhance } from '$app/forms';
import { dragHandleZone, dragHandle, type DndEvent } from 'svelte-dnd-action';
import { updateRoutineStep } from '$lib/api';
import type { GroomingAction, RoutineStep } from '$lib/types';
import type { ActionData, PageData } from './$types';
import { m } from '$lib/paraglide/messages.js';
import { Badge } from '$lib/components/ui/badge';
import { Button } from '$lib/components/ui/button';
import { Card, CardContent, CardHeader, CardTitle } from '$lib/components/ui/card';
import { Input } from '$lib/components/ui/input';
import { Label } from '$lib/components/ui/label';
import {
Select,
SelectContent,
SelectGroup,
SelectGroupHeading,
SelectItem,
SelectTrigger
} from '$lib/components/ui/select';
import { Separator } from '$lib/components/ui/separator';
let { data, form }: { data: PageData; form: ActionData } = $props();
let { routine, products } = $derived(data);
// ── Steps local state (synced from server data) ───────────────
let steps = $state<RoutineStep[]>([]);
$effect(() => {
steps = [...(routine.steps ?? [])].sort((a, b) => a.order_index - b.order_index);
});
const nextOrderIndex = $derived(
steps.length ? Math.max(...steps.map((s) => s.order_index)) + 1 : 0
);
// ── Drag & drop reordering ────────────────────────────────────
let dndSaving = $state(false);
function handleConsider(e: CustomEvent<DndEvent<RoutineStep>>) {
steps = e.detail.items;
}
async function handleFinalize(e: CustomEvent<DndEvent<RoutineStep>>) {
const newItems = e.detail.items;
// Assign new order_index = position in array, detect which changed
const updated = newItems.map((s, i) => ({ ...s, order_index: i }));
const changed = updated.filter((s, i) => s.order_index !== newItems[i].order_index);
steps = updated;
if (changed.length) {
dndSaving = true;
try {
await Promise.all(
changed.map((s) => updateRoutineStep(s.id, { order_index: s.order_index }))
);
} finally {
dndSaving = false;
}
}
}
// ── Inline editing ────────────────────────────────────────────
let editingStepId = $state<string | null>(null);
let editDraft = $state<Partial<RoutineStep>>({});
let editSaving = $state(false);
let editError = $state('');
function startEdit(step: RoutineStep) {
editingStepId = step.id;
editDraft = {
dose: step.dose ?? '',
region: step.region ?? '',
product_id: step.product_id,
action_type: step.action_type,
action_notes: step.action_notes ?? ''
};
editError = '';
}
async function saveEdit(step: RoutineStep) {
editSaving = true;
editError = '';
try {
const payload: Record<string, unknown> = {};
if (step.product_id !== undefined) {
payload.product_id = editDraft.product_id;
payload.dose = editDraft.dose || null;
payload.region = editDraft.region || null;
} else {
payload.action_type = editDraft.action_type;
payload.action_notes = editDraft.action_notes || null;
}
const updatedStep = await updateRoutineStep(step.id, payload);
steps = steps.map((s) => (s.id === step.id ? { ...s, ...updatedStep } : s));
editingStepId = null;
} catch (err) {
editError = (err as Error).message;
} finally {
editSaving = false;
}
}
function cancelEdit() {
editingStepId = null;
editDraft = {};
editError = '';
}
// ── Add step form ─────────────────────────────────────────────
let showStepForm = $state(false);
let selectedProductId = $state('');
const GROOMING_ACTIONS: GroomingAction[] = ['shaving_razor', 'shaving_oneblade', 'dermarolling'];
const CATEGORY_ORDER = [
'cleanser', 'toner', 'essence', 'serum', 'moisturizer',
'spf', 'mask', 'exfoliant', 'spot_treatment', 'oil',
'hair_treatment', 'tool', 'other'
];
function formatCategory(cat: string): string {
return cat.charAt(0).toUpperCase() + cat.slice(1).replace(/_/g, ' ');
}
const groupedProducts = $derived.by(() => {
const groups = new Map<string, typeof products>();
for (const p of products) {
const key = p.category ?? 'other';
if (!groups.has(key)) groups.set(key, []);
groups.get(key)!.push(p);
}
for (const list of groups.values()) {
list.sort((a, b) => {
const b_cmp = (a.brand ?? '').localeCompare(b.brand ?? '');
return b_cmp !== 0 ? b_cmp : a.name.localeCompare(b.name);
});
}
return CATEGORY_ORDER
.filter((c) => groups.has(c))
.map((c) => [c, groups.get(c)!] as const);
});
</script>
<svelte:head><title>Routine {routine.routine_date} {routine.part_of_day.toUpperCase()} — innercontext</title></svelte:head>
<div class="max-w-2xl space-y-6">
<div class="flex items-center gap-4">
<a href="/routines" class="text-sm text-muted-foreground hover:underline">{m["routines_backToList"]()}</a>
<h2 class="text-2xl font-bold tracking-tight">{routine.routine_date}</h2>
<Badge variant={routine.part_of_day === 'am' ? 'default' : 'secondary'}>
{routine.part_of_day.toUpperCase()}
</Badge>
</div>
{#if form?.error}
<div class="rounded-md bg-destructive/10 px-4 py-3 text-sm text-destructive">{form.error}</div>
{/if}
{#if routine.notes}
<p class="text-sm text-muted-foreground">{routine.notes}</p>
{/if}
<!-- Steps -->
<div class="space-y-3">
<div class="flex items-center justify-between">
<h3 class="text-lg font-semibold">{m.routines_steps({ count: steps.length })}</h3>
<Button variant="outline" size="sm" onclick={() => (showStepForm = !showStepForm)}>
{showStepForm ? m.common_cancel() : m["routines_addStep"]()}
</Button>
</div>
{#if showStepForm}
<Card>
<CardHeader><CardTitle class="text-base">{m["routines_addStepTitle"]()}</CardTitle></CardHeader>
<CardContent>
<form method="POST" action="?/addStep" use:enhance class="space-y-4">
<div class="space-y-1">
<Label>{m.routines_product()}</Label>
<input type="hidden" name="product_id" value={selectedProductId} />
<Select type="single" value={selectedProductId} onValueChange={(v) => (selectedProductId = v)}>
<SelectTrigger>
{#if selectedProductId}
{products.find((p) => p.id === selectedProductId)?.name ?? m["routines_selectProduct"]()}
{:else}
{m["routines_selectProduct"]()}
{/if}
</SelectTrigger>
<SelectContent>
{#each groupedProducts as [cat, items]}
<SelectGroup>
<SelectGroupHeading>{formatCategory(cat)}</SelectGroupHeading>
{#each items as p (p.id)}
<SelectItem value={p.id}>{p.name} · {p.brand}</SelectItem>
{/each}
</SelectGroup>
{/each}
</SelectContent>
</Select>
</div>
<input type="hidden" name="order_index" value={nextOrderIndex} />
<div class="grid grid-cols-2 gap-3">
<div class="space-y-1">
<Label for="dose">{m.routines_dose()}</Label>
<Input id="dose" name="dose" placeholder={m["routines_dosePlaceholder"]()} />
</div>
<div class="space-y-1">
<Label for="region">{m.routines_region()}</Label>
<Input id="region" name="region" placeholder={m["routines_regionPlaceholder"]()} />
</div>
</div>
<Button type="submit" size="sm">{m["routines_addStepBtn"]()}</Button>
</form>
</CardContent>
</Card>
{/if}
{#if steps.length}
<div
use:dragHandleZone={{ items: steps, flipDurationMs: 200, dragDisabled: !!editingStepId || dndSaving }}
onconsider={handleConsider}
onfinalize={handleFinalize}
class="space-y-2"
>
{#each steps as step, i (step.id)}
<div class="rounded-md border border-border bg-background">
{#if editingStepId === step.id}
<!-- ── Edit mode ── -->
<div class="px-4 py-3 space-y-3">
{#if step.product_id !== undefined}
<!-- Product step: change product / dose / region -->
<div class="space-y-1">
<Label>{m.routines_product()}</Label>
<Select
type="single"
value={editDraft.product_id ?? ''}
onValueChange={(v) => (editDraft.product_id = v || undefined)}
>
<SelectTrigger>
{#if editDraft.product_id}
{products.find((p) => p.id === editDraft.product_id)?.name ?? m["routines_selectProduct"]()}
{:else}
{m["routines_selectProduct"]()}
{/if}
</SelectTrigger>
<SelectContent>
{#each groupedProducts as [cat, items]}
<SelectGroup>
<SelectGroupHeading>{formatCategory(cat)}</SelectGroupHeading>
{#each items as p (p.id)}
<SelectItem value={p.id}>{p.name} · {p.brand}</SelectItem>
{/each}
</SelectGroup>
{/each}
</SelectContent>
</Select>
</div>
<div class="grid grid-cols-2 gap-3">
<div class="space-y-1">
<Label>{m.routines_dose()}</Label>
<Input
value={editDraft.dose ?? ''}
oninput={(e) => (editDraft.dose = e.currentTarget.value)}
placeholder={m["routines_dosePlaceholder"]()}
/>
</div>
<div class="space-y-1">
<Label>{m.routines_region()}</Label>
<Input
value={editDraft.region ?? ''}
oninput={(e) => (editDraft.region = e.currentTarget.value)}
placeholder={m["routines_regionPlaceholder"]()}
/>
</div>
</div>
{:else}
<!-- Action step: change action_type / notes -->
<div class="space-y-1">
<Label>Action</Label>
<Select
type="single"
value={editDraft.action_type ?? ''}
onValueChange={(v) =>
(editDraft.action_type = (v || undefined) as GroomingAction | undefined)}
>
<SelectTrigger>
{editDraft.action_type?.replace(/_/g, ' ') ?? 'Select action'}
</SelectTrigger>
<SelectContent>
{#each GROOMING_ACTIONS as action (action)}
<SelectItem value={action}>{action.replace(/_/g, ' ')}</SelectItem>
{/each}
</SelectContent>
</Select>
</div>
<div class="space-y-1">
<Label>Notes</Label>
<Input
value={editDraft.action_notes ?? ''}
oninput={(e) => (editDraft.action_notes = e.currentTarget.value)}
placeholder="optional notes"
/>
</div>
{/if}
{#if editError}
<p class="text-sm text-destructive">{editError}</p>
{/if}
<div class="flex gap-2">
<Button size="sm" onclick={() => saveEdit(step)} disabled={editSaving}>
{m.common_save()}
</Button>
<Button size="sm" variant="outline" onclick={cancelEdit} disabled={editSaving}>
{m.common_cancel()}
</Button>
</div>
</div>
{:else}
<!-- ── View mode ── -->
<div class="flex items-center gap-1 px-2 py-3">
<span
use:dragHandle
class="cursor-grab select-none px-1 text-muted-foreground/60 hover:text-muted-foreground"
aria-label="drag to reorder"
>⋮⋮</span>
<span class="w-5 shrink-0 text-xs text-muted-foreground">{i + 1}.</span>
<div class="flex-1 min-w-0 px-1">
{#if step.product_id}
{@const product = products.find((p) => p.id === step.product_id)}
<p class="text-sm font-medium truncate">{product?.name ?? step.product_id}</p>
{#if product?.brand}<p class="text-xs text-muted-foreground truncate">{product.brand}</p>{/if}
{:else if step.action_type}
<p class="text-sm font-medium">{step.action_type.replace(/_/g, ' ')}</p>
{:else}
<p class="text-sm text-muted-foreground">{m["routines_unknownStep"]()}</p>
{/if}
</div>
{#if step.dose}
<span class="shrink-0 text-xs text-muted-foreground">{step.dose}</span>
{/if}
<Button
variant="ghost"
size="sm"
class="shrink-0 h-7 w-7 p-0 text-muted-foreground hover:text-foreground"
onclick={() => startEdit(step)}
aria-label="edit step"
></Button>
<form method="POST" action="?/removeStep" use:enhance>
<input type="hidden" name="step_id" value={step.id} />
<Button
type="submit"
variant="ghost"
size="sm"
class="shrink-0 h-7 w-7 p-0 text-destructive hover:text-destructive"
>×</Button>
</form>
</div>
{/if}
</div>
{/each}
</div>
{:else}
<p class="text-sm text-muted-foreground">{m["routines_noSteps"]()}</p>
{/if}
</div>
<Separator />
<form
method="POST"
action="?/delete"
use:enhance
onsubmit={(e) => {
if (!confirm(m["routines_confirmDelete"]())) e.preventDefault();
}}
>
<Button type="submit" variant="destructive" size="sm">{m["routines_deleteRoutine"]()}</Button>
</form>
</div>