feat(profile): add profile settings and LLM user context

This commit is contained in:
Piotr Oleszczyk 2026-03-05 15:57:21 +01:00
parent db3d9514d5
commit b99b9ed68e
25 changed files with 472 additions and 9 deletions

View file

@ -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...",

View file

@ -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...",

View file

@ -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%);

View file

@ -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<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 ────────────────────────────────────────────────────────────────
export interface ProductListParams {

View file

@ -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;
}

View file

@ -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';

View 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 });
}
}
};

View 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>