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,13 +1,17 @@
from datetime import date
import json
from datetime import date, timedelta
from typing import Optional
from uuid import UUID, uuid4
from fastapi import APIRouter, Depends
from sqlmodel import Session, SQLModel, select
from fastapi import APIRouter, Depends, HTTPException
from google.genai import types as genai_types
from pydantic import BaseModel as PydanticBase
from sqlmodel import Session, SQLModel, col, select
from db import get_session
from innercontext.api.utils import get_or_404
from innercontext.models import GroomingSchedule, Routine, RoutineStep
from innercontext.llm import get_gemini_client
from innercontext.models import GroomingSchedule, Product, Routine, RoutineStep, SkinConditionSnapshot
from innercontext.models.enums import GroomingAction, PartOfDay
router = APIRouter()
@ -60,6 +64,188 @@ class GroomingScheduleUpdate(SQLModel):
notes: Optional[str] = None
class SuggestedStep(SQLModel):
product_id: Optional[UUID] = None
action_type: Optional[GroomingAction] = None
action_notes: Optional[str] = None
dose: Optional[str] = None
region: Optional[str] = None
class SuggestRoutineRequest(SQLModel):
routine_date: date
part_of_day: PartOfDay
notes: Optional[str] = None
class RoutineSuggestion(SQLModel):
steps: list[SuggestedStep]
reasoning: str
class SuggestBatchRequest(SQLModel):
from_date: date
to_date: date
notes: Optional[str] = None
class DayPlan(SQLModel):
date: date
am_steps: list[SuggestedStep]
pm_steps: list[SuggestedStep]
reasoning: str
class BatchSuggestion(SQLModel):
days: list[DayPlan]
overall_reasoning: str
# ---------------------------------------------------------------------------
# Pydantic schemas for Gemini structured output
# ---------------------------------------------------------------------------
class _StepOut(PydanticBase):
product_id: Optional[str] = None
action_type: Optional[str] = None
dose: Optional[str] = None
region: Optional[str] = None
action_notes: Optional[str] = None
class _SuggestionOut(PydanticBase):
steps: list[_StepOut]
reasoning: str
class _DayPlanOut(PydanticBase):
date: str
am_steps: list[_StepOut]
pm_steps: list[_StepOut]
reasoning: str
class _BatchOut(PydanticBase):
days: list[_DayPlanOut]
overall_reasoning: str
# ---------------------------------------------------------------------------
# Prompt helpers
# ---------------------------------------------------------------------------
_DAY_NAMES = ["poniedziałek", "wtorek", "środa", "czwartek", "piątek", "sobota", "niedziela"]
def _ev(v: object) -> str:
return v.value if v is not None and hasattr(v, "value") else str(v) if v is not None else ""
def _build_skin_context(session: Session) -> str:
snapshot = session.exec(
select(SkinConditionSnapshot).order_by(col(SkinConditionSnapshot.snapshot_date).desc())
).first()
if snapshot is None:
return "STAN SKÓRY: brak danych\n"
ev = _ev
return (
f"STAN SKÓRY (snapshot z {snapshot.snapshot_date}):\n"
f" Ogólny stan: {ev(snapshot.overall_state)}\n"
f" Nawilżenie: {snapshot.hydration_level}/5\n"
f" Bariera: {ev(snapshot.barrier_state)}\n"
f" Aktywne problemy: {', '.join(ev(c) for c in (snapshot.active_concerns or []))}\n"
f" Priorytety: {', '.join(snapshot.priorities or [])}\n"
f" Uwagi: {snapshot.notes or 'brak'}\n"
)
def _build_grooming_context(session: Session, weekdays: Optional[list[int]] = None) -> str:
entries = session.exec(select(GroomingSchedule).order_by(GroomingSchedule.day_of_week)).all()
if not entries:
return "HARMONOGRAM PIELĘGNACJI: brak\n"
lines = ["HARMONOGRAM PIELĘGNACJI:"]
for e in entries:
if weekdays is not None and e.day_of_week not in weekdays:
continue
day_name = _DAY_NAMES[e.day_of_week] if 0 <= e.day_of_week <= 6 else str(e.day_of_week)
lines.append(f" {day_name}: {_ev(e.action)}" + (f" ({e.notes})" if e.notes else ""))
if len(lines) == 1:
lines.append(" (brak wpisów dla podanych dni)")
return "\n".join(lines) + "\n"
def _build_recent_history(session: Session) -> str:
cutoff = date.today() - timedelta(days=7)
routines = session.exec(
select(Routine)
.where(Routine.routine_date >= cutoff)
.order_by(col(Routine.routine_date).desc())
).all()
if not routines:
return "OSTATNIE RUTYNY (7 dni): brak\n"
lines = ["OSTATNIE RUTYNY (7 dni):"]
for r in routines:
steps = session.exec(
select(RoutineStep)
.where(RoutineStep.routine_id == r.id)
.order_by(RoutineStep.order_index)
).all()
step_names = []
for s in steps:
if s.product_id:
p = session.get(Product, s.product_id)
step_names.append(p.name if p else str(s.product_id))
elif s.action_type:
step_names.append(_ev(s.action_type))
lines.append(f" {r.routine_date} {_ev(r.part_of_day).upper()}: {', '.join(step_names)}")
return "\n".join(lines) + "\n"
def _build_products_context(session: Session, time_filter: Optional[str] = None) -> str:
stmt = select(Product).where(Product.is_medication == False).where(Product.is_tool == False) # noqa: E712
products = session.exec(stmt).all()
lines = ["DOSTĘPNE PRODUKTY:"]
for p in products:
if time_filter and _ev(p.recommended_time) not in (time_filter, "both"):
continue
ctx = p.to_llm_context()
entry = (
f" - id={ctx['id']} name=\"{ctx['name']}\" brand=\"{ctx['brand']}\""
f" category={ctx.get('category', '')} recommended_time={ctx.get('recommended_time', '')}"
f" targets={ctx.get('targets', [])}"
)
profile = ctx.get("product_effect_profile", {})
if profile:
notable = {k: v for k, v in profile.items() if v and v > 0}
if notable:
entry += f" effects={notable}"
if ctx.get("incompatible_with"):
entry += f" incompatible_with={ctx['incompatible_with']}"
if ctx.get("context_rules"):
entry += f" context_rules={ctx['context_rules']}"
if ctx.get("min_interval_hours"):
entry += f" min_interval_hours={ctx['min_interval_hours']}"
if ctx.get("max_frequency_per_week"):
entry += f" max_frequency_per_week={ctx['max_frequency_per_week']}"
lines.append(entry)
return "\n".join(lines) + "\n"
_RULES = """\
ZASADY:
- Kolejność warstw: cleanser toner essence serum moisturizer [SPF dla AM]
- Respektuj incompatible_with (scope: same_step / same_day / same_period)
- Respektuj context_rules (safe_after_shaving, safe_after_acids itp.)
- Respektuj min_interval_hours i max_frequency_per_week
- 47 kroków na rutynę
- product_id musi być UUID produktu z listy lub null dla czynności pielęgnacyjnych
- action_type: tylko shaving_razor | shaving_oneblade | dermarolling (lub null)
- Nie używaj retinoidów i kwasów w tej samej rutynie
- W AM zawsze uwzględnij SPF jeśli dostępny
"""
# ---------------------------------------------------------------------------
# Helper
# ---------------------------------------------------------------------------
@ -96,6 +282,164 @@ def create_routine(data: RoutineCreate, session: Session = Depends(get_session))
return routine
# ---------------------------------------------------------------------------
# AI suggestion endpoints (must appear BEFORE /{routine_id})
# ---------------------------------------------------------------------------
@router.post("/suggest", response_model=RoutineSuggestion)
def suggest_routine(
data: SuggestRoutineRequest,
session: Session = Depends(get_session),
):
client, model = get_gemini_client()
weekday = data.routine_date.weekday()
skin_ctx = _build_skin_context(session)
grooming_ctx = _build_grooming_context(session, weekdays=[weekday])
history_ctx = _build_recent_history(session)
products_ctx = _build_products_context(session, time_filter=data.part_of_day.value)
notes_line = f"\nKONTEKST OD UŻYTKOWNIKA: {data.notes}\n" if data.notes else ""
day_name = _DAY_NAMES[weekday]
prompt = (
f"Zaproponuj rutynę pielęgnacyjną {data.part_of_day.value.upper()} "
f"na {data.routine_date} ({day_name}).\n\n"
f"{skin_ctx}\n{grooming_ctx}\n{history_ctx}\n{products_ctx}\n{_RULES}{notes_line}"
"\nZwróć JSON zgodny ze schematem."
)
try:
response = client.models.generate_content(
model=model,
contents=prompt,
config=genai_types.GenerateContentConfig(
response_mime_type="application/json",
response_schema=_SuggestionOut,
max_output_tokens=4096,
temperature=0.4,
),
)
except Exception as e:
raise HTTPException(status_code=502, detail=f"Gemini API error: {e}")
raw = response.text
if not raw:
raise HTTPException(status_code=502, detail="LLM returned an empty response")
try:
parsed = json.loads(raw)
except json.JSONDecodeError as e:
raise HTTPException(status_code=502, detail=f"LLM returned invalid JSON: {e}")
steps = [
SuggestedStep(
product_id=UUID(s["product_id"]) if s.get("product_id") else None,
action_type=s.get("action_type") or None,
action_notes=s.get("action_notes"),
dose=s.get("dose"),
region=s.get("region"),
)
for s in parsed.get("steps", [])
]
return RoutineSuggestion(steps=steps, reasoning=parsed.get("reasoning", ""))
@router.post("/suggest-batch", response_model=BatchSuggestion)
def suggest_batch(
data: SuggestBatchRequest,
session: Session = Depends(get_session),
):
delta = (data.to_date - data.from_date).days + 1
if delta > 14:
raise HTTPException(status_code=400, detail="Date range must not exceed 14 days.")
if data.from_date > data.to_date:
raise HTTPException(status_code=400, detail="from_date must be <= to_date.")
client, model = get_gemini_client()
weekdays = list({(data.from_date + timedelta(days=i)).weekday() for i in range(delta)})
skin_ctx = _build_skin_context(session)
grooming_ctx = _build_grooming_context(session, weekdays=weekdays)
history_ctx = _build_recent_history(session)
products_ctx = _build_products_context(session)
date_range_lines = []
for i in range(delta):
d = data.from_date + timedelta(days=i)
date_range_lines.append(f" {d} ({_DAY_NAMES[d.weekday()]})")
dates_str = "\n".join(date_range_lines)
notes_line = f"\nKONTEKST OD UŻYTKOWNIKA: {data.notes}\n" if data.notes else ""
prompt = (
f"Zaproponuj plan pielęgnacji AM + PM dla każdego dnia z zakresu:\n{dates_str}\n\n"
f"{skin_ctx}\n{grooming_ctx}\n{history_ctx}\n{products_ctx}\n{_RULES}{notes_line}"
"\nDodatkowe zasady dla planu wielodniowego:\n"
" - Retinol/retinoidy: przestrzegaj max_frequency_per_week i min_interval_hours między użyciami\n"
" - Nie stosuj kwasów i retinoidów tego samego dnia\n"
" - Uwzględnij safe_after_shaving dla dni golenia\n"
" - Zmienność aktywnych składników przez dni dla lepszej tolerancji\n"
" - Pole date w każdym dniu MUSI być w formacie YYYY-MM-DD\n"
"\nZwróć JSON zgodny ze schematem."
)
try:
response = client.models.generate_content(
model=model,
contents=prompt,
config=genai_types.GenerateContentConfig(
response_mime_type="application/json",
response_schema=_BatchOut,
max_output_tokens=8192,
temperature=0.4,
),
)
except Exception as e:
raise HTTPException(status_code=502, detail=f"Gemini API error: {e}")
raw = response.text
if not raw:
raise HTTPException(status_code=502, detail="LLM returned an empty response")
try:
parsed = json.loads(raw)
except json.JSONDecodeError as e:
raise HTTPException(status_code=502, detail=f"LLM returned invalid JSON: {e}")
def _parse_steps(raw_steps: list) -> list[SuggestedStep]:
result = []
for s in raw_steps:
result.append(
SuggestedStep(
product_id=UUID(s["product_id"]) if s.get("product_id") else None,
action_type=s.get("action_type") or None,
action_notes=s.get("action_notes"),
dose=s.get("dose"),
region=s.get("region"),
)
)
return result
days = []
for day_raw in parsed.get("days", []):
try:
day_date = date.fromisoformat(day_raw["date"])
except (KeyError, ValueError):
continue
days.append(
DayPlan(
date=day_date,
am_steps=_parse_steps(day_raw.get("am_steps", [])),
pm_steps=_parse_steps(day_raw.get("pm_steps", [])),
reasoning=day_raw.get("reasoning", ""),
)
)
return BatchSuggestion(days=days, overall_reasoning=parsed.get("overall_reasoning", ""))
# Grooming-schedule GET must appear before /{routine_id} to avoid being shadowed
@router.get("/grooming-schedule", response_model=list[GroomingSchedule])
def list_grooming_schedule(session: Session = Depends(get_session)):

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,8 +26,11 @@
<h2 class="text-2xl font-bold tracking-tight">Routines</h2>
<p class="text-muted-foreground">{data.routines.length} routines (last 30 days)</p>
</div>
<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}
<div class="space-y-4">

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>