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:
parent
a3b25d5e46
commit
6e7f715ef2
6 changed files with 918 additions and 5 deletions
|
|
@ -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> =>
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
152
frontend/src/routes/routines/suggest/+page.server.ts
Normal file
152
frontend/src/routes/routines/suggest/+page.server.ts
Normal 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');
|
||||
}
|
||||
};
|
||||
374
frontend/src/routes/routines/suggest/+page.svelte
Normal file
374
frontend/src/routes/routines/suggest/+page.svelte
Normal 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>
|
||||
Loading…
Add table
Add a link
Reference in a new issue