Add Gemini-powered endpoints and frontend pages for proposing skincare routines based on skin state, product compatibility, grooming schedule, and recent history. Backend (routines.py): - POST /routines/suggest — single AM/PM routine for a date - POST /routines/suggest-batch — AM+PM plan for up to 14 days - Prompt context: skin snapshot, grooming schedule, 7-day history, filtered product list with effects/incompatibilities/context rules - Respects retinoid frequency limits, acid/retinoid separation, grooming-aware safe_after_shaving rules Frontend: - /routines/suggest page with tab switcher (single / batch) - Single tab: date + AM/PM + optional notes → generate → preview → save - Batch tab: date range + notes → collapsible day cards (AM+PM) → save all - Loading spinner during Gemini calls; product names resolved from map - "Zaproponuj rutynę AI" button added to routines list page Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
374 lines
14 KiB
Svelte
374 lines
14 KiB
Svelte
<script lang="ts">
|
|
import { enhance } from '$app/forms';
|
|
import { SvelteSet } from 'svelte/reactivity';
|
|
import type { ActionData, PageData } from './$types';
|
|
import type { BatchSuggestion, RoutineSuggestion, SuggestedStep } from '$lib/types';
|
|
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, SelectItem, SelectTrigger } from '$lib/components/ui/select';
|
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from '$lib/components/ui/tabs';
|
|
|
|
let { data, form }: { data: PageData; form: ActionData } = $props();
|
|
|
|
const productMap = $derived(Object.fromEntries(data.products.map((p) => [p.id, p])));
|
|
|
|
// Single suggestion state
|
|
let suggestion = $state<RoutineSuggestion | null>(null);
|
|
let suggestionDate = $state('');
|
|
let suggestionPod = $state('');
|
|
let partOfDay = $state<'am' | 'pm'>('am');
|
|
|
|
// Batch suggestion state
|
|
let batch = $state<BatchSuggestion | null>(null);
|
|
let expandedDays = new SvelteSet<string>();
|
|
|
|
// Error + loading
|
|
let errorMsg = $state<string | null>(null);
|
|
let loadingSingle = $state(false);
|
|
let loadingBatch = $state(false);
|
|
let loadingSave = $state(false);
|
|
|
|
function stepLabel(step: SuggestedStep): string {
|
|
if (step.product_id && productMap[step.product_id]) {
|
|
const p = productMap[step.product_id];
|
|
return `${p.brand} ${p.name}`;
|
|
}
|
|
if (step.action_type) return step.action_type.replace(/_/g, ' ');
|
|
return step.action_notes ?? 'Unknown step';
|
|
}
|
|
|
|
function stepMeta(step: SuggestedStep): string {
|
|
const parts: string[] = [];
|
|
if (step.dose) parts.push(step.dose);
|
|
if (step.region) parts.push(step.region);
|
|
if (step.action_notes && !step.action_type) parts.push(step.action_notes);
|
|
return parts.join(' · ');
|
|
}
|
|
|
|
function toggleDay(d: string) {
|
|
if (expandedDays.has(d)) expandedDays.delete(d);
|
|
else expandedDays.add(d);
|
|
}
|
|
|
|
function enhanceSingle() {
|
|
loadingSingle = true;
|
|
errorMsg = null;
|
|
return async ({ result, update }: { result: { type: string; data?: Record<string, unknown> }; update: (opts?: { reset?: boolean }) => Promise<void> }) => {
|
|
loadingSingle = false;
|
|
if (result.type === 'success' && result.data?.suggestion) {
|
|
suggestion = result.data.suggestion as RoutineSuggestion;
|
|
suggestionDate = result.data.routine_date as string;
|
|
suggestionPod = result.data.part_of_day as string;
|
|
errorMsg = null;
|
|
} else if (result.type === 'failure') {
|
|
errorMsg = (result.data?.error as string) ?? 'Błąd podczas generowania.';
|
|
}
|
|
await update({ reset: false });
|
|
};
|
|
}
|
|
|
|
function enhanceBatch() {
|
|
loadingBatch = true;
|
|
errorMsg = null;
|
|
return async ({ result, update }: { result: { type: string; data?: Record<string, unknown> }; update: (opts?: { reset?: boolean }) => Promise<void> }) => {
|
|
loadingBatch = false;
|
|
if (result.type === 'success' && result.data?.batch) {
|
|
batch = result.data.batch as BatchSuggestion;
|
|
expandedDays.clear();
|
|
for (const d of batch.days) expandedDays.add(d.date);
|
|
errorMsg = null;
|
|
} else if (result.type === 'failure') {
|
|
errorMsg = (result.data?.error as string) ?? 'Błąd podczas generowania planu.';
|
|
}
|
|
await update({ reset: false });
|
|
};
|
|
}
|
|
|
|
function enhanceSave() {
|
|
loadingSave = true;
|
|
return async ({ result, update }: { result: { type: string; data?: Record<string, unknown> }; update: (opts?: { reset?: boolean }) => Promise<void> }) => {
|
|
loadingSave = false;
|
|
if (result.type === 'failure') {
|
|
errorMsg = (result.data?.error as string) ?? 'Błąd podczas zapisywania.';
|
|
}
|
|
await update({ reset: false });
|
|
};
|
|
}
|
|
</script>
|
|
|
|
<svelte:head><title>Zaproponuj rutynę AI — 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">← Rutyny</a>
|
|
<h2 class="text-2xl font-bold tracking-tight">Propozycja rutyny AI</h2>
|
|
</div>
|
|
|
|
{#if errorMsg}
|
|
<div class="rounded-md bg-destructive/10 px-4 py-3 text-sm text-destructive">{errorMsg}</div>
|
|
{/if}
|
|
|
|
<Tabs value="single">
|
|
<TabsList class="w-full">
|
|
<TabsTrigger value="single" class="flex-1" onclick={() => { errorMsg = null; }}>Jedna rutyna</TabsTrigger>
|
|
<TabsTrigger value="batch" class="flex-1" onclick={() => { errorMsg = null; }}>Batch / Urlop</TabsTrigger>
|
|
</TabsList>
|
|
|
|
<!-- ── Single tab ─────────────────────────────────────────────────── -->
|
|
<TabsContent value="single" class="space-y-6 pt-4">
|
|
<Card>
|
|
<CardHeader><CardTitle class="text-base">Parametry</CardTitle></CardHeader>
|
|
<CardContent>
|
|
<form method="POST" action="?/suggest" use:enhance={enhanceSingle} class="space-y-4">
|
|
<div class="grid grid-cols-2 gap-4">
|
|
<div class="space-y-2">
|
|
<Label for="single_date">Data</Label>
|
|
<Input id="single_date" name="routine_date" type="date" value={data.today} required />
|
|
</div>
|
|
<div class="space-y-2">
|
|
<Label>Pora dnia</Label>
|
|
<input type="hidden" name="part_of_day" value={partOfDay} />
|
|
<Select type="single" value={partOfDay} onValueChange={(v) => (partOfDay = v as 'am' | 'pm')}>
|
|
<SelectTrigger>{partOfDay.toUpperCase()}</SelectTrigger>
|
|
<SelectContent>
|
|
<SelectItem value="am">AM (rano)</SelectItem>
|
|
<SelectItem value="pm">PM (wieczór)</SelectItem>
|
|
</SelectContent>
|
|
</Select>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="space-y-2">
|
|
<Label for="single_notes">Dodatkowy kontekst dla AI <span class="text-muted-foreground text-xs">(opcjonalny)</span></Label>
|
|
<textarea
|
|
id="single_notes"
|
|
name="notes"
|
|
rows="2"
|
|
placeholder="np. wieczór imprezowy, skupiam się na nawilżeniu..."
|
|
class="w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring resize-none"
|
|
></textarea>
|
|
</div>
|
|
|
|
<Button type="submit" disabled={loadingSingle} class="w-full">
|
|
{#if loadingSingle}
|
|
<span class="mr-2 inline-block h-4 w-4 animate-spin rounded-full border-2 border-current border-t-transparent"></span>
|
|
Generuję…
|
|
{:else}
|
|
Generuj propozycję
|
|
{/if}
|
|
</Button>
|
|
</form>
|
|
</CardContent>
|
|
</Card>
|
|
|
|
{#if suggestion}
|
|
<div class="space-y-4">
|
|
<div class="flex items-center gap-3">
|
|
<h3 class="text-lg font-semibold">Propozycja</h3>
|
|
<Badge variant={suggestionPod === 'am' ? 'default' : 'secondary'}>
|
|
{suggestionPod.toUpperCase()}
|
|
</Badge>
|
|
<span class="text-sm text-muted-foreground">{suggestionDate}</span>
|
|
</div>
|
|
|
|
<!-- Reasoning -->
|
|
<Card class="border-muted bg-muted/30">
|
|
<CardContent class="pt-4">
|
|
<p class="text-sm text-muted-foreground italic">{suggestion.reasoning}</p>
|
|
</CardContent>
|
|
</Card>
|
|
|
|
<!-- Steps -->
|
|
<div class="space-y-2">
|
|
{#each suggestion.steps as step, i (i)}
|
|
<div class="flex items-start gap-3 rounded-md border border-border px-4 py-3">
|
|
<span class="mt-0.5 flex h-5 w-5 shrink-0 items-center justify-center rounded-full bg-muted text-xs font-medium text-muted-foreground">
|
|
{i + 1}
|
|
</span>
|
|
<div class="min-w-0 flex-1">
|
|
<p class="text-sm font-medium">{stepLabel(step)}</p>
|
|
{#if stepMeta(step)}
|
|
<p class="text-xs text-muted-foreground">{stepMeta(step)}</p>
|
|
{/if}
|
|
</div>
|
|
</div>
|
|
{/each}
|
|
</div>
|
|
|
|
<!-- Save form -->
|
|
<form method="POST" action="?/save" use:enhance={enhanceSave} class="flex gap-3">
|
|
<input type="hidden" name="routine_date" value={suggestionDate} />
|
|
<input type="hidden" name="part_of_day" value={suggestionPod} />
|
|
<input type="hidden" name="steps" value={JSON.stringify(suggestion.steps)} />
|
|
<Button type="submit" disabled={loadingSave}>
|
|
{#if loadingSave}
|
|
<span class="mr-2 inline-block h-4 w-4 animate-spin rounded-full border-2 border-current border-t-transparent"></span>
|
|
Zapisuję…
|
|
{:else}
|
|
Zapisz rutynę
|
|
{/if}
|
|
</Button>
|
|
<Button variant="outline" type="submit" form="suggest-single-form" disabled={loadingSingle}>
|
|
Wygeneruj ponownie
|
|
</Button>
|
|
</form>
|
|
</div>
|
|
{/if}
|
|
</TabsContent>
|
|
|
|
<!-- ── Batch tab ──────────────────────────────────────────────────── -->
|
|
<TabsContent value="batch" class="space-y-6 pt-4">
|
|
<Card>
|
|
<CardHeader><CardTitle class="text-base">Zakres dat</CardTitle></CardHeader>
|
|
<CardContent>
|
|
<form id="batch-form" method="POST" action="?/suggestBatch" use:enhance={enhanceBatch} class="space-y-4">
|
|
<div class="grid grid-cols-2 gap-4">
|
|
<div class="space-y-2">
|
|
<Label for="from_date">Od</Label>
|
|
<Input id="from_date" name="from_date" type="date" value={data.today} required />
|
|
</div>
|
|
<div class="space-y-2">
|
|
<Label for="to_date">Do (max 14 dni)</Label>
|
|
<Input id="to_date" name="to_date" type="date" value={data.today} required />
|
|
</div>
|
|
</div>
|
|
|
|
<div class="space-y-2">
|
|
<Label for="batch_notes">Kontekst / cel wyjazdu <span class="text-muted-foreground text-xs">(opcjonalny)</span></Label>
|
|
<textarea
|
|
id="batch_notes"
|
|
name="notes"
|
|
rows="2"
|
|
placeholder="np. słoneczna podróż do Włoch, aktywny urlop górski..."
|
|
class="w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring resize-none"
|
|
></textarea>
|
|
</div>
|
|
|
|
<Button type="submit" disabled={loadingBatch} class="w-full">
|
|
{#if loadingBatch}
|
|
<span class="mr-2 inline-block h-4 w-4 animate-spin rounded-full border-2 border-current border-t-transparent"></span>
|
|
Generuję plan…
|
|
{:else}
|
|
Generuj plan
|
|
{/if}
|
|
</Button>
|
|
</form>
|
|
</CardContent>
|
|
</Card>
|
|
|
|
{#if batch}
|
|
<div class="space-y-4">
|
|
<h3 class="text-lg font-semibold">Plan ({batch.days.length} dni)</h3>
|
|
|
|
<!-- Overall reasoning -->
|
|
{#if batch.overall_reasoning}
|
|
<Card class="border-muted bg-muted/30">
|
|
<CardContent class="pt-4">
|
|
<p class="text-sm text-muted-foreground italic">{batch.overall_reasoning}</p>
|
|
</CardContent>
|
|
</Card>
|
|
{/if}
|
|
|
|
<!-- Day cards -->
|
|
<div class="space-y-3">
|
|
{#each batch.days as day (day.date)}
|
|
{@const isOpen = expandedDays.has(day.date)}
|
|
<div class="rounded-md border border-border">
|
|
<button
|
|
type="button"
|
|
class="flex w-full items-center justify-between px-4 py-3 text-left hover:bg-muted/50 transition-colors"
|
|
onclick={() => toggleDay(day.date)}
|
|
>
|
|
<span class="font-medium text-sm">{day.date}</span>
|
|
<div class="flex items-center gap-2">
|
|
<span class="text-xs text-muted-foreground">
|
|
AM {day.am_steps.length} kroków · PM {day.pm_steps.length} kroków
|
|
</span>
|
|
<span class="text-muted-foreground">{isOpen ? '▲' : '▼'}</span>
|
|
</div>
|
|
</button>
|
|
|
|
{#if isOpen}
|
|
<div class="border-t border-border px-4 py-3 space-y-4">
|
|
{#if day.reasoning}
|
|
<p class="text-xs text-muted-foreground italic">{day.reasoning}</p>
|
|
{/if}
|
|
|
|
<!-- AM steps -->
|
|
<div class="space-y-2">
|
|
<div class="flex items-center gap-2">
|
|
<Badge>AM</Badge>
|
|
<span class="text-xs text-muted-foreground">{day.am_steps.length} kroków</span>
|
|
</div>
|
|
{#if day.am_steps.length}
|
|
<div class="space-y-1">
|
|
{#each day.am_steps as step, i (i)}
|
|
<div class="flex items-start gap-2 rounded-sm bg-muted/30 px-3 py-2">
|
|
<span class="text-xs text-muted-foreground w-4 shrink-0 mt-0.5">{i + 1}.</span>
|
|
<div class="min-w-0">
|
|
<p class="text-sm">{stepLabel(step)}</p>
|
|
{#if stepMeta(step)}
|
|
<p class="text-xs text-muted-foreground">{stepMeta(step)}</p>
|
|
{/if}
|
|
</div>
|
|
</div>
|
|
{/each}
|
|
</div>
|
|
{:else}
|
|
<p class="text-xs text-muted-foreground">Brak kroków AM.</p>
|
|
{/if}
|
|
</div>
|
|
|
|
<!-- PM steps -->
|
|
<div class="space-y-2">
|
|
<div class="flex items-center gap-2">
|
|
<Badge variant="secondary">PM</Badge>
|
|
<span class="text-xs text-muted-foreground">{day.pm_steps.length} kroków</span>
|
|
</div>
|
|
{#if day.pm_steps.length}
|
|
<div class="space-y-1">
|
|
{#each day.pm_steps as step, i (i)}
|
|
<div class="flex items-start gap-2 rounded-sm bg-muted/30 px-3 py-2">
|
|
<span class="text-xs text-muted-foreground w-4 shrink-0 mt-0.5">{i + 1}.</span>
|
|
<div class="min-w-0">
|
|
<p class="text-sm">{stepLabel(step)}</p>
|
|
{#if stepMeta(step)}
|
|
<p class="text-xs text-muted-foreground">{stepMeta(step)}</p>
|
|
{/if}
|
|
</div>
|
|
</div>
|
|
{/each}
|
|
</div>
|
|
{:else}
|
|
<p class="text-xs text-muted-foreground">Brak kroków PM.</p>
|
|
{/if}
|
|
</div>
|
|
</div>
|
|
{/if}
|
|
</div>
|
|
{/each}
|
|
</div>
|
|
|
|
<!-- Save all form -->
|
|
<form method="POST" action="?/saveBatch" use:enhance={enhanceSave} class="flex gap-3">
|
|
<input type="hidden" name="days" value={JSON.stringify(batch.days)} />
|
|
<Button type="submit" disabled={loadingSave}>
|
|
{#if loadingSave}
|
|
<span class="mr-2 inline-block h-4 w-4 animate-spin rounded-full border-2 border-current border-t-transparent"></span>
|
|
Zapisuję…
|
|
{:else}
|
|
Zapisz wszystkie rutyny
|
|
{/if}
|
|
</Button>
|
|
<Button variant="outline" type="submit" form="batch-form" disabled={loadingBatch}>
|
|
Wygeneruj ponownie
|
|
</Button>
|
|
</form>
|
|
</div>
|
|
{/if}
|
|
</TabsContent>
|
|
</Tabs>
|
|
</div>
|