Compare commits

...

10 commits

Author SHA1 Message Date
142fbe8530 fix(frontend): resolve TypeScript type errors from skin model refactor
- Remove s.trend from dashboard (field removed from SkinConditionSnapshot)
- Replace trend with texture in SkinPhotoAnalysisResponse (api.ts)
- Use record_id instead of id for LabResult and MedicationEntry keyed each blocks

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-28 18:01:17 +01:00
ac829171d9 feat(mcp): add FastMCP server with 14 tools for LLM agent access
- Add backend/innercontext/mcp_server.py with tools covering products,
  inventory, routines, skin snapshots, medications, lab results, and
  grooming schedule
- Mount MCP app at /mcp in main.py using combine_lifespans
- Fix test isolation: patch app.router.lifespan_context in conftest to
  avoid StreamableHTTPSessionManager single-run limitation

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-28 17:59:11 +01:00
4954d4f449 refactor(skin): replace trend with texture field on SkinConditionSnapshot
Remove the derived `trend` field (better computed from history by the MCP
agent) and add `texture: smooth|rough|flaky|bumpy` which LLM can reliably
assess from photos. Updates model, API, system prompt, tests, and frontend.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-28 13:25:57 +01:00
abf9593857 fix: correct Part.from_text() call and increase max_output_tokens for skin analysis
Pass `text=` as keyword arg to Part.from_text() and raise max_output_tokens
from 1024 to 2048 to prevent JSON truncation in the notes field.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-28 13:17:22 +01:00
ac28eb30d1 fix: remove redundant assignments to \$derived variables before goto
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-28 13:00:19 +01:00
853019075d fix: remove AM/PM filter from routines, add keyed each blocks
Remove the All/AM/PM filter buttons and part_of_day param from
the routines list page — date-based sorting/filtering is preferred.

Add missing keyed {#each} blocks across all pages to follow
Svelte 5 best practice (dashboard, products, medications,
lab results, routines list and detail, skin).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-28 12:57:56 +01:00
66ee473deb 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>
2026-02-28 12:47:51 +01:00
cc25ac4e65 fix: redirect to product list after creating a product
Previously redirected to /products/{id} (edit page) which looks
identical to the create form, making it appear as if nothing happened.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-27 23:11:41 +01:00
e60dee5015 style: reformat import block in main.py
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-27 23:05:14 +01:00
31e030eaac feat: AI pre-fill for product form via Gemini API
Add POST /products/parse-text endpoint that accepts raw product text,
calls Gemini (google-genai) with a structured extraction prompt, and
returns a partial ProductParseResponse. Frontend gains a collapsible
"AI pre-fill" card at the top of ProductForm that merges the LLM
response into all form fields reactively.

- Backend: ProductParseRequest/Response schemas, system prompt with
  enum constraints, temperature=0.0 for deterministic extraction,
  effect_profile always returned in full
- Frontend: parseProductText() in api.ts; controlled $state bindings
  for all text/number/checkbox inputs; applyAiResult() merges response

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-27 23:04:24 +01:00
25 changed files with 2218 additions and 188 deletions

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, Query from fastapi import APIRouter, Depends, HTTPException, Query
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 (
Product, Product,
ProductBase, ProductBase,
@ -95,6 +99,50 @@ class ProductUpdate(SQLModel):
personal_repurchase_intent: Optional[bool] = None personal_repurchase_intent: Optional[bool] = None
class ProductParseRequest(SQLModel):
text: str
class ProductParseResponse(SQLModel):
name: Optional[str] = None
brand: Optional[str] = None
line_name: Optional[str] = None
sku: Optional[str] = None
url: Optional[str] = None
barcode: Optional[str] = None
category: Optional[ProductCategory] = None
recommended_time: Optional[DayTime] = None
texture: Optional[TextureType] = None
absorption_speed: Optional[AbsorptionSpeed] = None
leave_on: Optional[bool] = None
price_tier: Optional[PriceTier] = None
size_ml: Optional[float] = None
full_weight_g: Optional[float] = None
empty_weight_g: Optional[float] = None
pao_months: Optional[int] = None
inci: Optional[list[str]] = None
actives: Optional[list[ActiveIngredient]] = None
recommended_for: Optional[list[SkinType]] = None
targets: Optional[list[SkinConcern]] = None
contraindications: Optional[list[str]] = None
usage_notes: Optional[str] = None
fragrance_free: Optional[bool] = None
essential_oils_free: Optional[bool] = None
alcohol_denat_free: Optional[bool] = None
pregnancy_safe: Optional[bool] = None
product_effect_profile: Optional[ProductEffectProfile] = None
ph_min: Optional[float] = None
ph_max: Optional[float] = None
incompatible_with: Optional[list[ProductInteraction]] = None
synergizes_with: Optional[list[str]] = None
context_rules: Optional[ProductContext] = None
min_interval_hours: Optional[int] = None
max_frequency_per_week: Optional[int] = None
is_medication: Optional[bool] = None
is_tool: Optional[bool] = None
needle_length_mm: Optional[float] = None
class InventoryCreate(SQLModel): class InventoryCreate(SQLModel):
is_opened: bool = False is_opened: bool = False
opened_at: Optional[date] = None opened_at: Optional[date] = None
@ -168,6 +216,160 @@ def create_product(data: ProductCreate, session: Session = Depends(get_session))
return product return product
def _product_parse_system_prompt() -> str:
return """\
You are a skincare and cosmetics product data extraction expert. \
Given raw text (product page copy, ingredient list, label scan, etc.), \
extract structured product data and return it as a single JSON object.
RULES:
- Return ONLY raw JSON no markdown code fences, no explanation, no preamble.
- Omit any field you cannot confidently determine from the text. Do not guess.
- All enum values must exactly match the allowed strings listed below.
- For INCI lists: return each ingredient as a separate string in the array, \
preserving standard INCI names exactly as they appear.
- For actives: extract name, concentration (numeric, 0100), functions \
(use the allowed strings), and strength/irritation level if inferable.
- For effect_profile scores (05 int): ALWAYS return the full product_effect_profile \
object with all 13 fields. Infer each score from ingredient activity and product claims. \
Use 0 only when you truly have no basis for an estimate.
- For pH: extract from explicit mention (e.g. "pH 5.5", "pH range 4.05.0"). \
Do not infer from ingredients alone.
- For context_rules: infer from usage instructions and ingredient interactions \
(e.g. "do not use with AHAs" safe_after_acids: false).
- fragrance_free / essential_oils_free / alcohol_denat_free: infer from INCI \
or explicit claims. Fragrance = "Parfum" or "Fragrance" in INCI fragrance_free: false.
- For leave_on: true = leave-on treatment, false = rinse-off (cleanser, mask to rinse).
- recommended_time: "am" if contains SPF or vitamin C; "pm" if retinoid/retinol; \
"both" otherwise (when unclear, use "both").
ENUM ALLOWED VALUES (use ONLY these exact strings):
category: "cleanser" | "toner" | "essence" | "serum" | "moisturizer" | "spf" | \
"mask" | "exfoliant" | "hair_treatment" | "tool" | "spot_treatment" | "oil"
recommended_time: "am" | "pm" | "both"
texture: "watery" | "gel" | "emulsion" | "cream" | "oil" | "balm" | "foam" | "fluid"
absorption_speed: "very_fast" | "fast" | "moderate" | "slow" | "very_slow"
price_tier: "budget" | "mid" | "premium" | "luxury"
recommended_for (array, pick applicable):
"dry" | "oily" | "combination" | "sensitive" | "normal" | "acne_prone"
targets (array, pick applicable):
"acne" | "rosacea" | "hyperpigmentation" | "aging" | "dehydration" | "redness" | \
"damaged_barrier" | "pore_visibility" | "uneven_texture" | "hair_growth" | "sebum_excess"
actives[].functions (array, pick applicable):
"humectant" | "emollient" | "occlusive" | "exfoliant_aha" | "exfoliant_bha" | \
"exfoliant_pha" | "retinoid" | "antioxidant" | "soothing" | "barrier_support" | \
"brightening" | "anti_acne" | "ceramide" | "niacinamide" | "sunscreen" | "peptide" | \
"hair_growth_stimulant" | "prebiotic" | "vitamin_c"
actives[].strength_level: 1 (low) | 2 (medium) | 3 (high)
actives[].irritation_potential: 1 (low) | 2 (medium) | 3 (high)
incompatible_with[].scope: "same_step" | "same_day" | "same_period"
OUTPUT SCHEMA (all fields optional omit what you cannot determine):
{
"name": string,
"brand": string,
"line_name": string,
"sku": string,
"url": string,
"barcode": string,
"category": string,
"recommended_time": string,
"texture": string,
"absorption_speed": string,
"leave_on": boolean,
"price_tier": string,
"size_ml": number,
"full_weight_g": number,
"empty_weight_g": number,
"pao_months": integer,
"inci": [string, ...],
"actives": [
{
"name": string,
"percent": number,
"functions": [string, ...],
"strength_level": 1|2|3,
"irritation_potential": 1|2|3
}
],
"recommended_for": [string, ...],
"targets": [string, ...],
"contraindications": [string, ...],
"usage_notes": string,
"fragrance_free": boolean,
"essential_oils_free": boolean,
"alcohol_denat_free": boolean,
"pregnancy_safe": boolean,
"product_effect_profile": {
"hydration_immediate": integer (0-5),
"hydration_long_term": integer (0-5),
"barrier_repair_strength": integer (0-5),
"soothing_strength": integer (0-5),
"exfoliation_strength": integer (0-5),
"retinoid_strength": integer (0-5),
"irritation_risk": integer (0-5),
"comedogenic_risk": integer (0-5),
"barrier_disruption_risk": integer (0-5),
"dryness_risk": integer (0-5),
"brightening_strength": integer (0-5),
"anti_acne_strength": integer (0-5),
"anti_aging_strength": integer (0-5)
},
"ph_min": number,
"ph_max": number,
"incompatible_with": [
{"target": string, "scope": string, "reason": string}
],
"synergizes_with": [string, ...],
"context_rules": {
"safe_after_shaving": boolean,
"safe_after_acids": boolean,
"safe_after_retinoids": boolean,
"safe_with_compromised_barrier": boolean,
"low_uv_only": boolean
},
"min_interval_hours": integer,
"max_frequency_per_week": integer,
"is_medication": boolean,
"is_tool": boolean,
"needle_length_mm": number
}
"""
@router.post("/parse-text", response_model=ProductParseResponse)
def parse_product_text(data: ProductParseRequest) -> ProductParseResponse:
client, model = get_gemini_client()
response = client.models.generate_content(
model=model,
contents=f"Extract product data from this text:\n\n{data.text}",
config=genai_types.GenerateContentConfig(
system_instruction=_product_parse_system_prompt(),
response_mime_type="application/json",
max_output_tokens=4096,
temperature=0.0,
),
)
try:
parsed = json.loads(response.text)
except (json.JSONDecodeError, Exception) as e:
raise HTTPException(status_code=502, detail=f"LLM returned invalid JSON: {e}")
try:
return ProductParseResponse.model_validate(parsed)
except ValidationError as e:
raise HTTPException(status_code=422, detail=e.errors())
@router.get("/{product_id}", response_model=ProductWithInventory) @router.get("/{product_id}", response_model=ProductWithInventory)
def get_product(product_id: UUID, session: Session = Depends(get_session)): def get_product(product_id: UUID, session: Session = Depends(get_session)):
product = get_or_404(session, Product, product_id) product = get_or_404(session, Product, product_id)

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,
@ -16,7 +20,7 @@ from innercontext.models.enums import (
BarrierState, BarrierState,
OverallSkinState, OverallSkinState,
SkinConcern, SkinConcern,
SkinTrend, SkinTexture,
SkinType, SkinType,
) )
@ -35,8 +39,8 @@ class SnapshotCreate(SkinConditionSnapshotBase):
class SnapshotUpdate(SQLModel): class SnapshotUpdate(SQLModel):
snapshot_date: Optional[date] = None snapshot_date: Optional[date] = None
overall_state: Optional[OverallSkinState] = None overall_state: Optional[OverallSkinState] = None
trend: Optional[SkinTrend] = None
skin_type: Optional[SkinType] = None skin_type: Optional[SkinType] = None
texture: Optional[SkinTexture] = None
hydration_level: Optional[int] = None hydration_level: Optional[int] = None
sebum_tzone: Optional[int] = None sebum_tzone: Optional[int] = None
@ -51,11 +55,117 @@ class SnapshotUpdate(SQLModel):
notes: Optional[str] = None notes: Optional[str] = None
class SkinPhotoAnalysisResponse(SQLModel):
overall_state: Optional[OverallSkinState] = None
skin_type: Optional[SkinType] = None
texture: Optional[SkinTexture] = 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).
- 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"
skin_type: "dry" | "oily" | "combination" | "sensitive" | "normal" | "acne_prone"
texture: "smooth" | "rough" | "flaky" | "bumpy"
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":, "skin_type":, "texture":, "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(
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=2048,
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

@ -0,0 +1,438 @@
from __future__ import annotations
from datetime import date, timedelta
from typing import Optional
from uuid import UUID
from fastmcp import FastMCP
from sqlmodel import Session, col, select
from db import engine
from innercontext.models import (
GroomingSchedule,
LabResult,
MedicationEntry,
MedicationUsage,
Product,
ProductInventory,
Routine,
RoutineStep,
SkinConditionSnapshot,
)
mcp = FastMCP("innercontext")
# ── Products ──────────────────────────────────────────────────────────────────
@mcp.tool()
def get_products(
category: Optional[str] = None,
is_medication: bool = False,
is_tool: bool = False,
) -> list[dict]:
"""List products. By default returns skincare products (excludes medications and tools).
Pass is_medication=True or is_tool=True to retrieve those categories instead."""
with Session(engine) as session:
stmt = select(Product)
if category is not None:
stmt = stmt.where(Product.category == category)
stmt = stmt.where(Product.is_medication == is_medication)
stmt = stmt.where(Product.is_tool == is_tool)
products = session.exec(stmt).all()
return [p.to_llm_context() for p in products]
@mcp.tool()
def get_product(product_id: str) -> dict:
"""Get full context for a single product (UUID) including all inventory entries."""
with Session(engine) as session:
product = session.get(Product, UUID(product_id))
if product is None:
return {"error": f"Product {product_id} not found"}
ctx = product.to_llm_context()
entries = session.exec(
select(ProductInventory).where(ProductInventory.product_id == product.id)
).all()
ctx["inventory"] = [
{
"id": str(inv.id),
"is_opened": inv.is_opened,
"opened_at": inv.opened_at.isoformat() if inv.opened_at else None,
"finished_at": inv.finished_at.isoformat() if inv.finished_at else None,
"expiry_date": inv.expiry_date.isoformat() if inv.expiry_date else None,
"current_weight_g": inv.current_weight_g,
}
for inv in entries
]
return ctx
# ── Inventory ─────────────────────────────────────────────────────────────────
@mcp.tool()
def get_open_inventory() -> list[dict]:
"""Return all currently open packages (is_opened=True, finished_at=None)
with product name, opening date, weight, and expiry date."""
with Session(engine) as session:
stmt = (
select(ProductInventory, Product)
.join(Product, ProductInventory.product_id == Product.id)
.where(ProductInventory.is_opened == True) # noqa: E712
.where(ProductInventory.finished_at == None) # noqa: E711
)
rows = session.exec(stmt).all()
return [
{
"inventory_id": str(inv.id),
"product_id": str(product.id),
"product_name": product.name,
"brand": product.brand,
"opened_at": inv.opened_at.isoformat() if inv.opened_at else None,
"current_weight_g": inv.current_weight_g,
"expiry_date": inv.expiry_date.isoformat() if inv.expiry_date else None,
}
for inv, product in rows
]
# ── Routines ──────────────────────────────────────────────────────────────────
@mcp.tool()
def get_recent_routines(days: int = 14) -> list[dict]:
"""Get skincare routines from the last N days, newest first.
Each routine includes its ordered steps with product name or action."""
with Session(engine) as session:
cutoff = date.today() - timedelta(days=days)
routines = session.exec(
select(Routine)
.where(Routine.routine_date >= cutoff)
.order_by(col(Routine.routine_date).desc())
).all()
result = []
for routine in routines:
steps = session.exec(
select(RoutineStep)
.where(RoutineStep.routine_id == routine.id)
.order_by(RoutineStep.order_index)
).all()
steps_data = []
for step in steps:
step_dict: dict = {"order": step.order_index}
if step.product_id:
product = session.get(Product, step.product_id)
if product:
step_dict["product"] = product.name
step_dict["product_id"] = str(product.id)
if step.action_type:
step_dict["action"] = (
step.action_type.value
if hasattr(step.action_type, "value")
else str(step.action_type)
)
if step.action_notes:
step_dict["notes"] = step.action_notes
if step.dose:
step_dict["dose"] = step.dose
if step.region:
step_dict["region"] = step.region
steps_data.append(step_dict)
result.append(
{
"id": str(routine.id),
"date": routine.routine_date.isoformat(),
"part_of_day": (
routine.part_of_day.value
if hasattr(routine.part_of_day, "value")
else str(routine.part_of_day)
),
"notes": routine.notes,
"steps": steps_data,
}
)
return result
# ── Skin snapshots ────────────────────────────────────────────────────────────
def _snapshot_to_dict(s: SkinConditionSnapshot, *, full: bool) -> dict:
def ev(v: object) -> object:
return v.value if v is not None and hasattr(v, "value") else v
d: dict = {
"id": str(s.id),
"date": s.snapshot_date.isoformat(),
"overall_state": ev(s.overall_state),
"hydration_level": s.hydration_level,
"sensitivity_level": s.sensitivity_level,
"barrier_state": ev(s.barrier_state),
"active_concerns": [ev(c) for c in (s.active_concerns or [])],
}
if full:
d.update(
{
"skin_type": ev(s.skin_type),
"texture": ev(s.texture),
"sebum_tzone": s.sebum_tzone,
"sebum_cheeks": s.sebum_cheeks,
"risks": s.risks or [],
"priorities": s.priorities or [],
"notes": s.notes,
}
)
return d
@mcp.tool()
def get_latest_skin_snapshot() -> dict | None:
"""Get the most recent skin condition snapshot with all metrics."""
with Session(engine) as session:
snapshot = session.exec(
select(SkinConditionSnapshot).order_by(
col(SkinConditionSnapshot.snapshot_date).desc()
)
).first()
if snapshot is None:
return None
return _snapshot_to_dict(snapshot, full=True)
@mcp.tool()
def get_skin_history(weeks: int = 8) -> list[dict]:
"""Get skin condition snapshots from the last N weeks with key metrics."""
with Session(engine) as session:
cutoff = date.today() - timedelta(weeks=weeks)
snapshots = session.exec(
select(SkinConditionSnapshot)
.where(SkinConditionSnapshot.snapshot_date >= cutoff)
.order_by(col(SkinConditionSnapshot.snapshot_date).desc())
).all()
return [_snapshot_to_dict(s, full=False) for s in snapshots]
@mcp.tool()
def get_skin_snapshot_dates() -> list[str]:
"""List all dates (YYYY-MM-DD) for which skin snapshots exist, newest first."""
with Session(engine) as session:
snapshots = session.exec(
select(SkinConditionSnapshot).order_by(
col(SkinConditionSnapshot.snapshot_date).desc()
)
).all()
return [s.snapshot_date.isoformat() for s in snapshots]
@mcp.tool()
def get_skin_snapshot(snapshot_date: str) -> dict | None:
"""Get the full skin condition snapshot for a specific date (YYYY-MM-DD)."""
with Session(engine) as session:
target = date.fromisoformat(snapshot_date)
snapshot = session.exec(
select(SkinConditionSnapshot).where(
SkinConditionSnapshot.snapshot_date == target
)
).first()
if snapshot is None:
return None
return _snapshot_to_dict(snapshot, full=True)
# ── Health / medications ───────────────────────────────────────────────────────
@mcp.tool()
def get_medications() -> list[dict]:
"""Get all medication entries with their currently active usage records
(valid_to IS NULL or >= today)."""
with Session(engine) as session:
medications = session.exec(select(MedicationEntry)).all()
today = date.today()
result = []
for med in medications:
usages = session.exec(
select(MedicationUsage)
.where(MedicationUsage.medication_record_id == med.record_id)
.where(
(MedicationUsage.valid_to == None) # noqa: E711
| (col(MedicationUsage.valid_to) >= today)
)
).all()
result.append(
{
"id": str(med.record_id),
"product_name": med.product_name,
"kind": (
med.kind.value if hasattr(med.kind, "value") else str(med.kind)
),
"active_substance": med.active_substance,
"formulation": med.formulation,
"route": med.route,
"notes": med.notes,
"active_usages": [
{
"id": str(u.record_id),
"dose": (
f"{u.dose_value} {u.dose_unit}"
if u.dose_value is not None and u.dose_unit
else None
),
"frequency": u.frequency,
"schedule_text": u.schedule_text,
"as_needed": u.as_needed,
"valid_from": u.valid_from.isoformat()
if u.valid_from
else None,
"valid_to": u.valid_to.isoformat() if u.valid_to else None,
}
for u in usages
],
}
)
return result
# ── Expiring inventory ────────────────────────────────────────────────────────
@mcp.tool()
def get_expiring_inventory(days: int = 30) -> list[dict]:
"""List open packages whose expiry date falls within the next N days.
Sorted by days remaining (soonest first)."""
with Session(engine) as session:
cutoff = date.today() + timedelta(days=days)
stmt = (
select(ProductInventory, Product)
.join(Product, ProductInventory.product_id == Product.id)
.where(ProductInventory.is_opened == True) # noqa: E712
.where(ProductInventory.finished_at == None) # noqa: E711
.where(ProductInventory.expiry_date != None) # noqa: E711
.where(col(ProductInventory.expiry_date) <= cutoff)
)
rows = session.exec(stmt).all()
today = date.today()
result = [
{
"product_name": product.name,
"brand": product.brand,
"expiry_date": inv.expiry_date.isoformat() if inv.expiry_date else None,
"days_remaining": (inv.expiry_date - today).days
if inv.expiry_date
else None,
"current_weight_g": inv.current_weight_g,
}
for inv, product in rows
]
return sorted(result, key=lambda x: x["days_remaining"] or 0)
# ── Grooming schedule ─────────────────────────────────────────────────────────
@mcp.tool()
def get_grooming_schedule() -> list[dict]:
"""Get the full grooming schedule sorted by day of week (0=Monday, 6=Sunday)."""
with Session(engine) as session:
entries = session.exec(
select(GroomingSchedule).order_by(GroomingSchedule.day_of_week)
).all()
return [
{
"id": str(e.id),
"day_of_week": e.day_of_week,
"action": (
e.action.value if hasattr(e.action, "value") else str(e.action)
),
"notes": e.notes,
}
for e in entries
]
# ── Lab results ───────────────────────────────────────────────────────────────
def _lab_result_to_dict(r: LabResult) -> dict:
return {
"id": str(r.record_id),
"collected_at": r.collected_at.isoformat(),
"test_code": r.test_code,
"test_name_loinc": r.test_name_loinc,
"test_name_original": r.test_name_original,
"value_num": r.value_num,
"value_text": r.value_text,
"value_bool": r.value_bool,
"unit": r.unit_ucum or r.unit_original,
"ref_low": r.ref_low,
"ref_high": r.ref_high,
"ref_text": r.ref_text,
"flag": r.flag.value if r.flag and hasattr(r.flag, "value") else r.flag,
"lab": r.lab,
"notes": r.notes,
}
@mcp.tool()
def get_recent_lab_results(limit: int = 30) -> list[dict]:
"""Get the most recent lab results sorted by collection date descending."""
with Session(engine) as session:
results = session.exec(
select(LabResult)
.order_by(col(LabResult.collected_at).desc())
.limit(limit)
).all()
return [_lab_result_to_dict(r) for r in results]
@mcp.tool()
def get_available_lab_tests() -> list[dict]:
"""List all distinct lab tests ever performed, grouped by LOINC test_code.
Returns test_code, LOINC name, original lab names, result count, and last collection date."""
with Session(engine) as session:
results = session.exec(select(LabResult)).all()
tests: dict[str, dict] = {}
for r in results:
code = r.test_code
if code not in tests:
tests[code] = {
"test_code": code,
"test_name_loinc": r.test_name_loinc,
"test_names_original": set(),
"count": 0,
"last_collected_at": r.collected_at,
}
tests[code]["count"] += 1
if r.test_name_original:
tests[code]["test_names_original"].add(r.test_name_original)
if r.collected_at > tests[code]["last_collected_at"]:
tests[code]["last_collected_at"] = r.collected_at
return [
{
"test_code": v["test_code"],
"test_name_loinc": v["test_name_loinc"],
"test_names_original": sorted(v["test_names_original"]),
"count": v["count"],
"last_collected_at": v["last_collected_at"].isoformat(),
}
for v in sorted(tests.values(), key=lambda x: x["test_code"])
]
@mcp.tool()
def get_lab_results_for_test(test_code: str) -> list[dict]:
"""Get the full chronological history of results for a specific LOINC test code."""
with Session(engine) as session:
results = session.exec(
select(LabResult)
.where(LabResult.test_code == test_code)
.order_by(col(LabResult.collected_at).asc())
).all()
return [_lab_result_to_dict(r) for r in results]

View file

@ -15,7 +15,7 @@ from .enums import (
ResultFlag, ResultFlag,
RoutineRole, RoutineRole,
SkinConcern, SkinConcern,
SkinTrend, SkinTexture,
SkinType, SkinType,
StrengthLevel, StrengthLevel,
TextureType, TextureType,
@ -59,7 +59,7 @@ __all__ = [
"ResultFlag", "ResultFlag",
"RoutineRole", "RoutineRole",
"SkinConcern", "SkinConcern",
"SkinTrend", "SkinTexture",
"SkinType", "SkinType",
"StrengthLevel", "StrengthLevel",
"TextureType", "TextureType",

View file

@ -186,11 +186,11 @@ class OverallSkinState(str, Enum):
POOR = "poor" POOR = "poor"
class SkinTrend(str, Enum): class SkinTexture(str, Enum):
IMPROVING = "improving" SMOOTH = "smooth"
STABLE = "stable" ROUGH = "rough"
WORSENING = "worsening" FLAKY = "flaky"
FLUCTUATING = "fluctuating" BUMPY = "bumpy"
class BarrierState(str, Enum): class BarrierState(str, Enum):

View file

@ -9,7 +9,7 @@ from sqlmodel import Field, SQLModel
from .base import utc_now from .base import utc_now
from .domain import Domain from .domain import Domain
from .enums import BarrierState, OverallSkinState, SkinConcern, SkinTrend, SkinType from .enums import BarrierState, OverallSkinState, SkinConcern, SkinTexture, SkinType
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Base model (pure Python types, no sa_column, no id/created_at) # Base model (pure Python types, no sa_column, no id/created_at)
@ -20,8 +20,8 @@ class SkinConditionSnapshotBase(SQLModel):
snapshot_date: date snapshot_date: date
overall_state: OverallSkinState | None = None overall_state: OverallSkinState | None = None
trend: SkinTrend | None = None
skin_type: SkinType | None = None skin_type: SkinType | None = None
texture: SkinTexture | None = None
# Metryki wizualne (1 = minimalne, 5 = maksymalne nasilenie) # Metryki wizualne (1 = minimalne, 5 = maksymalne nasilenie)
hydration_level: int | None = Field(default=None, ge=1, le=5) hydration_level: int | None = Field(default=None, ge=1, le=5)

View file

@ -6,9 +6,19 @@ load_dotenv() # load .env before db.py reads DATABASE_URL
from fastapi import FastAPI # noqa: E402 from fastapi import FastAPI # noqa: E402
from fastapi.middleware.cors import CORSMiddleware # noqa: E402 from fastapi.middleware.cors import CORSMiddleware # noqa: E402
from fastmcp.utilities.lifespan import combine_lifespans # noqa: E402
from db import create_db_and_tables # noqa: E402 from db import create_db_and_tables # noqa: E402
from innercontext.api import health, inventory, products, routines, skincare # noqa: E402 from innercontext.api import ( # noqa: E402
health,
inventory,
products,
routines,
skincare,
)
from innercontext.mcp_server import mcp # noqa: E402
mcp_app = mcp.http_app(path="/mcp")
@asynccontextmanager @asynccontextmanager
@ -17,7 +27,11 @@ async def lifespan(app: FastAPI):
yield yield
app = FastAPI(title="innercontext API", lifespan=lifespan, redirect_slashes=False) app = FastAPI(
title="innercontext API",
lifespan=combine_lifespans(lifespan, mcp_app.lifespan),
redirect_slashes=False,
)
app.add_middleware( app.add_middleware(
CORSMiddleware, # ty: ignore[invalid-argument-type] CORSMiddleware, # ty: ignore[invalid-argument-type]
@ -33,6 +47,9 @@ app.include_router(routines.router, prefix="/routines", tags=["routines"])
app.include_router(skincare.router, prefix="/skincare", tags=["skincare"]) app.include_router(skincare.router, prefix="/skincare", tags=["skincare"])
app.mount("/mcp", mcp_app)
@app.get("/health-check") @app.get("/health-check")
def health_check(): def health_check():
return {"status": "ok"} return {"status": "ok"}

View file

@ -6,8 +6,11 @@ readme = "README.md"
requires-python = ">=3.12" requires-python = ">=3.12"
dependencies = [ dependencies = [
"fastapi>=0.132.0", "fastapi>=0.132.0",
"fastmcp>=2.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",
] ]

View file

@ -1,4 +1,5 @@
import os import os
from contextlib import asynccontextmanager
# Must be set before importing db (which calls create_engine at module level) # Must be set before importing db (which calls create_engine at module level)
os.environ.setdefault("DATABASE_URL", "sqlite://") os.environ.setdefault("DATABASE_URL", "sqlite://")
@ -13,6 +14,18 @@ from db import get_session
from main import app from main import app
@asynccontextmanager
async def _db_only_lifespan(a):
"""Lifespan without the MCP server for test isolation.
StreamableHTTPSessionManager.run() can only be called once per instance,
which conflicts with the per-test TestClient lifecycle. We replace the
combined (db + MCP) lifespan with one that only does DB setup.
"""
db_module.create_db_and_tables()
yield
@pytest.fixture() @pytest.fixture()
def session(monkeypatch): def session(monkeypatch):
"""Per-test fresh SQLite in-memory database with full isolation.""" """Per-test fresh SQLite in-memory database with full isolation."""
@ -32,8 +45,11 @@ def session(monkeypatch):
@pytest.fixture() @pytest.fixture()
def client(session): def client(session, monkeypatch):
"""TestClient using the per-test session for every request.""" """TestClient using the per-test session for every request."""
# Replace combined (db+MCP) lifespan with DB-only to avoid the
# StreamableHTTPSessionManager single-run limitation.
monkeypatch.setattr(app.router, "lifespan_context", _db_only_lifespan)
def _override(): def _override():
yield session yield session

View file

@ -16,8 +16,8 @@ def test_create_snapshot_full(client):
json={ json={
"snapshot_date": "2026-02-20", "snapshot_date": "2026-02-20",
"overall_state": "good", "overall_state": "good",
"trend": "improving",
"skin_type": "combination", "skin_type": "combination",
"texture": "rough",
"hydration_level": 3, "hydration_level": 3,
"sebum_tzone": 4, "sebum_tzone": 4,
"sebum_cheeks": 2, "sebum_cheeks": 2,
@ -32,7 +32,7 @@ def test_create_snapshot_full(client):
assert r.status_code == 201 assert r.status_code == 201
data = r.json() data = r.json()
assert data["overall_state"] == "good" assert data["overall_state"] == "good"
assert data["trend"] == "improving" assert data["texture"] == "rough"
assert "acne" in data["active_concerns"] assert "acne" in data["active_concerns"]
assert data["hydration_level"] == 3 assert data["hydration_level"] == 3

946
backend/uv.lock generated

File diff suppressed because it is too large Load diff

View file

@ -1,9 +1,13 @@
import { PUBLIC_API_BASE } from '$env/static/public'; import { PUBLIC_API_BASE } from '$env/static/public';
import type { import type {
ActiveIngredient,
LabResult, LabResult,
MedicationEntry, MedicationEntry,
MedicationUsage, MedicationUsage,
Product, Product,
ProductContext,
ProductEffectProfile,
ProductInteraction,
ProductInventory, ProductInventory,
Routine, Routine,
RoutineStep, RoutineStep,
@ -73,6 +77,27 @@ export const updateInventory = (id: string, body: Record<string, unknown>): Prom
api.patch(`/inventory/${id}`, body); api.patch(`/inventory/${id}`, body);
export const deleteInventory = (id: string): Promise<void> => api.del(`/inventory/${id}`); export const deleteInventory = (id: string): Promise<void> => api.del(`/inventory/${id}`);
export interface ProductParseResponse {
name?: string; brand?: string; line_name?: string; sku?: string; url?: string; barcode?: string;
category?: string; recommended_time?: string; texture?: string; absorption_speed?: string;
leave_on?: boolean; price_tier?: string;
size_ml?: number; full_weight_g?: number; empty_weight_g?: number; pao_months?: number;
inci?: string[]; actives?: ActiveIngredient[];
recommended_for?: string[]; targets?: string[];
contraindications?: string[]; usage_notes?: string;
fragrance_free?: boolean; essential_oils_free?: boolean;
alcohol_denat_free?: boolean; pregnancy_safe?: boolean;
product_effect_profile?: ProductEffectProfile;
ph_min?: number; ph_max?: number;
incompatible_with?: ProductInteraction[]; synergizes_with?: string[];
context_rules?: ProductContext;
min_interval_hours?: number; max_frequency_per_week?: number;
is_medication?: boolean; is_tool?: boolean; needle_length_mm?: number;
}
export const parseProductText = (text: string): Promise<ProductParseResponse> =>
api.post('/products/parse-text', { text });
// ─── Routines ──────────────────────────────────────────────────────────────── // ─── Routines ────────────────────────────────────────────────────────────────
export interface RoutineListParams { export interface RoutineListParams {
@ -193,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;
texture?: 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

@ -7,6 +7,7 @@
import { Label } from '$lib/components/ui/label'; import { Label } from '$lib/components/ui/label';
import { Select, SelectContent, SelectItem, SelectTrigger } from '$lib/components/ui/select'; import { Select, SelectContent, SelectItem, SelectTrigger } from '$lib/components/ui/select';
import { Card, CardContent, CardHeader, CardTitle } from '$lib/components/ui/card'; import { Card, CardContent, CardHeader, CardTitle } from '$lib/components/ui/card';
import { parseProductText, type ProductParseResponse } from '$lib/api';
let { product }: { product?: Product } = $props(); let { product }: { product?: Product } = $props();
@ -58,6 +59,118 @@
{ key: 'anti_aging_strength', label: 'Anti-aging' } { key: 'anti_aging_strength', label: 'Anti-aging' }
] as const; ] as const;
// ── Controlled text/number inputs ────────────────────────────────────────
let name = $state(untrack(() => product?.name ?? ''));
let brand = $state(untrack(() => product?.brand ?? ''));
let lineName = $state(untrack(() => product?.line_name ?? ''));
let url = $state(untrack(() => product?.url ?? ''));
let sku = $state(untrack(() => product?.sku ?? ''));
let barcode = $state(untrack(() => product?.barcode ?? ''));
let sizeMl = $state(untrack(() => (product?.size_ml != null ? String(product.size_ml) : '')));
let fullWeightG = $state(untrack(() => (product?.full_weight_g != null ? String(product.full_weight_g) : '')));
let emptyWeightG = $state(untrack(() => (product?.empty_weight_g != null ? String(product.empty_weight_g) : '')));
let paoMonths = $state(untrack(() => (product?.pao_months != null ? String(product.pao_months) : '')));
let phMin = $state(untrack(() => (product?.ph_min != null ? String(product.ph_min) : '')));
let phMax = $state(untrack(() => (product?.ph_max != null ? String(product.ph_max) : '')));
let minIntervalHours = $state(untrack(() => (product?.min_interval_hours != null ? String(product.min_interval_hours) : '')));
let maxFrequencyPerWeek = $state(untrack(() => (product?.max_frequency_per_week != null ? String(product.max_frequency_per_week) : '')));
let needleLengthMm = $state(untrack(() => (product?.needle_length_mm != null ? String(product.needle_length_mm) : '')));
let usageNotes = $state(untrack(() => product?.usage_notes ?? ''));
let inciText = $state(untrack(() => product?.inci?.join('\n') ?? ''));
let contraindicationsText = $state(untrack(() => product?.contraindications?.join('\n') ?? ''));
let synergizesWithText = $state(untrack(() => product?.synergizes_with?.join('\n') ?? ''));
let recommendedFor = $state<string[]>(untrack(() => [...(product?.recommended_for ?? [])]));
let targetConcerns = $state<string[]>(untrack(() => [...(product?.targets ?? [])]));
let isMedication = $state(untrack(() => product?.is_medication ?? false));
let isTool = $state(untrack(() => product?.is_tool ?? false));
// ── AI pre-fill state ─────────────────────────────────────────────────────
let aiPanelOpen = $state(false);
let aiText = $state('');
let aiLoading = $state(false);
let aiError = $state('');
async function parseWithAi() {
if (!aiText.trim()) return;
aiLoading = true;
aiError = '';
try {
const r = await parseProductText(aiText);
applyAiResult(r);
aiPanelOpen = false;
} catch (e) {
aiError = (e as Error).message;
} finally {
aiLoading = false;
}
}
function applyAiResult(r: ProductParseResponse) {
if (r.name) name = r.name;
if (r.brand) brand = r.brand;
if (r.line_name) lineName = r.line_name;
if (r.url) url = r.url;
if (r.sku) sku = r.sku;
if (r.barcode) barcode = r.barcode;
if (r.usage_notes) usageNotes = r.usage_notes;
if (r.category) category = r.category;
if (r.recommended_time) recommendedTime = r.recommended_time;
if (r.texture) texture = r.texture;
if (r.absorption_speed) absorptionSpeed = r.absorption_speed;
if (r.price_tier) priceTier = r.price_tier;
if (r.leave_on != null) leaveOn = String(r.leave_on);
if (r.size_ml != null) sizeMl = String(r.size_ml);
if (r.full_weight_g != null) fullWeightG = String(r.full_weight_g);
if (r.empty_weight_g != null) emptyWeightG = String(r.empty_weight_g);
if (r.pao_months != null) paoMonths = String(r.pao_months);
if (r.ph_min != null) phMin = String(r.ph_min);
if (r.ph_max != null) phMax = String(r.ph_max);
if (r.min_interval_hours != null) minIntervalHours = String(r.min_interval_hours);
if (r.max_frequency_per_week != null) maxFrequencyPerWeek = String(r.max_frequency_per_week);
if (r.needle_length_mm != null) needleLengthMm = String(r.needle_length_mm);
if (r.fragrance_free != null) fragranceFree = String(r.fragrance_free);
if (r.essential_oils_free != null) essentialOilsFree = String(r.essential_oils_free);
if (r.alcohol_denat_free != null) alcoholDenatFree = String(r.alcohol_denat_free);
if (r.pregnancy_safe != null) pregnancySafe = String(r.pregnancy_safe);
if (r.recommended_for?.length) recommendedFor = [...r.recommended_for];
if (r.targets?.length) targetConcerns = [...r.targets];
if (r.is_medication != null) isMedication = r.is_medication;
if (r.is_tool != null) isTool = r.is_tool;
if (r.inci?.length) inciText = r.inci.join('\n');
if (r.contraindications?.length) contraindicationsText = r.contraindications.join('\n');
if (r.synergizes_with?.length) synergizesWithText = r.synergizes_with.join('\n');
if (r.actives?.length) {
actives = r.actives.map((a) => ({
name: a.name,
percent: a.percent != null ? String(a.percent) : '',
functions: [...(a.functions ?? [])] as IngredientFunction[],
strength_level: a.strength_level != null ? String(a.strength_level) : '',
irritation_potential: a.irritation_potential != null ? String(a.irritation_potential) : ''
}));
}
if (r.incompatible_with?.length) {
incompatibleWith = r.incompatible_with.map((i) => ({
target: i.target,
scope: i.scope,
reason: i.reason ?? ''
}));
}
if (r.product_effect_profile) {
effectValues = { ...effectValues, ...r.product_effect_profile };
}
if (r.context_rules) {
const cr = r.context_rules;
if (cr.safe_after_shaving != null) ctxAfterShaving = String(cr.safe_after_shaving);
if (cr.safe_after_acids != null) ctxAfterAcids = String(cr.safe_after_acids);
if (cr.safe_after_retinoids != null) ctxAfterRetinoids = String(cr.safe_after_retinoids);
if (cr.safe_with_compromised_barrier != null) ctxCompromisedBarrier = String(cr.safe_with_compromised_barrier);
if (cr.low_uv_only != null) ctxLowUvOnly = String(cr.low_uv_only);
}
}
// ── Select reactive state ───────────────────────────────────────────────── // ── Select reactive state ─────────────────────────────────────────────────
let category = $state(untrack(() => product?.category ?? '')); let category = $state(untrack(() => product?.category ?? ''));
@ -210,6 +323,35 @@
'h-9 w-full rounded-md border border-input bg-background px-3 py-1 text-sm focus:outline-none focus:ring-2 focus:ring-ring'; 'h-9 w-full rounded-md border border-input bg-background px-3 py-1 text-sm focus:outline-none focus:ring-2 focus:ring-ring';
</script> </script>
<!-- ── AI pre-fill ──────────────────────────────────────────────────────────── -->
<Card>
<CardHeader>
<button type="button" class="flex w-full items-center justify-between text-left"
onclick={() => (aiPanelOpen = !aiPanelOpen)}>
<CardTitle>AI pre-fill</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">
Wklej opis produktu ze strony, listę składników lub inny tekst.
AI uzupełni dostępne pola — możesz je przejrzeć i poprawić przed zapisem.
</p>
<textarea bind:value={aiText} rows="6"
placeholder="Wklej tutaj opis produktu, składniki INCI..."
class={textareaClass}></textarea>
{#if aiError}
<p class="text-sm text-destructive">{aiError}</p>
{/if}
<Button type="button" onclick={parseWithAi}
disabled={aiLoading || !aiText.trim()}>
{aiLoading ? 'Przetwarzam…' : 'Uzupełnij pola (AI)'}
</Button>
</CardContent>
{/if}
</Card>
<!-- ── Basic info ──────────────────────────────────────────────────────────── --> <!-- ── Basic info ──────────────────────────────────────────────────────────── -->
<Card> <Card>
<CardHeader><CardTitle>Basic info</CardTitle></CardHeader> <CardHeader><CardTitle>Basic info</CardTitle></CardHeader>
@ -217,31 +359,31 @@
<div class="grid grid-cols-2 gap-4"> <div class="grid grid-cols-2 gap-4">
<div class="space-y-2"> <div class="space-y-2">
<Label for="name">Name *</Label> <Label for="name">Name *</Label>
<Input id="name" name="name" required placeholder="e.g. Hydro Boost Water Gel" value={product?.name ?? ''} /> <Input id="name" name="name" required placeholder="e.g. Hydro Boost Water Gel" bind:value={name} />
</div> </div>
<div class="space-y-2"> <div class="space-y-2">
<Label for="brand">Brand *</Label> <Label for="brand">Brand *</Label>
<Input id="brand" name="brand" required placeholder="e.g. Neutrogena" value={product?.brand ?? ''} /> <Input id="brand" name="brand" required placeholder="e.g. Neutrogena" bind:value={brand} />
</div> </div>
</div> </div>
<div class="grid grid-cols-2 gap-4"> <div class="grid grid-cols-2 gap-4">
<div class="space-y-2"> <div class="space-y-2">
<Label for="line_name">Line / series</Label> <Label for="line_name">Line / series</Label>
<Input id="line_name" name="line_name" placeholder="e.g. Hydro Boost" value={product?.line_name ?? ''} /> <Input id="line_name" name="line_name" placeholder="e.g. Hydro Boost" bind:value={lineName} />
</div> </div>
<div class="space-y-2"> <div class="space-y-2">
<Label for="url">URL</Label> <Label for="url">URL</Label>
<Input id="url" name="url" type="url" placeholder="https://…" value={product?.url ?? ''} /> <Input id="url" name="url" type="url" placeholder="https://…" bind:value={url} />
</div> </div>
</div> </div>
<div class="grid grid-cols-2 gap-4"> <div class="grid grid-cols-2 gap-4">
<div class="space-y-2"> <div class="space-y-2">
<Label for="sku">SKU</Label> <Label for="sku">SKU</Label>
<Input id="sku" name="sku" placeholder="e.g. NTR-HB-50" value={product?.sku ?? ''} /> <Input id="sku" name="sku" placeholder="e.g. NTR-HB-50" bind:value={sku} />
</div> </div>
<div class="space-y-2"> <div class="space-y-2">
<Label for="barcode">Barcode / EAN</Label> <Label for="barcode">Barcode / EAN</Label>
<Input id="barcode" name="barcode" placeholder="e.g. 3614273258975" value={product?.barcode ?? ''} /> <Input id="barcode" name="barcode" placeholder="e.g. 3614273258975" bind:value={barcode} />
</div> </div>
</div> </div>
</CardContent> </CardContent>
@ -334,7 +476,13 @@
type="checkbox" type="checkbox"
name="recommended_for" name="recommended_for"
value={st} value={st}
checked={product?.recommended_for?.includes(st as never) ?? false} checked={recommendedFor.includes(st)}
onchange={() => {
if (recommendedFor.includes(st))
recommendedFor = recommendedFor.filter((s) => s !== st);
else
recommendedFor = [...recommendedFor, st];
}}
class="rounded border-input" class="rounded border-input"
/> />
{lbl(st)} {lbl(st)}
@ -352,7 +500,13 @@
type="checkbox" type="checkbox"
name="targets" name="targets"
value={sc} value={sc}
checked={product?.targets?.includes(sc as never) ?? false} checked={targetConcerns.includes(sc)}
onchange={() => {
if (targetConcerns.includes(sc))
targetConcerns = targetConcerns.filter((s) => s !== sc);
else
targetConcerns = [...targetConcerns, sc];
}}
class="rounded border-input" class="rounded border-input"
/> />
{lbl(sc)} {lbl(sc)}
@ -369,7 +523,8 @@
rows="2" rows="2"
placeholder="e.g. active rosacea flares" placeholder="e.g. active rosacea flares"
class={textareaClass} class={textareaClass}
>{product?.contraindications?.join('\n') ?? ''}</textarea> bind:value={contraindicationsText}
></textarea>
</div> </div>
</CardContent> </CardContent>
</Card> </Card>
@ -386,7 +541,8 @@
rows="5" rows="5"
placeholder="Aqua&#10;Glycerin&#10;Niacinamide" placeholder="Aqua&#10;Glycerin&#10;Niacinamide"
class={textareaClass} class={textareaClass}
>{product?.inci?.join('\n') ?? ''}</textarea> bind:value={inciText}
></textarea>
</div> </div>
<div class="space-y-3"> <div class="space-y-3">
@ -508,7 +664,8 @@
rows="3" rows="3"
placeholder="Ceramides&#10;Niacinamide&#10;Retinoids" placeholder="Ceramides&#10;Niacinamide&#10;Retinoids"
class={textareaClass} class={textareaClass}
>{product?.synergizes_with?.join('\n') ?? ''}</textarea> bind:value={synergizesWithText}
></textarea>
</div> </div>
<div class="space-y-3"> <div class="space-y-3">
@ -662,83 +819,33 @@
<div class="space-y-2"> <div class="space-y-2">
<Label for="size_ml">Size (ml)</Label> <Label for="size_ml">Size (ml)</Label>
<Input <Input id="size_ml" name="size_ml" type="number" min="0" step="0.1" placeholder="e.g. 50" bind:value={sizeMl} />
id="size_ml"
name="size_ml"
type="number"
min="0"
step="0.1"
placeholder="e.g. 50"
value={product?.size_ml ?? ''}
/>
</div> </div>
<div class="space-y-2"> <div class="space-y-2">
<Label for="full_weight_g">Full weight (g)</Label> <Label for="full_weight_g">Full weight (g)</Label>
<Input <Input id="full_weight_g" name="full_weight_g" type="number" min="0" step="0.1" placeholder="e.g. 120" bind:value={fullWeightG} />
id="full_weight_g"
name="full_weight_g"
type="number"
min="0"
step="0.1"
placeholder="e.g. 120"
value={product?.full_weight_g ?? ''}
/>
</div> </div>
<div class="space-y-2"> <div class="space-y-2">
<Label for="empty_weight_g">Empty weight (g)</Label> <Label for="empty_weight_g">Empty weight (g)</Label>
<Input <Input id="empty_weight_g" name="empty_weight_g" type="number" min="0" step="0.1" placeholder="e.g. 30" bind:value={emptyWeightG} />
id="empty_weight_g"
name="empty_weight_g"
type="number"
min="0"
step="0.1"
placeholder="e.g. 30"
value={product?.empty_weight_g ?? ''}
/>
</div> </div>
<div class="space-y-2"> <div class="space-y-2">
<Label for="pao_months">PAO (months)</Label> <Label for="pao_months">PAO (months)</Label>
<Input <Input id="pao_months" name="pao_months" type="number" min="1" max="60" placeholder="e.g. 12" bind:value={paoMonths} />
id="pao_months"
name="pao_months"
type="number"
min="1"
max="60"
placeholder="e.g. 12"
value={product?.pao_months ?? ''}
/>
</div> </div>
</div> </div>
<div class="grid grid-cols-2 gap-4"> <div class="grid grid-cols-2 gap-4">
<div class="space-y-2"> <div class="space-y-2">
<Label for="ph_min">pH min</Label> <Label for="ph_min">pH min</Label>
<Input <Input id="ph_min" name="ph_min" type="number" min="0" max="14" step="0.1" placeholder="e.g. 3.5" bind:value={phMin} />
id="ph_min"
name="ph_min"
type="number"
min="0"
max="14"
step="0.1"
placeholder="e.g. 3.5"
value={product?.ph_min ?? ''}
/>
</div> </div>
<div class="space-y-2"> <div class="space-y-2">
<Label for="ph_max">pH max</Label> <Label for="ph_max">pH max</Label>
<Input <Input id="ph_max" name="ph_max" type="number" min="0" max="14" step="0.1" placeholder="e.g. 4.5" bind:value={phMax} />
id="ph_max"
name="ph_max"
type="number"
min="0"
max="14"
step="0.1"
placeholder="e.g. 4.5"
value={product?.ph_max ?? ''}
/>
</div> </div>
</div> </div>
@ -750,7 +857,8 @@
rows="2" rows="2"
placeholder="e.g. Apply to damp skin, avoid eye area" placeholder="e.g. Apply to damp skin, avoid eye area"
class={textareaClass} class={textareaClass}
>{product?.usage_notes ?? ''}</textarea> bind:value={usageNotes}
></textarea>
</div> </div>
</CardContent> </CardContent>
</Card> </Card>
@ -830,46 +938,31 @@
<div class="grid grid-cols-2 gap-4"> <div class="grid grid-cols-2 gap-4">
<div class="space-y-2"> <div class="space-y-2">
<Label for="min_interval_hours">Min interval (hours)</Label> <Label for="min_interval_hours">Min interval (hours)</Label>
<Input <Input id="min_interval_hours" name="min_interval_hours" type="number" min="0" placeholder="e.g. 24" bind:value={minIntervalHours} />
id="min_interval_hours"
name="min_interval_hours"
type="number"
min="0"
placeholder="e.g. 24"
value={product?.min_interval_hours ?? ''}
/>
</div> </div>
<div class="space-y-2"> <div class="space-y-2">
<Label for="max_frequency_per_week">Max uses per week</Label> <Label for="max_frequency_per_week">Max uses per week</Label>
<Input <Input id="max_frequency_per_week" name="max_frequency_per_week" type="number" min="1" max="14" placeholder="e.g. 3" bind:value={maxFrequencyPerWeek} />
id="max_frequency_per_week"
name="max_frequency_per_week"
type="number"
min="1"
max="14"
placeholder="e.g. 3"
value={product?.max_frequency_per_week ?? ''}
/>
</div> </div>
</div> </div>
<div class="flex gap-6"> <div class="flex gap-6">
<label class="flex cursor-pointer items-center gap-2 text-sm"> <label class="flex cursor-pointer items-center gap-2 text-sm">
<input type="hidden" name="is_medication" value={String(isMedication)} />
<input <input
type="checkbox" type="checkbox"
name="is_medication" checked={isMedication}
value="true" onchange={() => (isMedication = !isMedication)}
checked={product?.is_medication ?? false}
class="rounded border-input" class="rounded border-input"
/> />
Is medication Is medication
</label> </label>
<label class="flex cursor-pointer items-center gap-2 text-sm"> <label class="flex cursor-pointer items-center gap-2 text-sm">
<input type="hidden" name="is_tool" value={String(isTool)} />
<input <input
type="checkbox" type="checkbox"
name="is_tool" checked={isTool}
value="true" onchange={() => (isTool = !isTool)}
checked={product?.is_tool ?? false}
class="rounded border-input" class="rounded border-input"
/> />
Is tool (e.g. dermaroller) Is tool (e.g. dermaroller)
@ -878,15 +971,7 @@
<div class="space-y-2"> <div class="space-y-2">
<Label for="needle_length_mm">Needle length (mm, tools only)</Label> <Label for="needle_length_mm">Needle length (mm, tools only)</Label>
<Input <Input id="needle_length_mm" name="needle_length_mm" type="number" min="0" step="0.01" placeholder="e.g. 0.25" bind:value={needleLengthMm} />
id="needle_length_mm"
name="needle_length_mm"
type="number"
min="0"
step="0.01"
placeholder="e.g. 0.25"
value={product?.needle_length_mm ?? ''}
/>
</div> </div>
</CardContent> </CardContent>
</Card> </Card>

View file

@ -55,7 +55,7 @@ export type SkinConcern =
| 'uneven_texture' | 'uneven_texture'
| 'hair_growth' | 'hair_growth'
| 'sebum_excess'; | 'sebum_excess';
export type SkinTrend = 'improving' | 'stable' | 'worsening' | 'fluctuating'; export type SkinTexture = 'smooth' | 'rough' | 'flaky' | 'bumpy';
export type SkinType = 'dry' | 'oily' | 'combination' | 'sensitive' | 'normal' | 'acne_prone'; export type SkinType = 'dry' | 'oily' | 'combination' | 'sensitive' | 'normal' | 'acne_prone';
export type StrengthLevel = 1 | 2 | 3; export type StrengthLevel = 1 | 2 | 3;
export type TextureType = 'watery' | 'gel' | 'emulsion' | 'cream' | 'oil' | 'balm' | 'foam' | 'fluid'; export type TextureType = 'watery' | 'gel' | 'emulsion' | 'cream' | 'oil' | 'balm' | 'foam' | 'fluid';
@ -250,8 +250,8 @@ export interface SkinConditionSnapshot {
id: string; id: string;
snapshot_date: string; snapshot_date: string;
overall_state?: OverallSkinState; overall_state?: OverallSkinState;
trend?: SkinTrend;
skin_type?: SkinType; skin_type?: SkinType;
texture?: SkinTexture;
hydration_level?: number; hydration_level?: number;
sebum_tzone?: number; sebum_tzone?: number;
sebum_cheeks?: number; sebum_cheeks?: number;

View file

@ -39,12 +39,9 @@
</span> </span>
{/if} {/if}
</div> </div>
{#if s.trend}
<p class="text-sm">Trend: <span class="font-medium">{s.trend}</span></p>
{/if}
{#if s.active_concerns.length} {#if s.active_concerns.length}
<div class="flex flex-wrap gap-1"> <div class="flex flex-wrap gap-1">
{#each s.active_concerns as concern} {#each s.active_concerns as concern (concern)}
<Badge variant="secondary">{concern.replace(/_/g, ' ')}</Badge> <Badge variant="secondary">{concern.replace(/_/g, ' ')}</Badge>
{/each} {/each}
</div> </div>
@ -67,7 +64,7 @@
<CardContent> <CardContent>
{#if data.recentRoutines.length} {#if data.recentRoutines.length}
<ul class="space-y-2"> <ul class="space-y-2">
{#each data.recentRoutines as routine} {#each data.recentRoutines as routine (routine.id)}
<li class="flex items-center justify-between"> <li class="flex items-center justify-between">
<a href="/routines/{routine.id}" class="text-sm hover:underline"> <a href="/routines/{routine.id}" class="text-sm hover:underline">
{routine.routine_date} {routine.routine_date}

View file

@ -61,14 +61,13 @@
type="single" type="single"
value={filterFlag} value={filterFlag}
onValueChange={(v) => { onValueChange={(v) => {
filterFlag = v;
goto(v ? `/health/lab-results?flag=${v}` : '/health/lab-results'); goto(v ? `/health/lab-results?flag=${v}` : '/health/lab-results');
}} }}
> >
<SelectTrigger class="w-32">{filterFlag || 'All'}</SelectTrigger> <SelectTrigger class="w-32">{filterFlag || 'All'}</SelectTrigger>
<SelectContent> <SelectContent>
<SelectItem value="">All</SelectItem> <SelectItem value="">All</SelectItem>
{#each flags as f} {#each flags as f (f)}
<SelectItem value={f}>{f}</SelectItem> <SelectItem value={f}>{f}</SelectItem>
{/each} {/each}
</SelectContent> </SelectContent>
@ -111,7 +110,7 @@
<SelectTrigger>{selectedFlag || 'None'}</SelectTrigger> <SelectTrigger>{selectedFlag || 'None'}</SelectTrigger>
<SelectContent> <SelectContent>
<SelectItem value="">None</SelectItem> <SelectItem value="">None</SelectItem>
{#each flags as f} {#each flags as f (f)}
<SelectItem value={f}>{f}</SelectItem> <SelectItem value={f}>{f}</SelectItem>
{/each} {/each}
</SelectContent> </SelectContent>
@ -138,7 +137,7 @@
</TableRow> </TableRow>
</TableHeader> </TableHeader>
<TableBody> <TableBody>
{#each data.results as r} {#each data.results as r (r.record_id)}
<TableRow> <TableRow>
<TableCell class="text-sm">{r.collected_at.slice(0, 10)}</TableCell> <TableCell class="text-sm">{r.collected_at.slice(0, 10)}</TableCell>
<TableCell class="font-medium">{r.test_name_original ?? r.test_code}</TableCell> <TableCell class="font-medium">{r.test_name_original ?? r.test_code}</TableCell>

View file

@ -54,7 +54,7 @@
<Select type="single" value={kind} onValueChange={(v) => (kind = v)}> <Select type="single" value={kind} onValueChange={(v) => (kind = v)}>
<SelectTrigger>{kind}</SelectTrigger> <SelectTrigger>{kind}</SelectTrigger>
<SelectContent> <SelectContent>
{#each kinds as k} {#each kinds as k (k)}
<SelectItem value={k}>{k}</SelectItem> <SelectItem value={k}>{k}</SelectItem>
{/each} {/each}
</SelectContent> </SelectContent>
@ -81,7 +81,7 @@
{/if} {/if}
<div class="space-y-3"> <div class="space-y-3">
{#each data.medications as med} {#each data.medications as med (med.record_id)}
<div class="rounded-md border border-border px-4 py-3"> <div class="rounded-md border border-border px-4 py-3">
<div class="flex items-center justify-between"> <div class="flex items-center justify-between">
<div class="flex items-center gap-3"> <div class="flex items-center gap-3">

View file

@ -23,7 +23,6 @@
let selectedCategory = $derived(data.category ?? ''); let selectedCategory = $derived(data.category ?? '');
function filterByCategory(cat: string) { function filterByCategory(cat: string) {
selectedCategory = cat;
goto(cat ? `/products?category=${cat}` : '/products'); goto(cat ? `/products?category=${cat}` : '/products');
} }
</script> </script>
@ -51,7 +50,7 @@
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
<SelectItem value="">All categories</SelectItem> <SelectItem value="">All categories</SelectItem>
{#each categories as cat} {#each categories as cat (cat)}
<SelectItem value={cat}>{cat.replace(/_/g, ' ')}</SelectItem> <SelectItem value={cat}>{cat.replace(/_/g, ' ')}</SelectItem>
{/each} {/each}
</SelectContent> </SelectContent>
@ -70,7 +69,7 @@
</TableRow> </TableRow>
</TableHeader> </TableHeader>
<TableBody> <TableBody>
{#each data.products as product} {#each data.products as product (product.id)}
<TableRow class="cursor-pointer hover:bg-muted/50"> <TableRow class="cursor-pointer hover:bg-muted/50">
<TableCell> <TableCell>
<a href="/products/{product.id}" class="font-medium hover:underline"> <a href="/products/{product.id}" class="font-medium hover:underline">
@ -83,7 +82,7 @@
</TableCell> </TableCell>
<TableCell> <TableCell>
<div class="flex flex-wrap gap-1"> <div class="flex flex-wrap gap-1">
{#each product.targets.slice(0, 3) as t} {#each product.targets.slice(0, 3) as t (t)}
<Badge variant="secondary" class="text-xs">{t.replace(/_/g, ' ')}</Badge> <Badge variant="secondary" class="text-xs">{t.replace(/_/g, ' ')}</Badge>
{/each} {/each}
{#if product.targets.length > 3} {#if product.targets.length > 3}

View file

@ -190,6 +190,6 @@ export const actions: Actions = {
} catch (e) { } catch (e) {
return fail(500, { error: (e as Error).message }); return fail(500, { error: (e as Error).message });
} }
redirect(303, `/products/${product.id}`); redirect(303, '/products');
} }
}; };

View file

@ -2,10 +2,9 @@ import { getRoutines } from '$lib/api';
import type { PageServerLoad } from './$types'; import type { PageServerLoad } from './$types';
export const load: PageServerLoad = async ({ url }) => { export const load: PageServerLoad = async ({ url }) => {
const part_of_day = url.searchParams.get('part_of_day') ?? undefined;
const from_date = url.searchParams.get('from_date') ?? recentDate(30); const from_date = url.searchParams.get('from_date') ?? recentDate(30);
const routines = await getRoutines({ from_date, part_of_day }); const routines = await getRoutines({ from_date });
return { routines, part_of_day }; return { routines };
}; };
function recentDate(daysAgo: number): string { function recentDate(daysAgo: number): string {

View file

@ -2,14 +2,9 @@
import type { PageData } from './$types'; import type { PageData } from './$types';
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 { goto } from '$app/navigation';
let { data }: { data: PageData } = $props(); let { data }: { data: PageData } = $props();
function filterPod(pod: string) {
goto(pod ? `/routines?part_of_day=${pod}` : '/routines');
}
// Group by date // Group by date
const byDate = $derived( const byDate = $derived(
data.routines.reduce( data.routines.reduce(
@ -34,24 +29,6 @@
<Button href="/routines/new">+ New routine</Button> <Button href="/routines/new">+ New routine</Button>
</div> </div>
<div class="flex gap-2">
<Button
variant={!data.part_of_day ? 'default' : 'outline'}
size="sm"
onclick={() => filterPod('')}
>All</Button>
<Button
variant={data.part_of_day === 'am' ? 'default' : 'outline'}
size="sm"
onclick={() => filterPod('am')}
>AM</Button>
<Button
variant={data.part_of_day === 'pm' ? 'default' : 'outline'}
size="sm"
onclick={() => filterPod('pm')}
>PM</Button>
</div>
{#if sortedDates.length} {#if sortedDates.length}
<div class="space-y-4"> <div class="space-y-4">
{#each sortedDates as date} {#each sortedDates as date}

View file

@ -65,7 +65,7 @@
{/if} {/if}
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
{#each products as p} {#each products as p (p.id)}
<SelectItem value={p.id}>{p.name} ({p.brand})</SelectItem> <SelectItem value={p.id}>{p.name} ({p.brand})</SelectItem>
{/each} {/each}
</SelectContent> </SelectContent>
@ -90,7 +90,7 @@
{#if routine.steps.length} {#if routine.steps.length}
<div class="space-y-2"> <div class="space-y-2">
{#each routine.steps.toSorted((a, b) => a.order_index - b.order_index) as step} {#each routine.steps.toSorted((a, b) => a.order_index - b.order_index) as step (step.id)}
<div class="flex items-center justify-between rounded-md border border-border px-4 py-3"> <div class="flex items-center justify-between rounded-md border border-border px-4 py-3">
<div class="flex items-center gap-3"> <div class="flex items-center gap-3">
<span class="text-xs text-muted-foreground w-4">{step.order_index + 1}</span> <span class="text-xs text-muted-foreground w-4">{step.order_index + 1}</span>

View file

@ -13,7 +13,7 @@ export const actions: Actions = {
const form = await request.formData(); const form = await request.formData();
const snapshot_date = form.get('snapshot_date') as string; const snapshot_date = form.get('snapshot_date') as string;
const overall_state = form.get('overall_state') as string; const overall_state = form.get('overall_state') as string;
const trend = form.get('trend') as string; const texture = form.get('texture') as string;
const notes = form.get('notes') as string; const notes = form.get('notes') as string;
const hydration_level = form.get('hydration_level') as string; const hydration_level = form.get('hydration_level') as string;
const sensitivity_level = form.get('sensitivity_level') as string; const sensitivity_level = form.get('sensitivity_level') as string;
@ -29,13 +29,20 @@ 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 (texture) body.texture = texture;
if (notes) body.notes = notes; if (notes) body.notes = notes;
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';
@ -11,8 +12,9 @@
let { data, form }: { data: PageData; form: ActionData } = $props(); let { data, form }: { data: PageData; form: ActionData } = $props();
const states = ['excellent', 'good', 'fair', 'poor']; const states = ['excellent', 'good', 'fair', 'poor'];
const trends = ['improving', 'stable', 'worsening', 'fluctuating']; const skinTextures = ['smooth', 'rough', 'flaky', 'bumpy'];
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 texture = $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.texture) texture = r.texture;
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,31 +171,45 @@
<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>
</Select> </Select>
</div> </div>
<div class="space-y-1"> <div class="space-y-1">
<Label>Trend</Label> <Label>Texture</Label>
<input type="hidden" name="trend" value={trend} /> <input type="hidden" name="texture" value={texture} />
<Select type="single" value={trend} onValueChange={(v) => (trend = v)}> <Select type="single" value={texture} onValueChange={(v) => (texture = v)}>
<SelectTrigger>{trend || 'Select'}</SelectTrigger> <SelectTrigger>{texture || 'Select'}</SelectTrigger>
<SelectContent> <SelectContent>
{#each trends as t} {#each skinTextures 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,19 +281,23 @@
{/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}
{#if snap.trend} {#if snap.texture}
<Badge variant="secondary">{snap.trend}</Badge> <Badge variant="secondary">{snap.texture}</Badge>
{/if} {/if}
</div> </div>
</div> </div>
@ -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>