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
|
|
@ -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...",
|
||||
|
|
|
|||
|
|
@ -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...",
|
||||
|
|
|
|||
|
|
@ -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%);
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
|
|||
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