feat: AI-generated skincare routine suggestions (single + batch)

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>
This commit is contained in:
Piotr Oleszczyk 2026-03-01 00:34:43 +01:00
parent a3b25d5e46
commit 6e7f715ef2
6 changed files with 918 additions and 5 deletions

View file

@ -1,16 +1,19 @@
import { PUBLIC_API_BASE } from '$env/static/public';
import type {
ActiveIngredient,
BatchSuggestion,
GroomingSchedule,
LabResult,
MedicationEntry,
MedicationUsage,
PartOfDay,
Product,
ProductContext,
ProductEffectProfile,
ProductInteraction,
ProductInventory,
Routine,
RoutineSuggestion,
RoutineStep,
SkinConditionSnapshot
} from './types';
@ -130,6 +133,18 @@ export const updateRoutineStep = (stepId: string, body: Record<string, unknown>)
export const deleteRoutineStep = (stepId: string): Promise<void> =>
api.del(`/routines/steps/${stepId}`);
export const suggestRoutine = (body: {
routine_date: string;
part_of_day: PartOfDay;
notes?: string;
}): Promise<RoutineSuggestion> => api.post('/routines/suggest', body);
export const suggestBatch = (body: {
from_date: string;
to_date: string;
notes?: string;
}): Promise<BatchSuggestion> => api.post('/routines/suggest-batch', body);
export const getGroomingSchedule = (): Promise<GroomingSchedule[]> =>
api.get('/routines/grooming-schedule');
export const createGroomingScheduleEntry = (body: Record<string, unknown>): Promise<GroomingSchedule> =>

View file

@ -191,6 +191,31 @@ export interface GroomingSchedule {
notes?: string;
}
export interface SuggestedStep {
product_id?: string;
action_type?: GroomingAction;
action_notes?: string;
dose?: string;
region?: string;
}
export interface RoutineSuggestion {
steps: SuggestedStep[];
reasoning: string;
}
export interface DayPlan {
date: string;
am_steps: SuggestedStep[];
pm_steps: SuggestedStep[];
reasoning: string;
}
export interface BatchSuggestion {
days: DayPlan[];
overall_reasoning: string;
}
// ─── Health types ────────────────────────────────────────────────────────────
export interface MedicationUsage {

View file

@ -26,7 +26,10 @@
<h2 class="text-2xl font-bold tracking-tight">Routines</h2>
<p class="text-muted-foreground">{data.routines.length} routines (last 30 days)</p>
</div>
<Button href="/routines/new">+ New routine</Button>
<div class="flex gap-2">
<Button href="/routines/suggest" variant="outline">Zaproponuj rutynę AI</Button>
<Button href="/routines/new">+ New routine</Button>
</div>
</div>
{#if sortedDates.length}

View file

@ -0,0 +1,152 @@
import { addRoutineStep, createRoutine, getProducts, suggestBatch, suggestRoutine } from '$lib/api';
import { fail, redirect } from '@sveltejs/kit';
import type { Actions, PageServerLoad } from './$types';
export const load: PageServerLoad = async () => {
const products = await getProducts();
const today = new Date().toISOString().slice(0, 10);
return { products, today };
};
export const actions: Actions = {
suggest: async ({ request }) => {
const form = await request.formData();
const routine_date = form.get('routine_date') as string;
const part_of_day = form.get('part_of_day') as 'am' | 'pm';
const notes = (form.get('notes') as string) || undefined;
if (!routine_date || !part_of_day) {
return fail(400, { error: 'Data i pora dnia są wymagane.' });
}
try {
const suggestion = await suggestRoutine({ routine_date, part_of_day, notes });
return { suggestion, routine_date, part_of_day };
} catch (e) {
return fail(502, { error: (e as Error).message });
}
},
suggestBatch: async ({ request }) => {
const form = await request.formData();
const from_date = form.get('from_date') as string;
const to_date = form.get('to_date') as string;
const notes = (form.get('notes') as string) || undefined;
if (!from_date || !to_date) {
return fail(400, { error: 'Daty początkowa i końcowa są wymagane.' });
}
const delta =
(new Date(to_date).getTime() - new Date(from_date).getTime()) / (1000 * 60 * 60 * 24) + 1;
if (delta > 14) {
return fail(400, { error: 'Zakres dat nie może przekraczać 14 dni.' });
}
try {
const batch = await suggestBatch({ from_date, to_date, notes });
return { batch, from_date, to_date };
} catch (e) {
return fail(502, { error: (e as Error).message });
}
},
save: async ({ request }) => {
const form = await request.formData();
const routine_date = form.get('routine_date') as string;
const part_of_day = form.get('part_of_day') as string;
const steps_json = form.get('steps') as string;
if (!routine_date || !part_of_day || !steps_json) {
return fail(400, { error: 'Brakujące dane do zapisania.' });
}
let steps: Array<{
product_id?: string;
action_type?: string;
action_notes?: string;
dose?: string;
region?: string;
}>;
try {
steps = JSON.parse(steps_json);
} catch {
return fail(400, { error: 'Nieprawidłowy format kroków.' });
}
try {
const routine = await createRoutine({ routine_date, part_of_day });
for (let i = 0; i < steps.length; i++) {
const s = steps[i];
await addRoutineStep(routine.id, {
order_index: i + 1,
product_id: s.product_id || undefined,
action_type: s.action_type || undefined,
action_notes: s.action_notes || undefined,
dose: s.dose || undefined,
region: s.region || undefined
});
}
redirect(303, `/routines/${routine.id}`);
} catch (e) {
return fail(500, { error: (e as Error).message });
}
},
saveBatch: async ({ request }) => {
const form = await request.formData();
const days_json = form.get('days') as string;
if (!days_json) {
return fail(400, { error: 'Brakujące dane do zapisania.' });
}
let days: Array<{
date: string;
am_steps: Array<{
product_id?: string;
action_type?: string;
action_notes?: string;
dose?: string;
region?: string;
}>;
pm_steps: Array<{
product_id?: string;
action_type?: string;
action_notes?: string;
dose?: string;
region?: string;
}>;
}>;
try {
days = JSON.parse(days_json);
} catch {
return fail(400, { error: 'Nieprawidłowy format danych.' });
}
try {
for (const day of days) {
for (const part_of_day of ['am', 'pm'] as const) {
const steps = part_of_day === 'am' ? day.am_steps : day.pm_steps;
if (steps.length === 0) continue;
const routine = await createRoutine({ routine_date: day.date, part_of_day });
for (let i = 0; i < steps.length; i++) {
const s = steps[i];
await addRoutineStep(routine.id, {
order_index: i + 1,
product_id: s.product_id || undefined,
action_type: s.action_type || undefined,
action_notes: s.action_notes || undefined,
dose: s.dose || undefined,
region: s.region || undefined
});
}
}
}
} catch (e) {
return fail(500, { error: (e as Error).message });
}
redirect(303, '/routines');
}
};

View file

@ -0,0 +1,374 @@
<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>