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,13 +1,17 @@
|
||||||
from datetime import date
|
import json
|
||||||
|
from datetime import date, timedelta
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
from uuid import UUID, uuid4
|
from uuid import UUID, uuid4
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends
|
from fastapi import APIRouter, Depends, HTTPException
|
||||||
from sqlmodel import Session, SQLModel, select
|
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 db import get_session
|
||||||
from innercontext.api.utils import get_or_404
|
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
|
from innercontext.models.enums import GroomingAction, PartOfDay
|
||||||
|
|
||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
|
|
@ -60,6 +64,188 @@ class GroomingScheduleUpdate(SQLModel):
|
||||||
notes: Optional[str] = None
|
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
|
||||||
|
- 4–7 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
|
# Helper
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
@ -96,6 +282,164 @@ def create_routine(data: RoutineCreate, session: Session = Depends(get_session))
|
||||||
return routine
|
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
|
# Grooming-schedule GET must appear before /{routine_id} to avoid being shadowed
|
||||||
@router.get("/grooming-schedule", response_model=list[GroomingSchedule])
|
@router.get("/grooming-schedule", response_model=list[GroomingSchedule])
|
||||||
def list_grooming_schedule(session: Session = Depends(get_session)):
|
def list_grooming_schedule(session: Session = Depends(get_session)):
|
||||||
|
|
|
||||||
|
|
@ -1,16 +1,19 @@
|
||||||
import { PUBLIC_API_BASE } from '$env/static/public';
|
import { PUBLIC_API_BASE } from '$env/static/public';
|
||||||
import type {
|
import type {
|
||||||
ActiveIngredient,
|
ActiveIngredient,
|
||||||
|
BatchSuggestion,
|
||||||
GroomingSchedule,
|
GroomingSchedule,
|
||||||
LabResult,
|
LabResult,
|
||||||
MedicationEntry,
|
MedicationEntry,
|
||||||
MedicationUsage,
|
MedicationUsage,
|
||||||
|
PartOfDay,
|
||||||
Product,
|
Product,
|
||||||
ProductContext,
|
ProductContext,
|
||||||
ProductEffectProfile,
|
ProductEffectProfile,
|
||||||
ProductInteraction,
|
ProductInteraction,
|
||||||
ProductInventory,
|
ProductInventory,
|
||||||
Routine,
|
Routine,
|
||||||
|
RoutineSuggestion,
|
||||||
RoutineStep,
|
RoutineStep,
|
||||||
SkinConditionSnapshot
|
SkinConditionSnapshot
|
||||||
} from './types';
|
} from './types';
|
||||||
|
|
@ -130,6 +133,18 @@ export const updateRoutineStep = (stepId: string, body: Record<string, unknown>)
|
||||||
export const deleteRoutineStep = (stepId: string): Promise<void> =>
|
export const deleteRoutineStep = (stepId: string): Promise<void> =>
|
||||||
api.del(`/routines/steps/${stepId}`);
|
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[]> =>
|
export const getGroomingSchedule = (): Promise<GroomingSchedule[]> =>
|
||||||
api.get('/routines/grooming-schedule');
|
api.get('/routines/grooming-schedule');
|
||||||
export const createGroomingScheduleEntry = (body: Record<string, unknown>): Promise<GroomingSchedule> =>
|
export const createGroomingScheduleEntry = (body: Record<string, unknown>): Promise<GroomingSchedule> =>
|
||||||
|
|
|
||||||
|
|
@ -191,6 +191,31 @@ export interface GroomingSchedule {
|
||||||
notes?: string;
|
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 ────────────────────────────────────────────────────────────
|
// ─── Health types ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
export interface MedicationUsage {
|
export interface MedicationUsage {
|
||||||
|
|
|
||||||
|
|
@ -26,7 +26,10 @@
|
||||||
<h2 class="text-2xl font-bold tracking-tight">Routines</h2>
|
<h2 class="text-2xl font-bold tracking-tight">Routines</h2>
|
||||||
<p class="text-muted-foreground">{data.routines.length} routines (last 30 days)</p>
|
<p class="text-muted-foreground">{data.routines.length} routines (last 30 days)</p>
|
||||||
</div>
|
</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>
|
</div>
|
||||||
|
|
||||||
{#if sortedDates.length}
|
{#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