feat(profile): add profile settings and LLM user context
This commit is contained in:
parent
db3d9514d5
commit
b99b9ed68e
25 changed files with 472 additions and 9 deletions
|
|
@ -58,6 +58,7 @@ UI available at `http://localhost:5173`.
|
||||||
| `/routines` | AM/PM skincare routines and steps |
|
| `/routines` | AM/PM skincare routines and steps |
|
||||||
| `/routines/grooming-schedule` | Weekly grooming schedule |
|
| `/routines/grooming-schedule` | Weekly grooming schedule |
|
||||||
| `/skincare` | Weekly skin condition snapshots |
|
| `/skincare` | Weekly skin condition snapshots |
|
||||||
|
| `/profile` | User profile (birth date, sex at birth) |
|
||||||
| `/health-check` | Liveness probe |
|
| `/health-check` | Liveness probe |
|
||||||
|
|
||||||
## Frontend routes
|
## Frontend routes
|
||||||
|
|
@ -74,6 +75,7 @@ UI available at `http://localhost:5173`.
|
||||||
| `/health/medications` | Medications |
|
| `/health/medications` | Medications |
|
||||||
| `/health/lab-results` | Lab results |
|
| `/health/lab-results` | Lab results |
|
||||||
| `/skin` | Skin condition snapshots |
|
| `/skin` | Skin condition snapshots |
|
||||||
|
| `/profile` | User profile |
|
||||||
|
|
||||||
## Development
|
## Development
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,45 @@
|
||||||
|
"""add_user_profile_table
|
||||||
|
|
||||||
|
Revision ID: 1f7e3b9c4a2d
|
||||||
|
Revises: 8e4c1b7a9d2f
|
||||||
|
Create Date: 2026-03-05 00:00:00.000000
|
||||||
|
|
||||||
|
"""
|
||||||
|
|
||||||
|
from typing import Sequence, Union
|
||||||
|
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
|
||||||
|
revision: str = "1f7e3b9c4a2d"
|
||||||
|
down_revision: Union[str, None] = "8e4c1b7a9d2f"
|
||||||
|
branch_labels: Union[str, Sequence[str], None] = None
|
||||||
|
depends_on: Union[str, Sequence[str], None] = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
op.create_table(
|
||||||
|
"user_profiles",
|
||||||
|
sa.Column("id", sa.Uuid(), nullable=False),
|
||||||
|
sa.Column("birth_date", sa.Date(), nullable=True),
|
||||||
|
sa.Column("sex_at_birth", sa.String(length=16), nullable=True),
|
||||||
|
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
|
||||||
|
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False),
|
||||||
|
sa.CheckConstraint(
|
||||||
|
"sex_at_birth IN ('male', 'female', 'intersex')",
|
||||||
|
name="ck_user_profiles_sex_at_birth",
|
||||||
|
),
|
||||||
|
sa.PrimaryKeyConstraint("id"),
|
||||||
|
)
|
||||||
|
op.create_index(
|
||||||
|
op.f("ix_user_profiles_sex_at_birth"),
|
||||||
|
"user_profiles",
|
||||||
|
["sex_at_birth"],
|
||||||
|
unique=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
op.drop_index(op.f("ix_user_profiles_sex_at_birth"), table_name="user_profiles")
|
||||||
|
op.drop_table("user_profiles")
|
||||||
44
backend/innercontext/api/llm_context.py
Normal file
44
backend/innercontext/api/llm_context.py
Normal file
|
|
@ -0,0 +1,44 @@
|
||||||
|
from datetime import date
|
||||||
|
|
||||||
|
from sqlmodel import Session, col, select
|
||||||
|
|
||||||
|
from innercontext.models import UserProfile
|
||||||
|
|
||||||
|
|
||||||
|
def get_user_profile(session: Session) -> UserProfile | None:
|
||||||
|
return session.exec(
|
||||||
|
select(UserProfile).order_by(col(UserProfile.created_at).desc())
|
||||||
|
).first()
|
||||||
|
|
||||||
|
|
||||||
|
def calculate_age(birth_date: date, reference_date: date) -> int:
|
||||||
|
years = reference_date.year - birth_date.year
|
||||||
|
if (reference_date.month, reference_date.day) < (birth_date.month, birth_date.day):
|
||||||
|
years -= 1
|
||||||
|
return years
|
||||||
|
|
||||||
|
|
||||||
|
def build_user_profile_context(session: Session, reference_date: date) -> str:
|
||||||
|
profile = get_user_profile(session)
|
||||||
|
if profile is None:
|
||||||
|
return "USER PROFILE: no data\n"
|
||||||
|
|
||||||
|
lines = ["USER PROFILE:"]
|
||||||
|
if profile.birth_date is not None:
|
||||||
|
age = calculate_age(profile.birth_date, reference_date)
|
||||||
|
lines.append(f" Age: {max(age, 0)}")
|
||||||
|
lines.append(f" Birth date: {profile.birth_date.isoformat()}")
|
||||||
|
else:
|
||||||
|
lines.append(" Age: unknown")
|
||||||
|
|
||||||
|
if profile.sex_at_birth is not None:
|
||||||
|
sex_value = (
|
||||||
|
profile.sex_at_birth.value
|
||||||
|
if hasattr(profile.sex_at_birth, "value")
|
||||||
|
else str(profile.sex_at_birth)
|
||||||
|
)
|
||||||
|
lines.append(f" Sex at birth: {sex_value}")
|
||||||
|
else:
|
||||||
|
lines.append(" Sex at birth: unknown")
|
||||||
|
|
||||||
|
return "\n".join(lines) + "\n"
|
||||||
|
|
@ -12,6 +12,7 @@ from sqlmodel import Field, Session, SQLModel, col, select
|
||||||
|
|
||||||
from db import get_session
|
from db import get_session
|
||||||
from innercontext.api.utils import get_or_404
|
from innercontext.api.utils import get_or_404
|
||||||
|
from innercontext.api.llm_context import build_user_profile_context
|
||||||
from innercontext.llm import (
|
from innercontext.llm import (
|
||||||
call_gemini,
|
call_gemini,
|
||||||
call_gemini_with_function_tools,
|
call_gemini_with_function_tools,
|
||||||
|
|
@ -786,7 +787,8 @@ def _ev(v: object) -> str:
|
||||||
return str(v)
|
return str(v)
|
||||||
|
|
||||||
|
|
||||||
def _build_shopping_context(session: Session) -> str:
|
def _build_shopping_context(session: Session, reference_date: date) -> str:
|
||||||
|
profile_ctx = build_user_profile_context(session, reference_date=reference_date)
|
||||||
snapshot = session.exec(
|
snapshot = session.exec(
|
||||||
select(SkinConditionSnapshot).order_by(
|
select(SkinConditionSnapshot).order_by(
|
||||||
col(SkinConditionSnapshot.snapshot_date).desc()
|
col(SkinConditionSnapshot.snapshot_date).desc()
|
||||||
|
|
@ -854,7 +856,9 @@ def _build_shopping_context(session: Session) -> str:
|
||||||
f"targets: {targets}{actives_str}{effects_str}"
|
f"targets: {targets}{actives_str}{effects_str}"
|
||||||
)
|
)
|
||||||
|
|
||||||
return "\n".join(skin_lines) + "\n\n" + "\n".join(products_lines)
|
return (
|
||||||
|
profile_ctx + "\n" + "\n".join(skin_lines) + "\n\n" + "\n".join(products_lines)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def _get_shopping_products(session: Session) -> list[Product]:
|
def _get_shopping_products(session: Session) -> list[Product]:
|
||||||
|
|
@ -1067,7 +1071,7 @@ Format odpowiedzi - zwróć wyłącznie JSON zgodny z podanym schematem."""
|
||||||
|
|
||||||
@router.post("/suggest", response_model=ShoppingSuggestionResponse)
|
@router.post("/suggest", response_model=ShoppingSuggestionResponse)
|
||||||
def suggest_shopping(session: Session = Depends(get_session)):
|
def suggest_shopping(session: Session = Depends(get_session)):
|
||||||
context = _build_shopping_context(session)
|
context = _build_shopping_context(session, reference_date=date.today())
|
||||||
shopping_products = _get_shopping_products(session)
|
shopping_products = _get_shopping_products(session)
|
||||||
|
|
||||||
prompt = (
|
prompt = (
|
||||||
|
|
|
||||||
62
backend/innercontext/api/profile.py
Normal file
62
backend/innercontext/api/profile.py
Normal file
|
|
@ -0,0 +1,62 @@
|
||||||
|
from datetime import date, datetime
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends
|
||||||
|
from sqlmodel import Session, SQLModel
|
||||||
|
|
||||||
|
from db import get_session
|
||||||
|
from innercontext.api.llm_context import get_user_profile
|
||||||
|
from innercontext.models import SexAtBirth, UserProfile
|
||||||
|
|
||||||
|
router = APIRouter()
|
||||||
|
|
||||||
|
|
||||||
|
class UserProfileUpdate(SQLModel):
|
||||||
|
birth_date: Optional[date] = None
|
||||||
|
sex_at_birth: Optional[SexAtBirth] = None
|
||||||
|
|
||||||
|
|
||||||
|
class UserProfilePublic(SQLModel):
|
||||||
|
id: str
|
||||||
|
birth_date: date | None
|
||||||
|
sex_at_birth: SexAtBirth | None
|
||||||
|
created_at: datetime
|
||||||
|
updated_at: datetime
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("", response_model=UserProfilePublic | None)
|
||||||
|
def get_profile(session: Session = Depends(get_session)):
|
||||||
|
profile = get_user_profile(session)
|
||||||
|
if profile is None:
|
||||||
|
return None
|
||||||
|
return UserProfilePublic(
|
||||||
|
id=str(profile.id),
|
||||||
|
birth_date=profile.birth_date,
|
||||||
|
sex_at_birth=profile.sex_at_birth,
|
||||||
|
created_at=profile.created_at,
|
||||||
|
updated_at=profile.updated_at,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.patch("", response_model=UserProfilePublic)
|
||||||
|
def upsert_profile(data: UserProfileUpdate, session: Session = Depends(get_session)):
|
||||||
|
profile = get_user_profile(session)
|
||||||
|
payload = data.model_dump(exclude_unset=True)
|
||||||
|
|
||||||
|
if profile is None:
|
||||||
|
profile = UserProfile(**payload)
|
||||||
|
else:
|
||||||
|
for key, value in payload.items():
|
||||||
|
setattr(profile, key, value)
|
||||||
|
|
||||||
|
session.add(profile)
|
||||||
|
session.commit()
|
||||||
|
session.refresh(profile)
|
||||||
|
|
||||||
|
return UserProfilePublic(
|
||||||
|
id=str(profile.id),
|
||||||
|
birth_date=profile.birth_date,
|
||||||
|
sex_at_birth=profile.sex_at_birth,
|
||||||
|
created_at=profile.created_at,
|
||||||
|
updated_at=profile.updated_at,
|
||||||
|
)
|
||||||
|
|
@ -10,6 +10,7 @@ from sqlmodel import Field, Session, SQLModel, col, select
|
||||||
|
|
||||||
from db import get_session
|
from db import get_session
|
||||||
from innercontext.api.utils import get_or_404
|
from innercontext.api.utils import get_or_404
|
||||||
|
from innercontext.api.llm_context import build_user_profile_context
|
||||||
from innercontext.llm import (
|
from innercontext.llm import (
|
||||||
call_gemini,
|
call_gemini,
|
||||||
call_gemini_with_function_tools,
|
call_gemini_with_function_tools,
|
||||||
|
|
@ -760,6 +761,7 @@ def suggest_routine(
|
||||||
):
|
):
|
||||||
weekday = data.routine_date.weekday()
|
weekday = data.routine_date.weekday()
|
||||||
skin_ctx = _build_skin_context(session)
|
skin_ctx = _build_skin_context(session)
|
||||||
|
profile_ctx = build_user_profile_context(session, reference_date=data.routine_date)
|
||||||
grooming_ctx = _build_grooming_context(session, weekdays=[weekday])
|
grooming_ctx = _build_grooming_context(session, weekdays=[weekday])
|
||||||
history_ctx = _build_recent_history(session)
|
history_ctx = _build_recent_history(session)
|
||||||
day_ctx = _build_day_context(data.leaving_home)
|
day_ctx = _build_day_context(data.leaving_home)
|
||||||
|
|
@ -781,7 +783,7 @@ def suggest_routine(
|
||||||
f"na {data.routine_date} ({day_name}).\n\n"
|
f"na {data.routine_date} ({day_name}).\n\n"
|
||||||
f"{mode_line}\n"
|
f"{mode_line}\n"
|
||||||
"INPUT DATA:\n"
|
"INPUT DATA:\n"
|
||||||
f"{skin_ctx}\n{grooming_ctx}\n{history_ctx}\n{day_ctx}\n{products_ctx}\n{objectives_ctx}"
|
f"{profile_ctx}\n{skin_ctx}\n{grooming_ctx}\n{history_ctx}\n{day_ctx}\n{products_ctx}\n{objectives_ctx}"
|
||||||
"\nNARZEDZIA:\n"
|
"\nNARZEDZIA:\n"
|
||||||
"- Masz dostep do funkcji: get_product_inci, get_product_safety_rules, get_product_actives.\n"
|
"- Masz dostep do funkcji: get_product_inci, get_product_safety_rules, get_product_actives.\n"
|
||||||
"- Wywoluj narzedzia tylko, gdy potrzebujesz detali do decyzji klinicznej/bezpieczenstwa.\n"
|
"- Wywoluj narzedzia tylko, gdy potrzebujesz detali do decyzji klinicznej/bezpieczenstwa.\n"
|
||||||
|
|
@ -924,6 +926,7 @@ def suggest_batch(
|
||||||
weekdays = list(
|
weekdays = list(
|
||||||
{(data.from_date + timedelta(days=i)).weekday() for i in range(delta)}
|
{(data.from_date + timedelta(days=i)).weekday() for i in range(delta)}
|
||||||
)
|
)
|
||||||
|
profile_ctx = build_user_profile_context(session, reference_date=data.from_date)
|
||||||
skin_ctx = _build_skin_context(session)
|
skin_ctx = _build_skin_context(session)
|
||||||
grooming_ctx = _build_grooming_context(session, weekdays=weekdays)
|
grooming_ctx = _build_grooming_context(session, weekdays=weekdays)
|
||||||
history_ctx = _build_recent_history(session)
|
history_ctx = _build_recent_history(session)
|
||||||
|
|
@ -950,7 +953,7 @@ def suggest_batch(
|
||||||
prompt = (
|
prompt = (
|
||||||
f"Zaproponuj plan pielęgnacji AM + PM dla każdego dnia z zakresu:\n{dates_str}\n\n{mode_line}\n"
|
f"Zaproponuj plan pielęgnacji AM + PM dla każdego dnia z zakresu:\n{dates_str}\n\n{mode_line}\n"
|
||||||
"INPUT DATA:\n"
|
"INPUT DATA:\n"
|
||||||
f"{skin_ctx}\n{grooming_ctx}\n{history_ctx}\n{products_ctx}\n{objectives_ctx}"
|
f"{profile_ctx}\n{skin_ctx}\n{grooming_ctx}\n{history_ctx}\n{products_ctx}\n{objectives_ctx}"
|
||||||
f"{notes_line}{minimize_line}"
|
f"{notes_line}{minimize_line}"
|
||||||
"\nZwróć JSON zgodny ze schematem."
|
"\nZwróć JSON zgodny ze schematem."
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -10,6 +10,7 @@ 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.llm_context import build_user_profile_context
|
||||||
from innercontext.api.utils import get_or_404
|
from innercontext.api.utils import get_or_404
|
||||||
from innercontext.llm import call_gemini, get_extraction_config
|
from innercontext.llm import call_gemini, get_extraction_config
|
||||||
from innercontext.models import (
|
from innercontext.models import (
|
||||||
|
|
@ -136,6 +137,7 @@ MAX_IMAGE_BYTES = 5 * 1024 * 1024 # 5 MB
|
||||||
@router.post("/analyze-photos", response_model=SkinPhotoAnalysisResponse)
|
@router.post("/analyze-photos", response_model=SkinPhotoAnalysisResponse)
|
||||||
async def analyze_skin_photos(
|
async def analyze_skin_photos(
|
||||||
photos: list[UploadFile] = File(...),
|
photos: list[UploadFile] = File(...),
|
||||||
|
session: Session = Depends(get_session),
|
||||||
) -> SkinPhotoAnalysisResponse:
|
) -> SkinPhotoAnalysisResponse:
|
||||||
if not (1 <= len(photos) <= 3):
|
if not (1 <= len(photos) <= 3):
|
||||||
raise HTTPException(status_code=422, detail="Send between 1 and 3 photos.")
|
raise HTTPException(status_code=422, detail="Send between 1 and 3 photos.")
|
||||||
|
|
@ -166,6 +168,11 @@ async def analyze_skin_photos(
|
||||||
text="Analyze the skin condition visible in the above photo(s) and return the JSON assessment."
|
text="Analyze the skin condition visible in the above photo(s) and return the JSON assessment."
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
parts.append(
|
||||||
|
genai_types.Part.from_text(
|
||||||
|
text=build_user_profile_context(session, reference_date=date.today())
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
image_summary = f"{len(photos)} image(s): {', '.join((p.content_type or 'unknown') for p in photos)}"
|
image_summary = f"{len(photos)} image(s): {', '.join((p.content_type or 'unknown') for p in photos)}"
|
||||||
response = call_gemini(
|
response = call_gemini(
|
||||||
|
|
|
||||||
|
|
@ -14,6 +14,7 @@ from .enums import (
|
||||||
ProductCategory,
|
ProductCategory,
|
||||||
ResultFlag,
|
ResultFlag,
|
||||||
RoutineRole,
|
RoutineRole,
|
||||||
|
SexAtBirth,
|
||||||
SkinConcern,
|
SkinConcern,
|
||||||
SkinTexture,
|
SkinTexture,
|
||||||
SkinType,
|
SkinType,
|
||||||
|
|
@ -33,6 +34,7 @@ from .product import (
|
||||||
ProductWithInventory,
|
ProductWithInventory,
|
||||||
)
|
)
|
||||||
from .pricing import PricingRecalcJob
|
from .pricing import PricingRecalcJob
|
||||||
|
from .profile import UserProfile
|
||||||
from .routine import GroomingSchedule, Routine, RoutineStep
|
from .routine import GroomingSchedule, Routine, RoutineStep
|
||||||
from .skincare import (
|
from .skincare import (
|
||||||
SkinConditionSnapshot,
|
SkinConditionSnapshot,
|
||||||
|
|
@ -59,6 +61,7 @@ __all__ = [
|
||||||
"ProductCategory",
|
"ProductCategory",
|
||||||
"ResultFlag",
|
"ResultFlag",
|
||||||
"RoutineRole",
|
"RoutineRole",
|
||||||
|
"SexAtBirth",
|
||||||
"SkinConcern",
|
"SkinConcern",
|
||||||
"SkinTexture",
|
"SkinTexture",
|
||||||
"SkinType",
|
"SkinType",
|
||||||
|
|
@ -79,6 +82,7 @@ __all__ = [
|
||||||
"ProductPublic",
|
"ProductPublic",
|
||||||
"ProductWithInventory",
|
"ProductWithInventory",
|
||||||
"PricingRecalcJob",
|
"PricingRecalcJob",
|
||||||
|
"UserProfile",
|
||||||
# routine
|
# routine
|
||||||
"GroomingSchedule",
|
"GroomingSchedule",
|
||||||
"Routine",
|
"Routine",
|
||||||
|
|
|
||||||
|
|
@ -153,6 +153,12 @@ class MedicationKind(str, Enum):
|
||||||
OTHER = "other"
|
OTHER = "other"
|
||||||
|
|
||||||
|
|
||||||
|
class SexAtBirth(str, Enum):
|
||||||
|
MALE = "male"
|
||||||
|
FEMALE = "female"
|
||||||
|
INTERSEX = "intersex"
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# Routine
|
# Routine
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
|
||||||
35
backend/innercontext/models/profile.py
Normal file
35
backend/innercontext/models/profile.py
Normal file
|
|
@ -0,0 +1,35 @@
|
||||||
|
from datetime import date, datetime
|
||||||
|
from typing import ClassVar
|
||||||
|
from uuid import UUID, uuid4
|
||||||
|
|
||||||
|
from sqlalchemy import Column, DateTime, String
|
||||||
|
from sqlmodel import Field, SQLModel
|
||||||
|
|
||||||
|
from .base import utc_now
|
||||||
|
from .domain import Domain
|
||||||
|
from .enums import SexAtBirth
|
||||||
|
|
||||||
|
|
||||||
|
class UserProfile(SQLModel, table=True):
|
||||||
|
__tablename__ = "user_profiles"
|
||||||
|
__domains__: ClassVar[frozenset[Domain]] = frozenset(
|
||||||
|
{Domain.HEALTH, Domain.SKINCARE}
|
||||||
|
)
|
||||||
|
|
||||||
|
id: UUID = Field(default_factory=uuid4, primary_key=True)
|
||||||
|
birth_date: date | None = Field(default=None)
|
||||||
|
sex_at_birth: SexAtBirth | None = Field(
|
||||||
|
default=None,
|
||||||
|
sa_column=Column(String(length=16), nullable=True, index=True),
|
||||||
|
)
|
||||||
|
|
||||||
|
created_at: datetime = Field(default_factory=utc_now, nullable=False)
|
||||||
|
updated_at: datetime = Field(
|
||||||
|
default_factory=utc_now,
|
||||||
|
sa_column=Column(
|
||||||
|
DateTime(timezone=True),
|
||||||
|
default=utc_now,
|
||||||
|
onupdate=utc_now,
|
||||||
|
nullable=False,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
@ -14,6 +14,7 @@ from innercontext.api import ( # noqa: E402
|
||||||
ai_logs,
|
ai_logs,
|
||||||
health,
|
health,
|
||||||
inventory,
|
inventory,
|
||||||
|
profile,
|
||||||
products,
|
products,
|
||||||
routines,
|
routines,
|
||||||
skincare,
|
skincare,
|
||||||
|
|
@ -48,6 +49,7 @@ app.add_middleware(
|
||||||
|
|
||||||
app.include_router(products.router, prefix="/products", tags=["products"])
|
app.include_router(products.router, prefix="/products", tags=["products"])
|
||||||
app.include_router(inventory.router, prefix="/inventory", tags=["inventory"])
|
app.include_router(inventory.router, prefix="/inventory", tags=["inventory"])
|
||||||
|
app.include_router(profile.router, prefix="/profile", tags=["profile"])
|
||||||
app.include_router(health.router, prefix="/health", tags=["health"])
|
app.include_router(health.router, prefix="/health", tags=["health"])
|
||||||
app.include_router(routines.router, prefix="/routines", tags=["routines"])
|
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"])
|
||||||
|
|
|
||||||
23
backend/tests/test_llm_profile_context.py
Normal file
23
backend/tests/test_llm_profile_context.py
Normal file
|
|
@ -0,0 +1,23 @@
|
||||||
|
from datetime import date
|
||||||
|
|
||||||
|
from sqlmodel import Session
|
||||||
|
|
||||||
|
from innercontext.api.llm_context import build_user_profile_context
|
||||||
|
from innercontext.models import SexAtBirth, UserProfile
|
||||||
|
|
||||||
|
|
||||||
|
def test_build_user_profile_context_without_data(session: Session):
|
||||||
|
ctx = build_user_profile_context(session, reference_date=date(2026, 3, 5))
|
||||||
|
assert ctx == "USER PROFILE: no data\n"
|
||||||
|
|
||||||
|
|
||||||
|
def test_build_user_profile_context_with_data(session: Session):
|
||||||
|
profile = UserProfile(birth_date=date(1990, 3, 20), sex_at_birth=SexAtBirth.FEMALE)
|
||||||
|
session.add(profile)
|
||||||
|
session.commit()
|
||||||
|
|
||||||
|
ctx = build_user_profile_context(session, reference_date=date(2026, 3, 5))
|
||||||
|
assert "USER PROFILE:" in ctx
|
||||||
|
assert "Age: 35" in ctx
|
||||||
|
assert "Birth date: 1990-03-20" in ctx
|
||||||
|
assert "Sex at birth: female" in ctx
|
||||||
|
|
@ -11,15 +11,26 @@ from innercontext.api.products import (
|
||||||
_build_shopping_context,
|
_build_shopping_context,
|
||||||
_extract_requested_product_ids,
|
_extract_requested_product_ids,
|
||||||
)
|
)
|
||||||
from innercontext.models import Product, ProductInventory, SkinConditionSnapshot
|
from innercontext.models import (
|
||||||
|
Product,
|
||||||
|
ProductInventory,
|
||||||
|
SexAtBirth,
|
||||||
|
SkinConditionSnapshot,
|
||||||
|
)
|
||||||
|
from innercontext.models.profile import UserProfile
|
||||||
|
|
||||||
|
|
||||||
def test_build_shopping_context(session: Session):
|
def test_build_shopping_context(session: Session):
|
||||||
# Empty context
|
# Empty context
|
||||||
ctx = _build_shopping_context(session)
|
ctx = _build_shopping_context(session, reference_date=date.today())
|
||||||
|
assert "USER PROFILE: no data" in ctx
|
||||||
assert "(brak danych)" in ctx
|
assert "(brak danych)" in ctx
|
||||||
assert "POSIADANE PRODUKTY" in ctx
|
assert "POSIADANE PRODUKTY" in ctx
|
||||||
|
|
||||||
|
profile = UserProfile(birth_date=date(1990, 1, 10), sex_at_birth=SexAtBirth.MALE)
|
||||||
|
session.add(profile)
|
||||||
|
session.commit()
|
||||||
|
|
||||||
# Add snapshot
|
# Add snapshot
|
||||||
snap = SkinConditionSnapshot(
|
snap = SkinConditionSnapshot(
|
||||||
id=uuid.uuid4(),
|
id=uuid.uuid4(),
|
||||||
|
|
@ -54,7 +65,10 @@ def test_build_shopping_context(session: Session):
|
||||||
session.add(inv)
|
session.add(inv)
|
||||||
session.commit()
|
session.commit()
|
||||||
|
|
||||||
ctx = _build_shopping_context(session)
|
ctx = _build_shopping_context(session, reference_date=date(2026, 3, 5))
|
||||||
|
assert "USER PROFILE:" in ctx
|
||||||
|
assert "Age: 36" in ctx
|
||||||
|
assert "Sex at birth: male" in ctx
|
||||||
assert "Typ skóry: combination" in ctx
|
assert "Typ skóry: combination" in ctx
|
||||||
assert "Nawilżenie: 3/5" in ctx
|
assert "Nawilżenie: 3/5" in ctx
|
||||||
assert "Wrażliwość: 4/5" in ctx
|
assert "Wrażliwość: 4/5" in ctx
|
||||||
|
|
@ -91,6 +105,7 @@ def test_suggest_shopping(client, session):
|
||||||
assert data["suggestions"][0]["product_type"] == "cleanser"
|
assert data["suggestions"][0]["product_type"] == "cleanser"
|
||||||
assert data["reasoning"] == "Test shopping"
|
assert data["reasoning"] == "Test shopping"
|
||||||
kwargs = mock_gemini.call_args.kwargs
|
kwargs = mock_gemini.call_args.kwargs
|
||||||
|
assert "USER PROFILE:" in kwargs["contents"]
|
||||||
assert "function_handlers" in kwargs
|
assert "function_handlers" in kwargs
|
||||||
assert "get_product_inci" in kwargs["function_handlers"]
|
assert "get_product_inci" in kwargs["function_handlers"]
|
||||||
assert "get_product_safety_rules" in kwargs["function_handlers"]
|
assert "get_product_safety_rules" in kwargs["function_handlers"]
|
||||||
|
|
@ -111,7 +126,7 @@ def test_shopping_context_medication_skip(session: Session):
|
||||||
session.add(p)
|
session.add(p)
|
||||||
session.commit()
|
session.commit()
|
||||||
|
|
||||||
ctx = _build_shopping_context(session)
|
ctx = _build_shopping_context(session, reference_date=date.today())
|
||||||
assert "Epiduo" not in ctx
|
assert "Epiduo" not in ctx
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
35
backend/tests/test_profile.py
Normal file
35
backend/tests/test_profile.py
Normal file
|
|
@ -0,0 +1,35 @@
|
||||||
|
def test_get_profile_empty(client):
|
||||||
|
r = client.get("/profile")
|
||||||
|
assert r.status_code == 200
|
||||||
|
assert r.json() is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_upsert_profile_create_and_get(client):
|
||||||
|
create = client.patch(
|
||||||
|
"/profile", json={"birth_date": "1990-01-15", "sex_at_birth": "male"}
|
||||||
|
)
|
||||||
|
assert create.status_code == 200
|
||||||
|
body = create.json()
|
||||||
|
assert body["birth_date"] == "1990-01-15"
|
||||||
|
assert body["sex_at_birth"] == "male"
|
||||||
|
|
||||||
|
fetch = client.get("/profile")
|
||||||
|
assert fetch.status_code == 200
|
||||||
|
fetched = fetch.json()
|
||||||
|
assert fetched is not None
|
||||||
|
assert fetched["id"] == body["id"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_upsert_profile_updates_existing_row(client):
|
||||||
|
first = client.patch(
|
||||||
|
"/profile", json={"birth_date": "1992-06-10", "sex_at_birth": "female"}
|
||||||
|
)
|
||||||
|
assert first.status_code == 200
|
||||||
|
first_id = first.json()["id"]
|
||||||
|
|
||||||
|
second = client.patch("/profile", json={"sex_at_birth": "intersex"})
|
||||||
|
assert second.status_code == 200
|
||||||
|
second_body = second.json()
|
||||||
|
assert second_body["id"] == first_id
|
||||||
|
assert second_body["birth_date"] == "1992-06-10"
|
||||||
|
assert second_body["sex_at_birth"] == "intersex"
|
||||||
|
|
@ -248,6 +248,7 @@ def test_suggest_routine(client, session):
|
||||||
assert data["steps"][0]["action_type"] == "shaving_razor"
|
assert data["steps"][0]["action_type"] == "shaving_razor"
|
||||||
assert data["reasoning"] == "because"
|
assert data["reasoning"] == "because"
|
||||||
kwargs = mock_gemini.call_args.kwargs
|
kwargs = mock_gemini.call_args.kwargs
|
||||||
|
assert "USER PROFILE:" in kwargs["contents"]
|
||||||
assert "function_handlers" in kwargs
|
assert "function_handlers" in kwargs
|
||||||
assert "get_product_inci" in kwargs["function_handlers"]
|
assert "get_product_inci" in kwargs["function_handlers"]
|
||||||
assert "get_product_safety_rules" in kwargs["function_handlers"]
|
assert "get_product_safety_rules" in kwargs["function_handlers"]
|
||||||
|
|
@ -279,6 +280,8 @@ def test_suggest_batch(client, session):
|
||||||
assert len(data["days"]) == 1
|
assert len(data["days"]) == 1
|
||||||
assert data["days"][0]["date"] == "2026-03-03"
|
assert data["days"][0]["date"] == "2026-03-03"
|
||||||
assert data["overall_reasoning"] == "batch test"
|
assert data["overall_reasoning"] == "batch test"
|
||||||
|
kwargs = mock_gemini.call_args.kwargs
|
||||||
|
assert "USER PROFILE:" in kwargs["contents"]
|
||||||
|
|
||||||
|
|
||||||
def test_suggest_batch_invalid_date_range(client):
|
def test_suggest_batch_invalid_date_range(client):
|
||||||
|
|
|
||||||
|
|
@ -128,3 +128,33 @@ def test_delete_snapshot(client):
|
||||||
def test_delete_snapshot_not_found(client):
|
def test_delete_snapshot_not_found(client):
|
||||||
r = client.delete(f"/skincare/{uuid.uuid4()}")
|
r = client.delete(f"/skincare/{uuid.uuid4()}")
|
||||||
assert r.status_code == 404
|
assert r.status_code == 404
|
||||||
|
|
||||||
|
|
||||||
|
def test_analyze_photos_includes_user_profile_context(client, monkeypatch):
|
||||||
|
from innercontext.api import skincare as skincare_api
|
||||||
|
|
||||||
|
captured: dict[str, object] = {}
|
||||||
|
|
||||||
|
class _FakeResponse:
|
||||||
|
text = "{}"
|
||||||
|
|
||||||
|
def _fake_call_gemini(**kwargs):
|
||||||
|
captured.update(kwargs)
|
||||||
|
return _FakeResponse()
|
||||||
|
|
||||||
|
monkeypatch.setattr(skincare_api, "call_gemini", _fake_call_gemini)
|
||||||
|
|
||||||
|
profile = client.patch(
|
||||||
|
"/profile", json={"birth_date": "1991-02-10", "sex_at_birth": "female"}
|
||||||
|
)
|
||||||
|
assert profile.status_code == 200
|
||||||
|
|
||||||
|
r = client.post(
|
||||||
|
"/skincare/analyze-photos",
|
||||||
|
files={"photos": ("face.jpg", b"fake-bytes", "image/jpeg")},
|
||||||
|
)
|
||||||
|
assert r.status_code == 200
|
||||||
|
|
||||||
|
parts = captured["contents"]
|
||||||
|
assert isinstance(parts, list)
|
||||||
|
assert any("USER PROFILE:" in str(part) for part in parts)
|
||||||
|
|
|
||||||
|
|
@ -45,6 +45,7 @@ Global neutrals are defined in `frontend/src/app.css` using CSS variables.
|
||||||
- Products: `--accent-products`
|
- Products: `--accent-products`
|
||||||
- Routines: `--accent-routines`
|
- Routines: `--accent-routines`
|
||||||
- Skin: `--accent-skin`
|
- Skin: `--accent-skin`
|
||||||
|
- Profile: `--accent-profile`
|
||||||
- Health labs: `--accent-health-labs`
|
- Health labs: `--accent-health-labs`
|
||||||
- Health medications: `--accent-health-meds`
|
- Health medications: `--accent-health-meds`
|
||||||
|
|
||||||
|
|
@ -114,6 +115,7 @@ These classes are already in use and should be reused:
|
||||||
- Filter toolbars for data-heavy routes should use `GET` forms with URL params so state is shareable and pagination links preserve active filters.
|
- Filter toolbars for data-heavy routes should use `GET` forms with URL params so state is shareable and pagination links preserve active filters.
|
||||||
- Use the products filter pattern as the shared baseline: compact search input, chip-style toggle rows (`editorial-filter-row` + small `Button` variants), and apply/reset actions aligned at the end of the toolbar.
|
- Use the products filter pattern as the shared baseline: compact search input, chip-style toggle rows (`editorial-filter-row` + small `Button` variants), and apply/reset actions aligned at the end of the toolbar.
|
||||||
- For high-volume medical data lists, default the primary view to condensed/latest mode and offer full-history as an explicit secondary option.
|
- For high-volume medical data lists, default the primary view to condensed/latest mode and offer full-history as an explicit secondary option.
|
||||||
|
- For profile/settings forms, reuse shared primitives (`FormSectionCard`, `LabeledInputField`, `SimpleSelect`) before creating route-specific field wrappers.
|
||||||
- In condensed/latest mode, group rows by collection date using lightweight section headers (`products-section-title`) to preserve report context without introducing heavy card nesting.
|
- In condensed/latest mode, group rows by collection date using lightweight section headers (`products-section-title`) to preserve report context without introducing heavy card nesting.
|
||||||
- Change/highlight pills in dense tables should stay compact (`text-[10px]`), semantic (new/flag change/abnormal), and avoid overwhelming color blocks.
|
- Change/highlight pills in dense tables should stay compact (`text-[10px]`), semantic (new/flag change/abnormal), and avoid overwhelming color blocks.
|
||||||
- For lab results, keep ordering fixed to newest collection date (`collected_at DESC`) and remove non-essential controls (no lab filter and no manual sort selector).
|
- For lab results, keep ordering fixed to newest collection date (`collected_at DESC`) and remove non-essential controls (no lab filter and no manual sort selector).
|
||||||
|
|
@ -192,6 +194,7 @@ These classes are already in use and should be reused:
|
||||||
- `frontend/src/routes/+page.svelte`
|
- `frontend/src/routes/+page.svelte`
|
||||||
- `frontend/src/routes/products/+page.svelte`
|
- `frontend/src/routes/products/+page.svelte`
|
||||||
- `frontend/src/routes/routines/+page.svelte`
|
- `frontend/src/routes/routines/+page.svelte`
|
||||||
|
- `frontend/src/routes/profile/+page.svelte`
|
||||||
- `frontend/src/routes/health/lab-results/+page.svelte`
|
- `frontend/src/routes/health/lab-results/+page.svelte`
|
||||||
- `frontend/src/routes/skin/+page.svelte`
|
- `frontend/src/routes/skin/+page.svelte`
|
||||||
- Primitive visuals:
|
- Primitive visuals:
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,7 @@
|
||||||
"nav_medications": "Medications",
|
"nav_medications": "Medications",
|
||||||
"nav_labResults": "Lab Results",
|
"nav_labResults": "Lab Results",
|
||||||
"nav_skin": "Skin",
|
"nav_skin": "Skin",
|
||||||
|
"nav_profile": "Profile",
|
||||||
"nav_appName": "innercontext",
|
"nav_appName": "innercontext",
|
||||||
"nav_appSubtitle": "personal health & skincare",
|
"nav_appSubtitle": "personal health & skincare",
|
||||||
|
|
||||||
|
|
@ -389,6 +390,16 @@
|
||||||
"skin_typeNormal": "normal",
|
"skin_typeNormal": "normal",
|
||||||
"skin_typeAcneProne": "acne prone",
|
"skin_typeAcneProne": "acne prone",
|
||||||
|
|
||||||
|
"profile_title": "Profile",
|
||||||
|
"profile_subtitle": "Basic context for AI suggestions",
|
||||||
|
"profile_sectionBasic": "Basic profile",
|
||||||
|
"profile_birthDate": "Birth date",
|
||||||
|
"profile_sexAtBirth": "Sex at birth",
|
||||||
|
"profile_sexFemale": "Female",
|
||||||
|
"profile_sexMale": "Male",
|
||||||
|
"profile_sexIntersex": "Intersex",
|
||||||
|
"profile_saved": "Profile saved.",
|
||||||
|
|
||||||
"productForm_aiPrefill": "AI pre-fill",
|
"productForm_aiPrefill": "AI pre-fill",
|
||||||
"productForm_aiPrefillText": "Paste product description from a website, ingredient list, or other text. AI will fill in available fields — you can review and correct before saving.",
|
"productForm_aiPrefillText": "Paste product description from a website, ingredient list, or other text. AI will fill in available fields — you can review and correct before saving.",
|
||||||
"productForm_pasteText": "Paste product description, INCI ingredients here...",
|
"productForm_pasteText": "Paste product description, INCI ingredients here...",
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,7 @@
|
||||||
"nav_medications": "Leki",
|
"nav_medications": "Leki",
|
||||||
"nav_labResults": "Wyniki badań",
|
"nav_labResults": "Wyniki badań",
|
||||||
"nav_skin": "Skóra",
|
"nav_skin": "Skóra",
|
||||||
|
"nav_profile": "Profil",
|
||||||
"nav_appName": "innercontext",
|
"nav_appName": "innercontext",
|
||||||
"nav_appSubtitle": "zdrowie & pielęgnacja",
|
"nav_appSubtitle": "zdrowie & pielęgnacja",
|
||||||
|
|
||||||
|
|
@ -403,6 +404,16 @@
|
||||||
"skin_typeNormal": "normalna",
|
"skin_typeNormal": "normalna",
|
||||||
"skin_typeAcneProne": "trądzikowa",
|
"skin_typeAcneProne": "trądzikowa",
|
||||||
|
|
||||||
|
"profile_title": "Profil",
|
||||||
|
"profile_subtitle": "Podstawowy kontekst dla sugestii AI",
|
||||||
|
"profile_sectionBasic": "Profil podstawowy",
|
||||||
|
"profile_birthDate": "Data urodzenia",
|
||||||
|
"profile_sexAtBirth": "Płeć biologiczna",
|
||||||
|
"profile_sexFemale": "Kobieta",
|
||||||
|
"profile_sexMale": "Mężczyzna",
|
||||||
|
"profile_sexIntersex": "Interpłciowa",
|
||||||
|
"profile_saved": "Profil zapisany.",
|
||||||
|
|
||||||
"productForm_aiPrefill": "Uzupełnienie AI",
|
"productForm_aiPrefill": "Uzupełnienie AI",
|
||||||
"productForm_aiPrefillText": "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.",
|
"productForm_aiPrefillText": "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.",
|
||||||
"productForm_pasteText": "Wklej tutaj opis produktu, składniki INCI...",
|
"productForm_pasteText": "Wklej tutaj opis produktu, składniki INCI...",
|
||||||
|
|
|
||||||
|
|
@ -36,6 +36,7 @@
|
||||||
--accent-products: hsl(95 28% 33%);
|
--accent-products: hsl(95 28% 33%);
|
||||||
--accent-routines: hsl(186 27% 33%);
|
--accent-routines: hsl(186 27% 33%);
|
||||||
--accent-skin: hsl(16 51% 44%);
|
--accent-skin: hsl(16 51% 44%);
|
||||||
|
--accent-profile: hsl(198 29% 35%);
|
||||||
--accent-health-labs: hsl(212 41% 39%);
|
--accent-health-labs: hsl(212 41% 39%);
|
||||||
--accent-health-meds: hsl(140 31% 33%);
|
--accent-health-meds: hsl(140 31% 33%);
|
||||||
|
|
||||||
|
|
@ -136,6 +137,11 @@ body {
|
||||||
--page-accent-soft: hsl(20 52% 88%);
|
--page-accent-soft: hsl(20 52% 88%);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.domain-profile {
|
||||||
|
--page-accent: var(--accent-profile);
|
||||||
|
--page-accent-soft: hsl(196 30% 89%);
|
||||||
|
}
|
||||||
|
|
||||||
.domain-health-labs {
|
.domain-health-labs {
|
||||||
--page-accent: var(--accent-health-labs);
|
--page-accent: var(--accent-health-labs);
|
||||||
--page-accent-soft: hsl(208 38% 88%);
|
--page-accent-soft: hsl(208 38% 88%);
|
||||||
|
|
|
||||||
|
|
@ -17,6 +17,7 @@ import type {
|
||||||
RoutineSuggestion,
|
RoutineSuggestion,
|
||||||
RoutineStep,
|
RoutineStep,
|
||||||
SkinConditionSnapshot,
|
SkinConditionSnapshot,
|
||||||
|
UserProfile,
|
||||||
} from "./types";
|
} from "./types";
|
||||||
|
|
||||||
// ─── Core fetch helpers ──────────────────────────────────────────────────────
|
// ─── Core fetch helpers ──────────────────────────────────────────────────────
|
||||||
|
|
@ -47,6 +48,14 @@ export const api = {
|
||||||
del: (path: string) => request<void>(path, { method: "DELETE" }),
|
del: (path: string) => request<void>(path, { method: "DELETE" }),
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// ─── Profile ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export const getProfile = (): Promise<UserProfile | null> => api.get("/profile");
|
||||||
|
|
||||||
|
export const updateProfile = (
|
||||||
|
body: { birth_date?: string; sex_at_birth?: "male" | "female" | "intersex" },
|
||||||
|
): Promise<UserProfile> => api.patch("/profile", body);
|
||||||
|
|
||||||
// ─── Products ────────────────────────────────────────────────────────────────
|
// ─── Products ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
export interface ProductListParams {
|
export interface ProductListParams {
|
||||||
|
|
|
||||||
|
|
@ -70,6 +70,7 @@ export type SkinConcern =
|
||||||
| "hair_growth"
|
| "hair_growth"
|
||||||
| "sebum_excess";
|
| "sebum_excess";
|
||||||
export type SkinTexture = "smooth" | "rough" | "flaky" | "bumpy";
|
export type SkinTexture = "smooth" | "rough" | "flaky" | "bumpy";
|
||||||
|
export type SexAtBirth = "male" | "female" | "intersex";
|
||||||
export type SkinType =
|
export type SkinType =
|
||||||
| "dry"
|
| "dry"
|
||||||
| "oily"
|
| "oily"
|
||||||
|
|
@ -348,3 +349,11 @@ export interface SkinConditionSnapshot {
|
||||||
notes?: string;
|
notes?: string;
|
||||||
created_at: string;
|
created_at: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface UserProfile {
|
||||||
|
id: string;
|
||||||
|
birth_date?: string;
|
||||||
|
sex_at_birth?: SexAtBirth;
|
||||||
|
created_at: string;
|
||||||
|
updated_at: string;
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -12,6 +12,7 @@
|
||||||
Pill,
|
Pill,
|
||||||
FlaskConical,
|
FlaskConical,
|
||||||
Sparkles,
|
Sparkles,
|
||||||
|
UserRound,
|
||||||
Menu,
|
Menu,
|
||||||
X
|
X
|
||||||
} from 'lucide-svelte';
|
} from 'lucide-svelte';
|
||||||
|
|
@ -27,6 +28,7 @@
|
||||||
{ href: resolve('/routines/grooming-schedule'), label: m.nav_grooming(), icon: Scissors },
|
{ href: resolve('/routines/grooming-schedule'), label: m.nav_grooming(), icon: Scissors },
|
||||||
{ href: resolve('/products'), label: m.nav_products(), icon: Package },
|
{ href: resolve('/products'), label: m.nav_products(), icon: Package },
|
||||||
{ href: resolve('/skin'), label: m.nav_skin(), icon: Sparkles },
|
{ href: resolve('/skin'), label: m.nav_skin(), icon: Sparkles },
|
||||||
|
{ href: resolve('/profile'), label: m.nav_profile(), icon: UserRound },
|
||||||
{ href: resolve('/health/medications'), label: m.nav_medications(), icon: Pill },
|
{ href: resolve('/health/medications'), label: m.nav_medications(), icon: Pill },
|
||||||
{ href: resolve('/health/lab-results'), label: m["nav_labResults"](), icon: FlaskConical }
|
{ href: resolve('/health/lab-results'), label: m["nav_labResults"](), icon: FlaskConical }
|
||||||
]);
|
]);
|
||||||
|
|
@ -46,6 +48,7 @@
|
||||||
if (pathname.startsWith('/products')) return 'domain-products';
|
if (pathname.startsWith('/products')) return 'domain-products';
|
||||||
if (pathname.startsWith('/routines')) return 'domain-routines';
|
if (pathname.startsWith('/routines')) return 'domain-routines';
|
||||||
if (pathname.startsWith('/skin')) return 'domain-skin';
|
if (pathname.startsWith('/skin')) return 'domain-skin';
|
||||||
|
if (pathname.startsWith('/profile')) return 'domain-profile';
|
||||||
if (pathname.startsWith('/health/lab-results')) return 'domain-health-labs';
|
if (pathname.startsWith('/health/lab-results')) return 'domain-health-labs';
|
||||||
if (pathname.startsWith('/health/medications')) return 'domain-health-meds';
|
if (pathname.startsWith('/health/medications')) return 'domain-health-meds';
|
||||||
return 'domain-dashboard';
|
return 'domain-dashboard';
|
||||||
|
|
|
||||||
29
frontend/src/routes/profile/+page.server.ts
Normal file
29
frontend/src/routes/profile/+page.server.ts
Normal file
|
|
@ -0,0 +1,29 @@
|
||||||
|
import { getProfile, updateProfile } from '$lib/api';
|
||||||
|
import { fail } from '@sveltejs/kit';
|
||||||
|
import type { Actions, PageServerLoad } from './$types';
|
||||||
|
|
||||||
|
export const load: PageServerLoad = async () => {
|
||||||
|
const profile = await getProfile();
|
||||||
|
return { profile };
|
||||||
|
};
|
||||||
|
|
||||||
|
export const actions: Actions = {
|
||||||
|
save: async ({ request }) => {
|
||||||
|
const form = await request.formData();
|
||||||
|
const birth_date_raw = String(form.get('birth_date') ?? '').trim();
|
||||||
|
const sex_at_birth_raw = String(form.get('sex_at_birth') ?? '').trim();
|
||||||
|
|
||||||
|
const payload: { birth_date?: string; sex_at_birth?: 'male' | 'female' | 'intersex' } = {};
|
||||||
|
if (birth_date_raw) payload.birth_date = birth_date_raw;
|
||||||
|
if (sex_at_birth_raw === 'male' || sex_at_birth_raw === 'female' || sex_at_birth_raw === 'intersex') {
|
||||||
|
payload.sex_at_birth = sex_at_birth_raw;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const profile = await updateProfile(payload);
|
||||||
|
return { saved: true, profile };
|
||||||
|
} catch (e) {
|
||||||
|
return fail(502, { error: (e as Error).message });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
62
frontend/src/routes/profile/+page.svelte
Normal file
62
frontend/src/routes/profile/+page.svelte
Normal file
|
|
@ -0,0 +1,62 @@
|
||||||
|
<script lang="ts">
|
||||||
|
import { enhance } from '$app/forms';
|
||||||
|
import { untrack } from 'svelte';
|
||||||
|
import type { ActionData, PageData } from './$types';
|
||||||
|
import { m } from '$lib/paraglide/messages.js';
|
||||||
|
import { Button } from '$lib/components/ui/button';
|
||||||
|
import FormSectionCard from '$lib/components/forms/FormSectionCard.svelte';
|
||||||
|
import LabeledInputField from '$lib/components/forms/LabeledInputField.svelte';
|
||||||
|
import SimpleSelect from '$lib/components/forms/SimpleSelect.svelte';
|
||||||
|
|
||||||
|
let { data, form }: { data: PageData; form: ActionData } = $props();
|
||||||
|
|
||||||
|
let birthDate = $state(untrack(() => data.profile?.birth_date ?? ''));
|
||||||
|
let sexAtBirth = $state(untrack(() => data.profile?.sex_at_birth ?? ''));
|
||||||
|
|
||||||
|
const sexOptions = $derived([
|
||||||
|
{ value: 'female', label: m.profile_sexFemale() },
|
||||||
|
{ value: 'male', label: m.profile_sexMale() },
|
||||||
|
{ value: 'intersex', label: m.profile_sexIntersex() }
|
||||||
|
]);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<svelte:head><title>{m.profile_title()} — innercontext</title></svelte:head>
|
||||||
|
|
||||||
|
<div class="editorial-page space-y-4">
|
||||||
|
<section class="editorial-hero reveal-1">
|
||||||
|
<p class="editorial-kicker">{m["nav_appSubtitle"]()}</p>
|
||||||
|
<h2 class="editorial-title">{m.profile_title()}</h2>
|
||||||
|
<p class="editorial-subtitle">{m.profile_subtitle()}</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{#if form?.error}
|
||||||
|
<div class="editorial-alert editorial-alert--error">{form.error}</div>
|
||||||
|
{/if}
|
||||||
|
{#if form?.saved}
|
||||||
|
<div class="editorial-alert editorial-alert--success">{m.profile_saved()}</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<form method="POST" action="?/save" use:enhance class="reveal-2 space-y-4">
|
||||||
|
<FormSectionCard title={m.profile_sectionBasic()} contentClassName="space-y-4">
|
||||||
|
<LabeledInputField
|
||||||
|
id="birth_date"
|
||||||
|
name="birth_date"
|
||||||
|
label={m.profile_birthDate()}
|
||||||
|
type="date"
|
||||||
|
bind:value={birthDate}
|
||||||
|
/>
|
||||||
|
<SimpleSelect
|
||||||
|
id="sex_at_birth"
|
||||||
|
name="sex_at_birth"
|
||||||
|
label={m.profile_sexAtBirth()}
|
||||||
|
options={sexOptions}
|
||||||
|
placeholder={m.common_select()}
|
||||||
|
bind:value={sexAtBirth}
|
||||||
|
/>
|
||||||
|
</FormSectionCard>
|
||||||
|
|
||||||
|
<div class="flex justify-end">
|
||||||
|
<Button type="submit">{m.common_save()}</Button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
Loading…
Add table
Add a link
Reference in a new issue