feat: AI photo analysis for skin snapshots

Add POST /skincare/analyze-photos endpoint that accepts 1–3 skin
photos, sends them to Gemini vision, and returns a structured
SkinPhotoAnalysisResponse for pre-filling the snapshot form.

Extract shared Gemini client setup into innercontext/llm.py
(get_gemini_client) so both products and skincare use a single
default model (gemini-flash-latest) and API key check.

Frontend: AI photo card on /skin page with file picker, previews,
and auto-fill of all form fields from the analysis result.
New fields (skin_type, sebum_tzone, sebum_cheeks) added to form
and server action.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Piotr Oleszczyk 2026-02-28 12:47:51 +01:00
parent cc25ac4e65
commit 66ee473deb
8 changed files with 356 additions and 21 deletions

View file

@ -1,17 +1,16 @@
import json import json
import os
from datetime import date from datetime import date
from typing import Optional from typing import Optional
from uuid import UUID, uuid4 from uuid import UUID, uuid4
from fastapi import APIRouter, Depends, HTTPException, Query from fastapi import APIRouter, Depends, HTTPException, Query
from google import genai
from google.genai import types as genai_types from google.genai import types as genai_types
from pydantic import ValidationError from pydantic import ValidationError
from sqlmodel import Session, SQLModel, select from sqlmodel import Session, SQLModel, 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.llm import get_gemini_client
from innercontext.models import ( from innercontext.models import (
Product, Product,
ProductBase, ProductBase,
@ -350,11 +349,7 @@ OUTPUT SCHEMA (all fields optional — omit what you cannot determine):
@router.post("/parse-text", response_model=ProductParseResponse) @router.post("/parse-text", response_model=ProductParseResponse)
def parse_product_text(data: ProductParseRequest) -> ProductParseResponse: def parse_product_text(data: ProductParseRequest) -> ProductParseResponse:
api_key = os.environ.get("GEMINI_API_KEY") client, model = get_gemini_client()
if not api_key:
raise HTTPException(status_code=503, detail="GEMINI_API_KEY not configured")
model = os.environ.get("GEMINI_MODEL", "gemini-flash-latest")
client = genai.Client(api_key=api_key)
response = client.models.generate_content( response = client.models.generate_content(
model=model, model=model,
contents=f"Extract product data from this text:\n\n{data.text}", contents=f"Extract product data from this text:\n\n{data.text}",

View file

@ -1,12 +1,16 @@
import json
from datetime import date from datetime import date
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, File, HTTPException, UploadFile
from google.genai import types as genai_types
from pydantic import ValidationError
from sqlmodel import Session, SQLModel, select from sqlmodel import Session, SQLModel, 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.llm import get_gemini_client
from innercontext.models import ( from innercontext.models import (
SkinConditionSnapshot, SkinConditionSnapshot,
SkinConditionSnapshotBase, SkinConditionSnapshotBase,
@ -51,11 +55,118 @@ class SnapshotUpdate(SQLModel):
notes: Optional[str] = None notes: Optional[str] = None
class SkinPhotoAnalysisResponse(SQLModel):
overall_state: Optional[OverallSkinState] = None
trend: Optional[SkinTrend] = None
skin_type: Optional[SkinType] = None
hydration_level: Optional[int] = None
sebum_tzone: Optional[int] = None
sebum_cheeks: Optional[int] = None
sensitivity_level: Optional[int] = None
barrier_state: Optional[BarrierState] = None
active_concerns: Optional[list[SkinConcern]] = None
risks: Optional[list[str]] = None
priorities: Optional[list[str]] = None
notes: Optional[str] = None
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def _skin_photo_system_prompt() -> str:
return """\
You are a dermatology-trained skin assessment AI. Analyze the provided photo(s) of a person's
skin and return a structured JSON assessment.
RULES:
- Return ONLY raw JSON no markdown fences, no explanation.
- Omit any field you cannot confidently determine from the photos. Do not guess.
- All enum values must exactly match the allowed strings listed below.
- Numeric metrics use a 15 scale (1 = minimal, 5 = maximal).
- For trends: only populate if you have strong visual cues; otherwise omit.
- risks and priorities: short English phrases, max 10 words each.
- notes: 24 sentence paragraph describing key observations.
ENUM VALUES:
overall_state: "excellent" | "good" | "fair" | "poor"
trend: "improving" | "stable" | "worsening" | "fluctuating"
skin_type: "dry" | "oily" | "combination" | "sensitive" | "normal" | "acne_prone"
barrier_state: "intact" | "mildly_compromised" | "compromised"
active_concerns: "acne" | "rosacea" | "hyperpigmentation" | "aging" | "dehydration" |
"redness" | "damaged_barrier" | "pore_visibility" | "uneven_texture" | "sebum_excess"
METRICS (int 15, omit if not assessable):
hydration_level: 1=very dehydrated/dull 5=plump/luminous
sebum_tzone: 1=very dry T-zone 5=very oily T-zone
sebum_cheeks: 1=very dry cheeks 5=very oily cheeks
sensitivity_level: 1=no visible signs 5=severe redness/reactivity
OUTPUT (all fields optional):
{"overall_state":, "trend":, "skin_type":, "hydration_level":,
"sebum_tzone":, "sebum_cheeks":, "sensitivity_level":,
"barrier_state":, "active_concerns":[], "risks":[], "priorities":[], "notes":}
"""
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Routes # Routes
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
MAX_IMAGE_BYTES = 5 * 1024 * 1024 # 5 MB
@router.post("/analyze-photos", response_model=SkinPhotoAnalysisResponse)
async def analyze_skin_photos(
photos: list[UploadFile] = File(...),
) -> SkinPhotoAnalysisResponse:
if not (1 <= len(photos) <= 3):
raise HTTPException(status_code=422, detail="Send between 1 and 3 photos.")
client, model = get_gemini_client()
allowed = {"image/jpeg", "image/png", "image/webp"}
parts: list[genai_types.Part] = []
for photo in photos:
if photo.content_type not in allowed:
raise HTTPException(status_code=422, detail=f"Unsupported type: {photo.content_type}")
data = await photo.read()
if len(data) > MAX_IMAGE_BYTES:
raise HTTPException(status_code=413, detail=f"{photo.filename} exceeds 5 MB.")
parts.append(genai_types.Part.from_bytes(data=data, mime_type=photo.content_type))
parts.append(
genai_types.Part.from_text(
"Analyze the skin condition visible in the above photo(s) and return the JSON assessment."
)
)
try:
response = client.models.generate_content(
model=model,
contents=parts,
config=genai_types.GenerateContentConfig(
system_instruction=_skin_photo_system_prompt(),
response_mime_type="application/json",
max_output_tokens=1024,
temperature=0.0,
),
)
except Exception as e:
raise HTTPException(status_code=502, detail=f"Gemini API error: {e}")
try:
parsed = json.loads(response.text)
except json.JSONDecodeError as e:
raise HTTPException(status_code=502, detail=f"LLM returned invalid JSON: {e}")
try:
return SkinPhotoAnalysisResponse.model_validate(parsed)
except ValidationError as e:
raise HTTPException(status_code=422, detail=e.errors())
@router.get("", response_model=list[SkinConditionSnapshotPublic]) @router.get("", response_model=list[SkinConditionSnapshotPublic])
def list_snapshots( def list_snapshots(
from_date: Optional[date] = None, from_date: Optional[date] = None,

View file

@ -0,0 +1,21 @@
"""Shared helpers for Gemini API access."""
import os
from fastapi import HTTPException
from google import genai
_DEFAULT_MODEL = "gemini-flash-latest"
def get_gemini_client() -> tuple[genai.Client, str]:
"""Return an authenticated Gemini client and the configured model name.
Raises HTTP 503 if GEMINI_API_KEY is not set.
"""
api_key = os.environ.get("GEMINI_API_KEY")
if not api_key:
raise HTTPException(status_code=503, detail="GEMINI_API_KEY not configured")
model = os.environ.get("GEMINI_MODEL", _DEFAULT_MODEL)
return genai.Client(api_key=api_key), model

View file

@ -9,6 +9,7 @@ dependencies = [
"google-genai>=1.65.0", "google-genai>=1.65.0",
"psycopg>=3.3.3", "psycopg>=3.3.3",
"python-dotenv>=1.2.1", "python-dotenv>=1.2.1",
"python-multipart>=0.0.22",
"sqlmodel>=0.0.37", "sqlmodel>=0.0.37",
"uvicorn[standard]>=0.34.0", "uvicorn[standard]>=0.34.0",
] ]

11
backend/uv.lock generated
View file

@ -459,6 +459,7 @@ dependencies = [
{ name = "google-genai" }, { name = "google-genai" },
{ name = "psycopg" }, { name = "psycopg" },
{ name = "python-dotenv" }, { name = "python-dotenv" },
{ name = "python-multipart" },
{ name = "sqlmodel" }, { name = "sqlmodel" },
{ name = "uvicorn", extra = ["standard"] }, { name = "uvicorn", extra = ["standard"] },
] ]
@ -479,6 +480,7 @@ requires-dist = [
{ name = "google-genai", specifier = ">=1.65.0" }, { name = "google-genai", specifier = ">=1.65.0" },
{ name = "psycopg", specifier = ">=3.3.3" }, { name = "psycopg", specifier = ">=3.3.3" },
{ name = "python-dotenv", specifier = ">=1.2.1" }, { name = "python-dotenv", specifier = ">=1.2.1" },
{ name = "python-multipart", specifier = ">=0.0.22" },
{ name = "sqlmodel", specifier = ">=0.0.37" }, { name = "sqlmodel", specifier = ">=0.0.37" },
{ name = "uvicorn", extras = ["standard"], specifier = ">=0.34.0" }, { name = "uvicorn", extras = ["standard"], specifier = ">=0.34.0" },
] ]
@ -710,6 +712,15 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/14/1b/a298b06749107c305e1fe0f814c6c74aea7b2f1e10989cb30f544a1b3253/python_dotenv-1.2.1-py3-none-any.whl", hash = "sha256:b81ee9561e9ca4004139c6cbba3a238c32b03e4894671e181b671e8cb8425d61", size = 21230, upload-time = "2025-10-26T15:12:09.109Z" }, { url = "https://files.pythonhosted.org/packages/14/1b/a298b06749107c305e1fe0f814c6c74aea7b2f1e10989cb30f544a1b3253/python_dotenv-1.2.1-py3-none-any.whl", hash = "sha256:b81ee9561e9ca4004139c6cbba3a238c32b03e4894671e181b671e8cb8425d61", size = 21230, upload-time = "2025-10-26T15:12:09.109Z" },
] ]
[[package]]
name = "python-multipart"
version = "0.0.22"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/94/01/979e98d542a70714b0cb2b6728ed0b7c46792b695e3eaec3e20711271ca3/python_multipart-0.0.22.tar.gz", hash = "sha256:7340bef99a7e0032613f56dc36027b959fd3b30a787ed62d310e951f7c3a3a58", size = 37612, upload-time = "2026-01-25T10:15:56.219Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/1b/d0/397f9626e711ff749a95d96b7af99b9c566a9bb5129b8e4c10fc4d100304/python_multipart-0.0.22-py3-none-any.whl", hash = "sha256:2b2cd894c83d21bf49d702499531c7bafd057d730c201782048f7945d82de155", size = 24579, upload-time = "2026-01-25T10:15:54.811Z" },
]
[[package]] [[package]]
name = "pytokens" name = "pytokens"
version = "0.4.1" version = "0.4.1"

View file

@ -218,3 +218,29 @@ export const updateSkinSnapshot = (
body: Record<string, unknown> body: Record<string, unknown>
): Promise<SkinConditionSnapshot> => api.patch(`/skincare/${id}`, body); ): Promise<SkinConditionSnapshot> => api.patch(`/skincare/${id}`, body);
export const deleteSkinSnapshot = (id: string): Promise<void> => api.del(`/skincare/${id}`); export const deleteSkinSnapshot = (id: string): Promise<void> => api.del(`/skincare/${id}`);
export interface SkinPhotoAnalysisResponse {
overall_state?: string;
trend?: string;
skin_type?: string;
hydration_level?: number;
sebum_tzone?: number;
sebum_cheeks?: number;
sensitivity_level?: number;
barrier_state?: string;
active_concerns?: string[];
risks?: string[];
priorities?: string[];
notes?: string;
}
export async function analyzeSkinPhotos(files: File[]): Promise<SkinPhotoAnalysisResponse> {
const body = new FormData();
for (const file of files) body.append('photos', file);
const res = await fetch(`${PUBLIC_API_BASE}/skincare/analyze-photos`, { method: 'POST', body });
if (!res.ok) {
const detail = await res.json().catch(() => ({ detail: res.statusText }));
throw new Error(detail?.detail ?? res.statusText);
}
return res.json();
}

View file

@ -29,6 +29,10 @@ export const actions: Actions = {
.map((c) => c.trim()) .map((c) => c.trim())
.filter(Boolean) ?? []; .filter(Boolean) ?? [];
const skin_type = form.get('skin_type') as string;
const sebum_tzone = form.get('sebum_tzone') as string;
const sebum_cheeks = form.get('sebum_cheeks') as string;
const body: Record<string, unknown> = { snapshot_date, active_concerns }; const body: Record<string, unknown> = { snapshot_date, active_concerns };
if (overall_state) body.overall_state = overall_state; if (overall_state) body.overall_state = overall_state;
if (trend) body.trend = trend; if (trend) body.trend = trend;
@ -36,6 +40,9 @@ export const actions: Actions = {
if (hydration_level) body.hydration_level = Number(hydration_level); if (hydration_level) body.hydration_level = Number(hydration_level);
if (sensitivity_level) body.sensitivity_level = Number(sensitivity_level); if (sensitivity_level) body.sensitivity_level = Number(sensitivity_level);
if (barrier_state) body.barrier_state = barrier_state; if (barrier_state) body.barrier_state = barrier_state;
if (skin_type) body.skin_type = skin_type;
if (sebum_tzone) body.sebum_tzone = Number(sebum_tzone);
if (sebum_cheeks) body.sebum_cheeks = Number(sebum_cheeks);
try { try {
await createSkinSnapshot(body); await createSkinSnapshot(body);

View file

@ -1,6 +1,7 @@
<script lang="ts"> <script lang="ts">
import { enhance } from '$app/forms'; import { enhance } from '$app/forms';
import type { ActionData, PageData } from './$types'; import type { ActionData, PageData } from './$types';
import { analyzeSkinPhotos } from '$lib/api';
import { Badge } from '$lib/components/ui/badge'; import { Badge } from '$lib/components/ui/badge';
import { Button } from '$lib/components/ui/button'; import { Button } from '$lib/components/ui/button';
import { Card, CardContent, CardHeader, CardTitle } from '$lib/components/ui/card'; import { Card, CardContent, CardHeader, CardTitle } from '$lib/components/ui/card';
@ -13,6 +14,7 @@
const states = ['excellent', 'good', 'fair', 'poor']; const states = ['excellent', 'good', 'fair', 'poor'];
const trends = ['improving', 'stable', 'worsening', 'fluctuating']; const trends = ['improving', 'stable', 'worsening', 'fluctuating'];
const barrierStates = ['intact', 'mildly_compromised', 'compromised']; const barrierStates = ['intact', 'mildly_compromised', 'compromised'];
const skinTypes = ['dry', 'oily', 'combination', 'sensitive', 'normal', 'acne_prone'];
const stateColors: Record<string, string> = { const stateColors: Record<string, string> = {
excellent: 'bg-green-100 text-green-800', excellent: 'bg-green-100 text-green-800',
@ -22,13 +24,62 @@
}; };
let showForm = $state(false); let showForm = $state(false);
// Form state (bound to inputs so AI can pre-fill)
let snapshotDate = $state(new Date().toISOString().slice(0, 10));
let overallState = $state(''); let overallState = $state('');
let trend = $state(''); let trend = $state('');
let barrierState = $state(''); let barrierState = $state('');
let skinType = $state('');
let hydrationLevel = $state('');
let sensitivityLevel = $state('');
let sebumTzone = $state('');
let sebumCheeks = $state('');
let activeConcernsRaw = $state('');
let notes = $state('');
// AI photo analysis state
let aiPanelOpen = $state(false);
let selectedFiles = $state<File[]>([]);
let previewUrls = $state<string[]>([]);
let aiLoading = $state(false);
let aiError = $state('');
const sortedSnapshots = $derived( const sortedSnapshots = $derived(
[...data.snapshots].sort((a, b) => b.snapshot_date.localeCompare(a.snapshot_date)) [...data.snapshots].sort((a, b) => b.snapshot_date.localeCompare(a.snapshot_date))
); );
function handleFileSelect(e: Event) {
const input = e.target as HTMLInputElement;
const files = Array.from(input.files ?? []).slice(0, 3);
selectedFiles = files;
previewUrls.forEach(URL.revokeObjectURL);
previewUrls = files.map((f) => URL.createObjectURL(f));
}
async function analyzePhotos() {
if (!selectedFiles.length) return;
aiLoading = true;
aiError = '';
try {
const r = await analyzeSkinPhotos(selectedFiles);
if (r.overall_state) overallState = r.overall_state;
if (r.trend) trend = r.trend;
if (r.skin_type) skinType = r.skin_type;
if (r.barrier_state) barrierState = r.barrier_state;
if (r.hydration_level != null) hydrationLevel = String(r.hydration_level);
if (r.sensitivity_level != null) sensitivityLevel = String(r.sensitivity_level);
if (r.sebum_tzone != null) sebumTzone = String(r.sebum_tzone);
if (r.sebum_cheeks != null) sebumCheeks = String(r.sebum_cheeks);
if (r.active_concerns?.length) activeConcernsRaw = r.active_concerns.join(', ');
if (r.notes) notes = r.notes;
aiPanelOpen = false;
} catch (e) {
aiError = (e as Error).message;
} finally {
aiLoading = false;
}
}
</script> </script>
<svelte:head><title>Skin — innercontext</title></svelte:head> <svelte:head><title>Skin — innercontext</title></svelte:head>
@ -52,14 +103,67 @@
{/if} {/if}
{#if showForm} {#if showForm}
<!-- AI photo analysis card -->
<Card>
<CardHeader>
<button
type="button"
class="flex w-full items-center justify-between text-left"
onclick={() => (aiPanelOpen = !aiPanelOpen)}
>
<CardTitle>AI analysis from photos</CardTitle>
<span class="text-sm text-muted-foreground">{aiPanelOpen ? '▲' : '▼'}</span>
</button>
</CardHeader>
{#if aiPanelOpen}
<CardContent class="space-y-3">
<p class="text-sm text-muted-foreground">
Upload 13 photos of your skin. AI will pre-fill the form fields below.
</p>
<input
type="file"
accept="image/jpeg,image/png,image/webp"
multiple
onchange={handleFileSelect}
class="block w-full text-sm text-muted-foreground
file:mr-4 file:rounded-md file:border-0 file:bg-primary
file:px-3 file:py-1.5 file:text-sm file:font-medium file:text-primary-foreground"
/>
{#if previewUrls.length}
<div class="flex flex-wrap gap-2">
{#each previewUrls as url (url)}
<img src={url} alt="skin preview" class="h-24 w-24 rounded-md object-cover border" />
{/each}
</div>
{/if}
{#if aiError}
<p class="text-sm text-destructive">{aiError}</p>
{/if}
<Button
type="button"
onclick={analyzePhotos}
disabled={aiLoading || !selectedFiles.length}
>
{aiLoading ? 'Analyzing…' : 'Analyze photos'}
</Button>
</CardContent>
{/if}
</Card>
<!-- New snapshot form -->
<Card> <Card>
<CardHeader><CardTitle>New skin snapshot</CardTitle></CardHeader> <CardHeader><CardTitle>New skin snapshot</CardTitle></CardHeader>
<CardContent> <CardContent>
<form method="POST" action="?/create" use:enhance class="grid grid-cols-2 gap-4"> <form method="POST" action="?/create" use:enhance class="grid grid-cols-2 gap-4">
<div class="space-y-1"> <div class="space-y-1">
<Label for="snapshot_date">Date *</Label> <Label for="snapshot_date">Date *</Label>
<Input id="snapshot_date" name="snapshot_date" type="date" <Input
value={new Date().toISOString().slice(0, 10)} required /> id="snapshot_date"
name="snapshot_date"
type="date"
bind:value={snapshotDate}
required
/>
</div> </div>
<div class="space-y-1"> <div class="space-y-1">
<Label>Overall state</Label> <Label>Overall state</Label>
@ -67,7 +171,7 @@
<Select type="single" value={overallState} onValueChange={(v) => (overallState = v)}> <Select type="single" value={overallState} onValueChange={(v) => (overallState = v)}>
<SelectTrigger>{overallState || 'Select'}</SelectTrigger> <SelectTrigger>{overallState || 'Select'}</SelectTrigger>
<SelectContent> <SelectContent>
{#each states as s} {#each states as s (s)}
<SelectItem value={s}>{s}</SelectItem> <SelectItem value={s}>{s}</SelectItem>
{/each} {/each}
</SelectContent> </SelectContent>
@ -79,19 +183,33 @@
<Select type="single" value={trend} onValueChange={(v) => (trend = v)}> <Select type="single" value={trend} onValueChange={(v) => (trend = v)}>
<SelectTrigger>{trend || 'Select'}</SelectTrigger> <SelectTrigger>{trend || 'Select'}</SelectTrigger>
<SelectContent> <SelectContent>
{#each trends as t} {#each trends as t (t)}
<SelectItem value={t}>{t}</SelectItem> <SelectItem value={t}>{t}</SelectItem>
{/each} {/each}
</SelectContent> </SelectContent>
</Select> </Select>
</div> </div>
<div class="space-y-1">
<Label>Skin type</Label>
<input type="hidden" name="skin_type" value={skinType} />
<Select type="single" value={skinType} onValueChange={(v) => (skinType = v)}>
<SelectTrigger>{skinType ? skinType.replace(/_/g, ' ') : 'Select'}</SelectTrigger>
<SelectContent>
{#each skinTypes as st (st)}
<SelectItem value={st}>{st.replace(/_/g, ' ')}</SelectItem>
{/each}
</SelectContent>
</Select>
</div>
<div class="space-y-1"> <div class="space-y-1">
<Label>Barrier state</Label> <Label>Barrier state</Label>
<input type="hidden" name="barrier_state" value={barrierState} /> <input type="hidden" name="barrier_state" value={barrierState} />
<Select type="single" value={barrierState} onValueChange={(v) => (barrierState = v)}> <Select type="single" value={barrierState} onValueChange={(v) => (barrierState = v)}>
<SelectTrigger>{barrierState ? barrierState.replace(/_/g, ' ') : 'Select'}</SelectTrigger> <SelectTrigger
>{barrierState ? barrierState.replace(/_/g, ' ') : 'Select'}</SelectTrigger
>
<SelectContent> <SelectContent>
{#each barrierStates as b} {#each barrierStates as b (b)}
<SelectItem value={b}>{b.replace(/_/g, ' ')}</SelectItem> <SelectItem value={b}>{b.replace(/_/g, ' ')}</SelectItem>
{/each} {/each}
</SelectContent> </SelectContent>
@ -99,19 +217,60 @@
</div> </div>
<div class="space-y-1"> <div class="space-y-1">
<Label for="hydration_level">Hydration (15)</Label> <Label for="hydration_level">Hydration (15)</Label>
<Input id="hydration_level" name="hydration_level" type="number" min="1" max="5" /> <Input
id="hydration_level"
name="hydration_level"
type="number"
min="1"
max="5"
bind:value={hydrationLevel}
/>
</div> </div>
<div class="space-y-1"> <div class="space-y-1">
<Label for="sensitivity_level">Sensitivity (15)</Label> <Label for="sensitivity_level">Sensitivity (15)</Label>
<Input id="sensitivity_level" name="sensitivity_level" type="number" min="1" max="5" /> <Input
id="sensitivity_level"
name="sensitivity_level"
type="number"
min="1"
max="5"
bind:value={sensitivityLevel}
/>
</div>
<div class="space-y-1">
<Label for="sebum_tzone">Sebum T-zone (15)</Label>
<Input
id="sebum_tzone"
name="sebum_tzone"
type="number"
min="1"
max="5"
bind:value={sebumTzone}
/>
</div>
<div class="space-y-1">
<Label for="sebum_cheeks">Sebum cheeks (15)</Label>
<Input
id="sebum_cheeks"
name="sebum_cheeks"
type="number"
min="1"
max="5"
bind:value={sebumCheeks}
/>
</div> </div>
<div class="space-y-1 col-span-2"> <div class="space-y-1 col-span-2">
<Label for="active_concerns">Active concerns (comma-separated)</Label> <Label for="active_concerns">Active concerns (comma-separated)</Label>
<Input id="active_concerns" name="active_concerns" placeholder="acne, redness, dehydration" /> <Input
id="active_concerns"
name="active_concerns"
placeholder="acne, redness, dehydration"
bind:value={activeConcernsRaw}
/>
</div> </div>
<div class="space-y-1 col-span-2"> <div class="space-y-1 col-span-2">
<Label for="notes">Notes</Label> <Label for="notes">Notes</Label>
<Input id="notes" name="notes" /> <Input id="notes" name="notes" bind:value={notes} />
</div> </div>
<div class="col-span-2"> <div class="col-span-2">
<Button type="submit">Add snapshot</Button> <Button type="submit">Add snapshot</Button>
@ -122,14 +281,18 @@
{/if} {/if}
<div class="space-y-4"> <div class="space-y-4">
{#each sortedSnapshots as snap} {#each sortedSnapshots as snap (snap.id)}
<Card> <Card>
<CardContent class="pt-4"> <CardContent class="pt-4">
<div class="flex items-center justify-between mb-3"> <div class="flex items-center justify-between mb-3">
<span class="font-medium">{snap.snapshot_date}</span> <span class="font-medium">{snap.snapshot_date}</span>
<div class="flex gap-2"> <div class="flex gap-2">
{#if snap.overall_state} {#if snap.overall_state}
<span class="rounded-full px-2 py-0.5 text-xs font-medium {stateColors[snap.overall_state] ?? ''}"> <span
class="rounded-full px-2 py-0.5 text-xs font-medium {stateColors[
snap.overall_state
] ?? ''}"
>
{snap.overall_state} {snap.overall_state}
</span> </span>
{/if} {/if}
@ -160,7 +323,7 @@
</div> </div>
{#if snap.active_concerns.length} {#if snap.active_concerns.length}
<div class="flex flex-wrap gap-1"> <div class="flex flex-wrap gap-1">
{#each snap.active_concerns as c} {#each snap.active_concerns as c (c)}
<Badge variant="secondary" class="text-xs">{c.replace(/_/g, ' ')}</Badge> <Badge variant="secondary" class="text-xs">{c.replace(/_/g, ' ')}</Badge>
{/each} {/each}
</div> </div>