diff --git a/README.md b/README.md index 75a6f0b..c3c683d 100644 --- a/README.md +++ b/README.md @@ -58,6 +58,7 @@ UI available at `http://localhost:5173`. | `/routines` | AM/PM skincare routines and steps | | `/routines/grooming-schedule` | Weekly grooming schedule | | `/skincare` | Weekly skin condition snapshots | +| `/profile` | User profile (birth date, sex at birth) | | `/health-check` | Liveness probe | ## Frontend routes @@ -74,6 +75,7 @@ UI available at `http://localhost:5173`. | `/health/medications` | Medications | | `/health/lab-results` | Lab results | | `/skin` | Skin condition snapshots | +| `/profile` | User profile | ## Development diff --git a/backend/alembic/versions/1f7e3b9c4a2d_add_user_profile_table.py b/backend/alembic/versions/1f7e3b9c4a2d_add_user_profile_table.py new file mode 100644 index 0000000..435e0d7 --- /dev/null +++ b/backend/alembic/versions/1f7e3b9c4a2d_add_user_profile_table.py @@ -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") diff --git a/backend/innercontext/api/llm_context.py b/backend/innercontext/api/llm_context.py new file mode 100644 index 0000000..05bd492 --- /dev/null +++ b/backend/innercontext/api/llm_context.py @@ -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" diff --git a/backend/innercontext/api/products.py b/backend/innercontext/api/products.py index b8c6630..656e2da 100644 --- a/backend/innercontext/api/products.py +++ b/backend/innercontext/api/products.py @@ -12,6 +12,7 @@ from sqlmodel import Field, Session, SQLModel, col, select from db import get_session from innercontext.api.utils import get_or_404 +from innercontext.api.llm_context import build_user_profile_context from innercontext.llm import ( call_gemini, call_gemini_with_function_tools, @@ -786,7 +787,8 @@ def _ev(v: object) -> str: 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( select(SkinConditionSnapshot).order_by( col(SkinConditionSnapshot.snapshot_date).desc() @@ -854,7 +856,9 @@ def _build_shopping_context(session: Session) -> 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]: @@ -1067,7 +1071,7 @@ Format odpowiedzi - zwróć wyłącznie JSON zgodny z podanym schematem.""" @router.post("/suggest", response_model=ShoppingSuggestionResponse) 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) prompt = ( diff --git a/backend/innercontext/api/profile.py b/backend/innercontext/api/profile.py new file mode 100644 index 0000000..52e8e14 --- /dev/null +++ b/backend/innercontext/api/profile.py @@ -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, + ) diff --git a/backend/innercontext/api/routines.py b/backend/innercontext/api/routines.py index 6baf763..0f79e14 100644 --- a/backend/innercontext/api/routines.py +++ b/backend/innercontext/api/routines.py @@ -10,6 +10,7 @@ from sqlmodel import Field, Session, SQLModel, col, select from db import get_session from innercontext.api.utils import get_or_404 +from innercontext.api.llm_context import build_user_profile_context from innercontext.llm import ( call_gemini, call_gemini_with_function_tools, @@ -760,6 +761,7 @@ def suggest_routine( ): weekday = data.routine_date.weekday() 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]) history_ctx = _build_recent_history(session) 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"{mode_line}\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" "- 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" @@ -924,6 +926,7 @@ def suggest_batch( weekdays = list( {(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) grooming_ctx = _build_grooming_context(session, weekdays=weekdays) history_ctx = _build_recent_history(session) @@ -950,7 +953,7 @@ def suggest_batch( prompt = ( f"Zaproponuj plan pielęgnacji AM + PM dla każdego dnia z zakresu:\n{dates_str}\n\n{mode_line}\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}" "\nZwróć JSON zgodny ze schematem." ) diff --git a/backend/innercontext/api/skincare.py b/backend/innercontext/api/skincare.py index 8998e50..0ca7adb 100644 --- a/backend/innercontext/api/skincare.py +++ b/backend/innercontext/api/skincare.py @@ -10,6 +10,7 @@ from pydantic import ValidationError from sqlmodel import Session, SQLModel, select 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.llm import call_gemini, get_extraction_config from innercontext.models import ( @@ -136,6 +137,7 @@ MAX_IMAGE_BYTES = 5 * 1024 * 1024 # 5 MB @router.post("/analyze-photos", response_model=SkinPhotoAnalysisResponse) async def analyze_skin_photos( photos: list[UploadFile] = File(...), + session: Session = Depends(get_session), ) -> SkinPhotoAnalysisResponse: if not (1 <= len(photos) <= 3): 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." ) ) + 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)}" response = call_gemini( diff --git a/backend/innercontext/models/__init__.py b/backend/innercontext/models/__init__.py index 141045e..7cad5ee 100644 --- a/backend/innercontext/models/__init__.py +++ b/backend/innercontext/models/__init__.py @@ -14,6 +14,7 @@ from .enums import ( ProductCategory, ResultFlag, RoutineRole, + SexAtBirth, SkinConcern, SkinTexture, SkinType, @@ -33,6 +34,7 @@ from .product import ( ProductWithInventory, ) from .pricing import PricingRecalcJob +from .profile import UserProfile from .routine import GroomingSchedule, Routine, RoutineStep from .skincare import ( SkinConditionSnapshot, @@ -59,6 +61,7 @@ __all__ = [ "ProductCategory", "ResultFlag", "RoutineRole", + "SexAtBirth", "SkinConcern", "SkinTexture", "SkinType", @@ -79,6 +82,7 @@ __all__ = [ "ProductPublic", "ProductWithInventory", "PricingRecalcJob", + "UserProfile", # routine "GroomingSchedule", "Routine", diff --git a/backend/innercontext/models/enums.py b/backend/innercontext/models/enums.py index a18e85c..96f1c6a 100644 --- a/backend/innercontext/models/enums.py +++ b/backend/innercontext/models/enums.py @@ -153,6 +153,12 @@ class MedicationKind(str, Enum): OTHER = "other" +class SexAtBirth(str, Enum): + MALE = "male" + FEMALE = "female" + INTERSEX = "intersex" + + # --------------------------------------------------------------------------- # Routine # --------------------------------------------------------------------------- diff --git a/backend/innercontext/models/profile.py b/backend/innercontext/models/profile.py new file mode 100644 index 0000000..fab7fd0 --- /dev/null +++ b/backend/innercontext/models/profile.py @@ -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, + ), + ) diff --git a/backend/main.py b/backend/main.py index 57e48d4..370a06b 100644 --- a/backend/main.py +++ b/backend/main.py @@ -14,6 +14,7 @@ from innercontext.api import ( # noqa: E402 ai_logs, health, inventory, + profile, products, routines, skincare, @@ -48,6 +49,7 @@ app.add_middleware( app.include_router(products.router, prefix="/products", tags=["products"]) 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(routines.router, prefix="/routines", tags=["routines"]) app.include_router(skincare.router, prefix="/skincare", tags=["skincare"]) diff --git a/backend/tests/test_llm_profile_context.py b/backend/tests/test_llm_profile_context.py new file mode 100644 index 0000000..6edb936 --- /dev/null +++ b/backend/tests/test_llm_profile_context.py @@ -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 diff --git a/backend/tests/test_products_helpers.py b/backend/tests/test_products_helpers.py index dde845e..941d441 100644 --- a/backend/tests/test_products_helpers.py +++ b/backend/tests/test_products_helpers.py @@ -11,15 +11,26 @@ from innercontext.api.products import ( _build_shopping_context, _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): # 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 "POSIADANE PRODUKTY" in ctx + profile = UserProfile(birth_date=date(1990, 1, 10), sex_at_birth=SexAtBirth.MALE) + session.add(profile) + session.commit() + # Add snapshot snap = SkinConditionSnapshot( id=uuid.uuid4(), @@ -54,7 +65,10 @@ def test_build_shopping_context(session: Session): session.add(inv) 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 "Nawilżenie: 3/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["reasoning"] == "Test shopping" kwargs = mock_gemini.call_args.kwargs + assert "USER PROFILE:" in kwargs["contents"] assert "function_handlers" in kwargs assert "get_product_inci" 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.commit() - ctx = _build_shopping_context(session) + ctx = _build_shopping_context(session, reference_date=date.today()) assert "Epiduo" not in ctx diff --git a/backend/tests/test_profile.py b/backend/tests/test_profile.py new file mode 100644 index 0000000..48069f9 --- /dev/null +++ b/backend/tests/test_profile.py @@ -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" diff --git a/backend/tests/test_routines.py b/backend/tests/test_routines.py index 8e7d54a..e59e81c 100644 --- a/backend/tests/test_routines.py +++ b/backend/tests/test_routines.py @@ -248,6 +248,7 @@ def test_suggest_routine(client, session): assert data["steps"][0]["action_type"] == "shaving_razor" assert data["reasoning"] == "because" kwargs = mock_gemini.call_args.kwargs + assert "USER PROFILE:" in kwargs["contents"] assert "function_handlers" in kwargs assert "get_product_inci" 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 data["days"][0]["date"] == "2026-03-03" 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): diff --git a/backend/tests/test_skincare.py b/backend/tests/test_skincare.py index 5bf9db5..b6ce4b0 100644 --- a/backend/tests/test_skincare.py +++ b/backend/tests/test_skincare.py @@ -128,3 +128,33 @@ def test_delete_snapshot(client): def test_delete_snapshot_not_found(client): r = client.delete(f"/skincare/{uuid.uuid4()}") 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) diff --git a/docs/frontend-design-cookbook.md b/docs/frontend-design-cookbook.md index d1cc169..5dc1f00 100644 --- a/docs/frontend-design-cookbook.md +++ b/docs/frontend-design-cookbook.md @@ -45,6 +45,7 @@ Global neutrals are defined in `frontend/src/app.css` using CSS variables. - Products: `--accent-products` - Routines: `--accent-routines` - Skin: `--accent-skin` +- Profile: `--accent-profile` - Health labs: `--accent-health-labs` - 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. - 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 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. - 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). @@ -192,6 +194,7 @@ These classes are already in use and should be reused: - `frontend/src/routes/+page.svelte` - `frontend/src/routes/products/+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/skin/+page.svelte` - Primitive visuals: diff --git a/frontend/messages/en.json b/frontend/messages/en.json index 8ee99f5..bd9c882 100644 --- a/frontend/messages/en.json +++ b/frontend/messages/en.json @@ -6,6 +6,7 @@ "nav_medications": "Medications", "nav_labResults": "Lab Results", "nav_skin": "Skin", + "nav_profile": "Profile", "nav_appName": "innercontext", "nav_appSubtitle": "personal health & skincare", @@ -389,6 +390,16 @@ "skin_typeNormal": "normal", "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_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...", diff --git a/frontend/messages/pl.json b/frontend/messages/pl.json index 9e45d4b..3ee2d23 100644 --- a/frontend/messages/pl.json +++ b/frontend/messages/pl.json @@ -6,6 +6,7 @@ "nav_medications": "Leki", "nav_labResults": "Wyniki badań", "nav_skin": "Skóra", + "nav_profile": "Profil", "nav_appName": "innercontext", "nav_appSubtitle": "zdrowie & pielęgnacja", @@ -403,6 +404,16 @@ "skin_typeNormal": "normalna", "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_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...", diff --git a/frontend/src/app.css b/frontend/src/app.css index fe2a6c1..b2d103a 100644 --- a/frontend/src/app.css +++ b/frontend/src/app.css @@ -36,6 +36,7 @@ --accent-products: hsl(95 28% 33%); --accent-routines: hsl(186 27% 33%); --accent-skin: hsl(16 51% 44%); + --accent-profile: hsl(198 29% 35%); --accent-health-labs: hsl(212 41% 39%); --accent-health-meds: hsl(140 31% 33%); @@ -136,6 +137,11 @@ body { --page-accent-soft: hsl(20 52% 88%); } +.domain-profile { + --page-accent: var(--accent-profile); + --page-accent-soft: hsl(196 30% 89%); +} + .domain-health-labs { --page-accent: var(--accent-health-labs); --page-accent-soft: hsl(208 38% 88%); diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts index 193fb81..49bd01c 100644 --- a/frontend/src/lib/api.ts +++ b/frontend/src/lib/api.ts @@ -17,6 +17,7 @@ import type { RoutineSuggestion, RoutineStep, SkinConditionSnapshot, + UserProfile, } from "./types"; // ─── Core fetch helpers ────────────────────────────────────────────────────── @@ -47,6 +48,14 @@ export const api = { del: (path: string) => request(path, { method: "DELETE" }), }; +// ─── Profile ───────────────────────────────────────────────────────────────── + +export const getProfile = (): Promise => api.get("/profile"); + +export const updateProfile = ( + body: { birth_date?: string; sex_at_birth?: "male" | "female" | "intersex" }, +): Promise => api.patch("/profile", body); + // ─── Products ──────────────────────────────────────────────────────────────── export interface ProductListParams { diff --git a/frontend/src/lib/types.ts b/frontend/src/lib/types.ts index 5ec7b70..77758d5 100644 --- a/frontend/src/lib/types.ts +++ b/frontend/src/lib/types.ts @@ -70,6 +70,7 @@ export type SkinConcern = | "hair_growth" | "sebum_excess"; export type SkinTexture = "smooth" | "rough" | "flaky" | "bumpy"; +export type SexAtBirth = "male" | "female" | "intersex"; export type SkinType = | "dry" | "oily" @@ -348,3 +349,11 @@ export interface SkinConditionSnapshot { notes?: string; created_at: string; } + +export interface UserProfile { + id: string; + birth_date?: string; + sex_at_birth?: SexAtBirth; + created_at: string; + updated_at: string; +} diff --git a/frontend/src/routes/+layout.svelte b/frontend/src/routes/+layout.svelte index 7463efa..f331346 100644 --- a/frontend/src/routes/+layout.svelte +++ b/frontend/src/routes/+layout.svelte @@ -12,6 +12,7 @@ Pill, FlaskConical, Sparkles, + UserRound, Menu, X } from 'lucide-svelte'; @@ -27,6 +28,7 @@ { href: resolve('/routines/grooming-schedule'), label: m.nav_grooming(), icon: Scissors }, { href: resolve('/products'), label: m.nav_products(), icon: Package }, { 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/lab-results'), label: m["nav_labResults"](), icon: FlaskConical } ]); @@ -46,6 +48,7 @@ if (pathname.startsWith('/products')) return 'domain-products'; if (pathname.startsWith('/routines')) return 'domain-routines'; 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/medications')) return 'domain-health-meds'; return 'domain-dashboard'; diff --git a/frontend/src/routes/profile/+page.server.ts b/frontend/src/routes/profile/+page.server.ts new file mode 100644 index 0000000..a0b8468 --- /dev/null +++ b/frontend/src/routes/profile/+page.server.ts @@ -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 }); + } + } +}; diff --git a/frontend/src/routes/profile/+page.svelte b/frontend/src/routes/profile/+page.svelte new file mode 100644 index 0000000..70191af --- /dev/null +++ b/frontend/src/routes/profile/+page.svelte @@ -0,0 +1,62 @@ + + +{m.profile_title()} — innercontext + +
+
+

{m["nav_appSubtitle"]()}

+

{m.profile_title()}

+

{m.profile_subtitle()}

+
+ + {#if form?.error} +
{form.error}
+ {/if} + {#if form?.saved} +
{m.profile_saved()}
+ {/if} + +
+ + + + + +
+ +
+
+