diff --git a/frontend/.env.example b/frontend/.env.example new file mode 100644 index 0000000..0e3a50c --- /dev/null +++ b/frontend/.env.example @@ -0,0 +1 @@ +PUBLIC_API_BASE=http://localhost:8000 diff --git a/frontend/package.json b/frontend/package.json index 6a57ce6..60af085 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -12,12 +12,15 @@ "check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch" }, "devDependencies": { + "@internationalized/date": "^3.11.0", + "@lucide/svelte": "^0.561.0", "@sveltejs/adapter-auto": "^7.0.0", "@sveltejs/kit": "^2.50.2", "@sveltejs/vite-plugin-svelte": "^6.2.4", "@tailwindcss/vite": "^4.2.1", "svelte": "^5.51.0", "svelte-check": "^4.3.6", + "tailwind-variants": "^3.2.2", "tailwindcss": "^4.2.1", "typescript": "^5.9.3", "vite": "^7.3.1" diff --git a/frontend/pnpm-lock.yaml b/frontend/pnpm-lock.yaml index d941fb1..7c5a708 100644 --- a/frontend/pnpm-lock.yaml +++ b/frontend/pnpm-lock.yaml @@ -24,6 +24,12 @@ importers: specifier: ^3.5.0 version: 3.5.0 devDependencies: + '@internationalized/date': + specifier: ^3.11.0 + version: 3.11.0 + '@lucide/svelte': + specifier: ^0.561.0 + version: 0.561.0(svelte@5.53.5) '@sveltejs/adapter-auto': specifier: ^7.0.0 version: 7.0.1(@sveltejs/kit@2.53.2(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.53.5)(vite@7.3.1(jiti@2.6.1)(lightningcss@1.31.1)))(svelte@5.53.5)(typescript@5.9.3)(vite@7.3.1(jiti@2.6.1)(lightningcss@1.31.1))) @@ -42,6 +48,9 @@ importers: svelte-check: specifier: ^4.3.6 version: 4.4.4(picomatch@4.0.3)(svelte@5.53.5)(typescript@5.9.3) + tailwind-variants: + specifier: ^3.2.2 + version: 3.2.2(tailwind-merge@3.5.0)(tailwindcss@4.2.1) tailwindcss: specifier: ^4.2.1 version: 4.2.1 @@ -238,6 +247,11 @@ packages: '@jridgewell/trace-mapping@0.3.31': resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==} + '@lucide/svelte@0.561.0': + resolution: {integrity: sha512-vofKV2UFVrKE6I4ewKJ3dfCXSV6iP6nWVmiM83MLjsU91EeJcEg7LoWUABLp/aOTxj1HQNbJD1f3g3L0JQgH9A==} + peerDependencies: + svelte: ^5 + '@polka/url@1.0.0-next.29': resolution: {integrity: sha512-wwQAWhWSuHaag8c4q/KN/vCoeOJYshAIvMQwD4GpSb3OiZklFfvAgmj0VCBBImRpuF/aFgIRzllXlVX93Jevww==} @@ -819,6 +833,16 @@ packages: tailwind-merge@3.5.0: resolution: {integrity: sha512-I8K9wewnVDkL1NTGoqWmVEIlUcB9gFriAEkXkfCjX5ib8ezGxtR3xD7iZIxrfArjEsH7F1CHD4RFUtxefdqV/A==} + tailwind-variants@3.2.2: + resolution: {integrity: sha512-Mi4kHeMTLvKlM98XPnK+7HoBPmf4gygdFmqQPaDivc3DpYS6aIY6KiG/PgThrGvii5YZJqRsPz0aPyhoFzmZgg==} + engines: {node: '>=16.x', pnpm: '>=7.x'} + peerDependencies: + tailwind-merge: '>=3.0.0' + tailwindcss: '*' + peerDependenciesMeta: + tailwind-merge: + optional: true + tailwindcss@4.2.1: resolution: {integrity: sha512-/tBrSQ36vCleJkAOsy9kbNTgaxvGbyOamC30PRePTQe/o1MFwEKHQk4Cn7BNGaPtjp+PuUrByJehM1hgxfq4sw==} @@ -1007,6 +1031,10 @@ snapshots: '@jridgewell/resolve-uri': 3.1.2 '@jridgewell/sourcemap-codec': 1.5.5 + '@lucide/svelte@0.561.0(svelte@5.53.5)': + dependencies: + svelte: 5.53.5 + '@polka/url@1.0.0-next.29': {} '@rollup/rollup-android-arm-eabi@4.59.0': @@ -1509,6 +1537,12 @@ snapshots: tailwind-merge@3.5.0: {} + tailwind-variants@3.2.2(tailwind-merge@3.5.0)(tailwindcss@4.2.1): + dependencies: + tailwindcss: 4.2.1 + optionalDependencies: + tailwind-merge: 3.5.0 + tailwindcss@4.2.1: {} tapable@2.3.0: {} diff --git a/frontend/src/app.css b/frontend/src/app.css index 489ff7e..130b9cb 100644 --- a/frontend/src/app.css +++ b/frontend/src/app.css @@ -1,56 +1,89 @@ @import "tailwindcss"; -@layer base { - :root { - --background: 0 0% 100%; - --foreground: 240 10% 3.9%; - --card: 0 0% 100%; - --card-foreground: 240 10% 3.9%; - --popover: 0 0% 100%; - --popover-foreground: 240 10% 3.9%; - --primary: 240 5.9% 10%; - --primary-foreground: 0 0% 98%; - --secondary: 240 4.8% 95.9%; - --secondary-foreground: 240 5.9% 10%; - --muted: 240 4.8% 95.9%; - --muted-foreground: 240 3.8% 46.1%; - --accent: 240 4.8% 95.9%; - --accent-foreground: 240 5.9% 10%; - --destructive: 0 84.2% 60.2%; - --destructive-foreground: 0 0% 98%; - --border: 240 5.9% 90%; - --input: 240 5.9% 90%; - --ring: 240 5.9% 10%; - --radius: 0.5rem; - } - .dark { - --background: 240 10% 3.9%; - --foreground: 0 0% 98%; - --card: 240 10% 3.9%; - --card-foreground: 0 0% 98%; - --popover: 240 10% 3.9%; - --popover-foreground: 0 0% 98%; - --primary: 0 0% 98%; - --primary-foreground: 240 5.9% 10%; - --secondary: 240 3.7% 15.9%; - --secondary-foreground: 0 0% 98%; - --muted: 240 3.7% 15.9%; - --muted-foreground: 240 5% 64.9%; - --accent: 240 3.7% 15.9%; - --accent-foreground: 0 0% 98%; - --destructive: 0 62.8% 30.6%; - --destructive-foreground: 0 0% 98%; - --border: 240 3.7% 15.9%; - --input: 240 3.7% 15.9%; - --ring: 240 4.9% 83.9%; - } +@custom-variant dark (&:is(.dark *)); + +/* ── CSS variable definitions (light / dark) ─────────────────────────────── */ + +:root { + --background: hsl(0 0% 100%); + --foreground: hsl(240 10% 3.9%); + --card: hsl(0 0% 100%); + --card-foreground: hsl(240 10% 3.9%); + --popover: hsl(0 0% 100%); + --popover-foreground: hsl(240 10% 3.9%); + --primary: hsl(240 5.9% 10%); + --primary-foreground: hsl(0 0% 98%); + --secondary: hsl(240 4.8% 95.9%); + --secondary-foreground: hsl(240 5.9% 10%); + --muted: hsl(240 4.8% 95.9%); + --muted-foreground: hsl(240 3.8% 46.1%); + --accent: hsl(240 4.8% 95.9%); + --accent-foreground: hsl(240 5.9% 10%); + --destructive: hsl(0 84.2% 60.2%); + --destructive-foreground: hsl(0 0% 98%); + --border: hsl(240 5.9% 90%); + --input: hsl(240 5.9% 90%); + --ring: hsl(240 5.9% 10%); + --radius: 0.5rem; } -@layer base { - * { - @apply border-border; - } - body { - @apply bg-background text-foreground; - } +.dark { + --background: hsl(240 10% 3.9%); + --foreground: hsl(0 0% 98%); + --card: hsl(240 10% 3.9%); + --card-foreground: hsl(0 0% 98%); + --popover: hsl(240 10% 3.9%); + --popover-foreground: hsl(0 0% 98%); + --primary: hsl(0 0% 98%); + --primary-foreground: hsl(240 5.9% 10%); + --secondary: hsl(240 3.7% 15.9%); + --secondary-foreground: hsl(0 0% 98%); + --muted: hsl(240 3.7% 15.9%); + --muted-foreground: hsl(240 5% 64.9%); + --accent: hsl(240 3.7% 15.9%); + --accent-foreground: hsl(0 0% 98%); + --destructive: hsl(0 62.8% 30.6%); + --destructive-foreground: hsl(0 0% 98%); + --border: hsl(240 3.7% 15.9%); + --input: hsl(240 3.7% 15.9%); + --ring: hsl(240 4.9% 83.9%); +} + +/* ── Map CSS vars → Tailwind v4 design tokens ────────────────────────────── */ + +@theme inline { + --color-background: var(--background); + --color-foreground: var(--foreground); + --color-card: var(--card); + --color-card-foreground: var(--card-foreground); + --color-popover: var(--popover); + --color-popover-foreground: var(--popover-foreground); + --color-primary: var(--primary); + --color-primary-foreground: var(--primary-foreground); + --color-secondary: var(--secondary); + --color-secondary-foreground: var(--secondary-foreground); + --color-muted: var(--muted); + --color-muted-foreground: var(--muted-foreground); + --color-accent: var(--accent); + --color-accent-foreground: var(--accent-foreground); + --color-destructive: var(--destructive); + --color-destructive-foreground: var(--destructive-foreground); + --color-border: var(--border); + --color-input: var(--input); + --color-ring: var(--ring); + + --radius-sm: calc(var(--radius) - 4px); + --radius-md: calc(var(--radius) - 2px); + --radius-lg: var(--radius); + --radius-xl: calc(var(--radius) + 4px); +} + +/* ── Base resets ─────────────────────────────────────────────────────────── */ + +* { + border-color: var(--border); +} +body { + background-color: var(--background); + color: var(--foreground); } diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts new file mode 100644 index 0000000..16e259b --- /dev/null +++ b/frontend/src/lib/api.ts @@ -0,0 +1,192 @@ +import { PUBLIC_API_BASE } from '$env/static/public'; +import type { + LabResult, + MedicationEntry, + MedicationUsage, + Product, + ProductInventory, + Routine, + RoutineStep, + SkinConditionSnapshot +} from './types'; + +// ─── Core fetch helpers ────────────────────────────────────────────────────── + +async function request(path: string, init: RequestInit = {}): Promise { + const url = `${PUBLIC_API_BASE}${path}`; + const res = await fetch(url, { + headers: { 'Content-Type': 'application/json', ...init.headers }, + ...init + }); + if (!res.ok) { + const detail = await res.json().catch(() => ({ detail: res.statusText })); + throw new Error(detail?.detail ?? res.statusText); + } + if (res.status === 204) return undefined as T; + return res.json(); +} + +export const api = { + get: (path: string) => request(path), + post: (path: string, body: unknown) => + request(path, { method: 'POST', body: JSON.stringify(body) }), + patch: (path: string, body: unknown) => + request(path, { method: 'PATCH', body: JSON.stringify(body) }), + del: (path: string) => request(path, { method: 'DELETE' }) +}; + +// ─── Products ──────────────────────────────────────────────────────────────── + +export interface ProductListParams { + category?: string; + brand?: string; + targets?: string[]; + is_medication?: boolean; + is_tool?: boolean; +} + +export function getProducts(params: ProductListParams = {}): Promise { + const q = new URLSearchParams(); + if (params.category) q.set('category', params.category); + if (params.brand) q.set('brand', params.brand); + if (params.targets) params.targets.forEach((t) => q.append('targets', t)); + if (params.is_medication != null) q.set('is_medication', String(params.is_medication)); + if (params.is_tool != null) q.set('is_tool', String(params.is_tool)); + const qs = q.toString(); + return api.get(`/products${qs ? `?${qs}` : ''}`); +} + +export const getProduct = (id: string): Promise => api.get(`/products/${id}`); +export const createProduct = (body: Record): Promise => + api.post('/products', body); +export const updateProduct = (id: string, body: Record): Promise => + api.patch(`/products/${id}`, body); +export const deleteProduct = (id: string): Promise => api.del(`/products/${id}`); + +export const getInventory = (productId: string): Promise => + api.get(`/products/${productId}/inventory`); +export const createInventory = ( + productId: string, + body: Record +): Promise => api.post(`/products/${productId}/inventory`, body); + +// ─── Routines ──────────────────────────────────────────────────────────────── + +export interface RoutineListParams { + from_date?: string; + to_date?: string; + part_of_day?: string; +} + +export function getRoutines(params: RoutineListParams = {}): Promise { + const q = new URLSearchParams(); + if (params.from_date) q.set('from_date', params.from_date); + if (params.to_date) q.set('to_date', params.to_date); + if (params.part_of_day) q.set('part_of_day', params.part_of_day); + const qs = q.toString(); + return api.get(`/routines${qs ? `?${qs}` : ''}`); +} + +export const getRoutine = (id: string): Promise => api.get(`/routines/${id}`); +export const createRoutine = (body: Record): Promise => + api.post('/routines', body); +export const updateRoutine = (id: string, body: Record): Promise => + api.patch(`/routines/${id}`, body); +export const deleteRoutine = (id: string): Promise => api.del(`/routines/${id}`); + +export const addRoutineStep = (routineId: string, body: Record): Promise => + api.post(`/routines/${routineId}/steps`, body); +export const updateRoutineStep = (stepId: string, body: Record): Promise => + api.patch(`/routines/steps/${stepId}`, body); +export const deleteRoutineStep = (stepId: string): Promise => + api.del(`/routines/steps/${stepId}`); + +// ─── Health – Medications ──────────────────────────────────────────────────── + +export interface MedicationListParams { + kind?: string; + product_name?: string; +} + +export function getMedications(params: MedicationListParams = {}): Promise { + const q = new URLSearchParams(); + if (params.kind) q.set('kind', params.kind); + if (params.product_name) q.set('product_name', params.product_name); + const qs = q.toString(); + return api.get(`/health/medications${qs ? `?${qs}` : ''}`); +} + +export const getMedication = (id: string): Promise => + api.get(`/health/medications/${id}`); +export const createMedication = (body: Record): Promise => + api.post('/health/medications', body); +export const updateMedication = ( + id: string, + body: Record +): Promise => api.patch(`/health/medications/${id}`, body); +export const deleteMedication = (id: string): Promise => + api.del(`/health/medications/${id}`); + +export const getMedicationUsages = (medicationId: string): Promise => + api.get(`/health/medications/${medicationId}/usages`); +export const createMedicationUsage = ( + medicationId: string, + body: Record +): Promise => api.post(`/health/medications/${medicationId}/usages`, body); + +// ─── Health – Lab results ──────────────────────────────────────────────────── + +export interface LabResultListParams { + test_code?: string; + flag?: string; + lab?: string; + from_date?: string; + to_date?: string; +} + +export function getLabResults(params: LabResultListParams = {}): Promise { + const q = new URLSearchParams(); + if (params.test_code) q.set('test_code', params.test_code); + if (params.flag) q.set('flag', params.flag); + if (params.lab) q.set('lab', params.lab); + if (params.from_date) q.set('from_date', params.from_date); + if (params.to_date) q.set('to_date', params.to_date); + const qs = q.toString(); + return api.get(`/health/lab-results${qs ? `?${qs}` : ''}`); +} + +export const getLabResult = (id: string): Promise => + api.get(`/health/lab-results/${id}`); +export const createLabResult = (body: Record): Promise => + api.post('/health/lab-results', body); +export const updateLabResult = (id: string, body: Record): Promise => + api.patch(`/health/lab-results/${id}`, body); +export const deleteLabResult = (id: string): Promise => + api.del(`/health/lab-results/${id}`); + +// ─── Skin ──────────────────────────────────────────────────────────────────── + +export interface SnapshotListParams { + from_date?: string; + to_date?: string; + overall_state?: string; +} + +export function getSkinSnapshots(params: SnapshotListParams = {}): Promise { + const q = new URLSearchParams(); + if (params.from_date) q.set('from_date', params.from_date); + if (params.to_date) q.set('to_date', params.to_date); + if (params.overall_state) q.set('overall_state', params.overall_state); + const qs = q.toString(); + return api.get(`/skincare${qs ? `?${qs}` : ''}`); +} + +export const getSkinSnapshot = (id: string): Promise => + api.get(`/skincare/${id}`); +export const createSkinSnapshot = (body: Record): Promise => + api.post('/skincare', body); +export const updateSkinSnapshot = ( + id: string, + body: Record +): Promise => api.patch(`/skincare/${id}`, body); +export const deleteSkinSnapshot = (id: string): Promise => api.del(`/skincare/${id}`); diff --git a/frontend/src/lib/components/ui/badge/badge.svelte b/frontend/src/lib/components/ui/badge/badge.svelte new file mode 100644 index 0000000..e3164ba --- /dev/null +++ b/frontend/src/lib/components/ui/badge/badge.svelte @@ -0,0 +1,50 @@ + + + + + + {@render children?.()} + diff --git a/frontend/src/lib/components/ui/badge/index.ts b/frontend/src/lib/components/ui/badge/index.ts new file mode 100644 index 0000000..64e0aa9 --- /dev/null +++ b/frontend/src/lib/components/ui/badge/index.ts @@ -0,0 +1,2 @@ +export { default as Badge } from "./badge.svelte"; +export { badgeVariants, type BadgeVariant } from "./badge.svelte"; diff --git a/frontend/src/lib/components/ui/button/button.svelte b/frontend/src/lib/components/ui/button/button.svelte new file mode 100644 index 0000000..a8296ae --- /dev/null +++ b/frontend/src/lib/components/ui/button/button.svelte @@ -0,0 +1,82 @@ + + + + +{#if href} + + {@render children?.()} + +{:else} + +{/if} diff --git a/frontend/src/lib/components/ui/button/index.ts b/frontend/src/lib/components/ui/button/index.ts new file mode 100644 index 0000000..fb585d7 --- /dev/null +++ b/frontend/src/lib/components/ui/button/index.ts @@ -0,0 +1,17 @@ +import Root, { + type ButtonProps, + type ButtonSize, + type ButtonVariant, + buttonVariants, +} from "./button.svelte"; + +export { + Root, + type ButtonProps as Props, + // + Root as Button, + buttonVariants, + type ButtonProps, + type ButtonSize, + type ButtonVariant, +}; diff --git a/frontend/src/lib/components/ui/card/card-action.svelte b/frontend/src/lib/components/ui/card/card-action.svelte new file mode 100644 index 0000000..cc36c56 --- /dev/null +++ b/frontend/src/lib/components/ui/card/card-action.svelte @@ -0,0 +1,20 @@ + + +
+ {@render children?.()} +
diff --git a/frontend/src/lib/components/ui/card/card-content.svelte b/frontend/src/lib/components/ui/card/card-content.svelte new file mode 100644 index 0000000..bc90b83 --- /dev/null +++ b/frontend/src/lib/components/ui/card/card-content.svelte @@ -0,0 +1,15 @@ + + +
+ {@render children?.()} +
diff --git a/frontend/src/lib/components/ui/card/card-description.svelte b/frontend/src/lib/components/ui/card/card-description.svelte new file mode 100644 index 0000000..9b20ac7 --- /dev/null +++ b/frontend/src/lib/components/ui/card/card-description.svelte @@ -0,0 +1,20 @@ + + +

+ {@render children?.()} +

diff --git a/frontend/src/lib/components/ui/card/card-footer.svelte b/frontend/src/lib/components/ui/card/card-footer.svelte new file mode 100644 index 0000000..2d4d0f2 --- /dev/null +++ b/frontend/src/lib/components/ui/card/card-footer.svelte @@ -0,0 +1,20 @@ + + +
+ {@render children?.()} +
diff --git a/frontend/src/lib/components/ui/card/card-header.svelte b/frontend/src/lib/components/ui/card/card-header.svelte new file mode 100644 index 0000000..2501788 --- /dev/null +++ b/frontend/src/lib/components/ui/card/card-header.svelte @@ -0,0 +1,23 @@ + + +
+ {@render children?.()} +
diff --git a/frontend/src/lib/components/ui/card/card-title.svelte b/frontend/src/lib/components/ui/card/card-title.svelte new file mode 100644 index 0000000..7447231 --- /dev/null +++ b/frontend/src/lib/components/ui/card/card-title.svelte @@ -0,0 +1,20 @@ + + +
+ {@render children?.()} +
diff --git a/frontend/src/lib/components/ui/card/card.svelte b/frontend/src/lib/components/ui/card/card.svelte new file mode 100644 index 0000000..99448cc --- /dev/null +++ b/frontend/src/lib/components/ui/card/card.svelte @@ -0,0 +1,23 @@ + + +
+ {@render children?.()} +
diff --git a/frontend/src/lib/components/ui/card/index.ts b/frontend/src/lib/components/ui/card/index.ts new file mode 100644 index 0000000..4d3fce4 --- /dev/null +++ b/frontend/src/lib/components/ui/card/index.ts @@ -0,0 +1,25 @@ +import Root from "./card.svelte"; +import Content from "./card-content.svelte"; +import Description from "./card-description.svelte"; +import Footer from "./card-footer.svelte"; +import Header from "./card-header.svelte"; +import Title from "./card-title.svelte"; +import Action from "./card-action.svelte"; + +export { + Root, + Content, + Description, + Footer, + Header, + Title, + Action, + // + Root as Card, + Content as CardContent, + Description as CardDescription, + Footer as CardFooter, + Header as CardHeader, + Title as CardTitle, + Action as CardAction, +}; diff --git a/frontend/src/lib/components/ui/input/index.ts b/frontend/src/lib/components/ui/input/index.ts new file mode 100644 index 0000000..f47b6d3 --- /dev/null +++ b/frontend/src/lib/components/ui/input/index.ts @@ -0,0 +1,7 @@ +import Root from "./input.svelte"; + +export { + Root, + // + Root as Input, +}; diff --git a/frontend/src/lib/components/ui/input/input.svelte b/frontend/src/lib/components/ui/input/input.svelte new file mode 100644 index 0000000..ff1a4c8 --- /dev/null +++ b/frontend/src/lib/components/ui/input/input.svelte @@ -0,0 +1,52 @@ + + +{#if type === "file"} + +{:else} + +{/if} diff --git a/frontend/src/lib/components/ui/label/index.ts b/frontend/src/lib/components/ui/label/index.ts new file mode 100644 index 0000000..8bfca0b --- /dev/null +++ b/frontend/src/lib/components/ui/label/index.ts @@ -0,0 +1,7 @@ +import Root from "./label.svelte"; + +export { + Root, + // + Root as Label, +}; diff --git a/frontend/src/lib/components/ui/label/label.svelte b/frontend/src/lib/components/ui/label/label.svelte new file mode 100644 index 0000000..d71afbc --- /dev/null +++ b/frontend/src/lib/components/ui/label/label.svelte @@ -0,0 +1,20 @@ + + + diff --git a/frontend/src/lib/components/ui/select/index.ts b/frontend/src/lib/components/ui/select/index.ts new file mode 100644 index 0000000..4dec358 --- /dev/null +++ b/frontend/src/lib/components/ui/select/index.ts @@ -0,0 +1,37 @@ +import Root from "./select.svelte"; +import Group from "./select-group.svelte"; +import Label from "./select-label.svelte"; +import Item from "./select-item.svelte"; +import Content from "./select-content.svelte"; +import Trigger from "./select-trigger.svelte"; +import Separator from "./select-separator.svelte"; +import ScrollDownButton from "./select-scroll-down-button.svelte"; +import ScrollUpButton from "./select-scroll-up-button.svelte"; +import GroupHeading from "./select-group-heading.svelte"; +import Portal from "./select-portal.svelte"; + +export { + Root, + Group, + Label, + Item, + Content, + Trigger, + Separator, + ScrollDownButton, + ScrollUpButton, + GroupHeading, + Portal, + // + Root as Select, + Group as SelectGroup, + Label as SelectLabel, + Item as SelectItem, + Content as SelectContent, + Trigger as SelectTrigger, + Separator as SelectSeparator, + ScrollDownButton as SelectScrollDownButton, + ScrollUpButton as SelectScrollUpButton, + GroupHeading as SelectGroupHeading, + Portal as SelectPortal, +}; diff --git a/frontend/src/lib/components/ui/select/select-content.svelte b/frontend/src/lib/components/ui/select/select-content.svelte new file mode 100644 index 0000000..4b9ca43 --- /dev/null +++ b/frontend/src/lib/components/ui/select/select-content.svelte @@ -0,0 +1,45 @@ + + + + + + + {@render children?.()} + + + + diff --git a/frontend/src/lib/components/ui/select/select-group-heading.svelte b/frontend/src/lib/components/ui/select/select-group-heading.svelte new file mode 100644 index 0000000..1fab5f0 --- /dev/null +++ b/frontend/src/lib/components/ui/select/select-group-heading.svelte @@ -0,0 +1,21 @@ + + + + {@render children?.()} + diff --git a/frontend/src/lib/components/ui/select/select-group.svelte b/frontend/src/lib/components/ui/select/select-group.svelte new file mode 100644 index 0000000..a1f43bf --- /dev/null +++ b/frontend/src/lib/components/ui/select/select-group.svelte @@ -0,0 +1,7 @@ + + + diff --git a/frontend/src/lib/components/ui/select/select-item.svelte b/frontend/src/lib/components/ui/select/select-item.svelte new file mode 100644 index 0000000..b85eef6 --- /dev/null +++ b/frontend/src/lib/components/ui/select/select-item.svelte @@ -0,0 +1,38 @@ + + + + {#snippet children({ selected, highlighted })} + + {#if selected} + + {/if} + + {#if childrenProp} + {@render childrenProp({ selected, highlighted })} + {:else} + {label || value} + {/if} + {/snippet} + diff --git a/frontend/src/lib/components/ui/select/select-label.svelte b/frontend/src/lib/components/ui/select/select-label.svelte new file mode 100644 index 0000000..4696025 --- /dev/null +++ b/frontend/src/lib/components/ui/select/select-label.svelte @@ -0,0 +1,20 @@ + + +
+ {@render children?.()} +
diff --git a/frontend/src/lib/components/ui/select/select-portal.svelte b/frontend/src/lib/components/ui/select/select-portal.svelte new file mode 100644 index 0000000..424bcdd --- /dev/null +++ b/frontend/src/lib/components/ui/select/select-portal.svelte @@ -0,0 +1,7 @@ + + + diff --git a/frontend/src/lib/components/ui/select/select-scroll-down-button.svelte b/frontend/src/lib/components/ui/select/select-scroll-down-button.svelte new file mode 100644 index 0000000..3629205 --- /dev/null +++ b/frontend/src/lib/components/ui/select/select-scroll-down-button.svelte @@ -0,0 +1,20 @@ + + + + + diff --git a/frontend/src/lib/components/ui/select/select-scroll-up-button.svelte b/frontend/src/lib/components/ui/select/select-scroll-up-button.svelte new file mode 100644 index 0000000..1aa2300 --- /dev/null +++ b/frontend/src/lib/components/ui/select/select-scroll-up-button.svelte @@ -0,0 +1,20 @@ + + + + + diff --git a/frontend/src/lib/components/ui/select/select-separator.svelte b/frontend/src/lib/components/ui/select/select-separator.svelte new file mode 100644 index 0000000..0eac3eb --- /dev/null +++ b/frontend/src/lib/components/ui/select/select-separator.svelte @@ -0,0 +1,18 @@ + + + diff --git a/frontend/src/lib/components/ui/select/select-trigger.svelte b/frontend/src/lib/components/ui/select/select-trigger.svelte new file mode 100644 index 0000000..dbb81df --- /dev/null +++ b/frontend/src/lib/components/ui/select/select-trigger.svelte @@ -0,0 +1,29 @@ + + + + {@render children?.()} + + diff --git a/frontend/src/lib/components/ui/select/select.svelte b/frontend/src/lib/components/ui/select/select.svelte new file mode 100644 index 0000000..05eb663 --- /dev/null +++ b/frontend/src/lib/components/ui/select/select.svelte @@ -0,0 +1,11 @@ + + + diff --git a/frontend/src/lib/components/ui/separator/index.ts b/frontend/src/lib/components/ui/separator/index.ts new file mode 100644 index 0000000..82442d2 --- /dev/null +++ b/frontend/src/lib/components/ui/separator/index.ts @@ -0,0 +1,7 @@ +import Root from "./separator.svelte"; + +export { + Root, + // + Root as Separator, +}; diff --git a/frontend/src/lib/components/ui/separator/separator.svelte b/frontend/src/lib/components/ui/separator/separator.svelte new file mode 100644 index 0000000..f40999f --- /dev/null +++ b/frontend/src/lib/components/ui/separator/separator.svelte @@ -0,0 +1,21 @@ + + + diff --git a/frontend/src/lib/components/ui/table/index.ts b/frontend/src/lib/components/ui/table/index.ts new file mode 100644 index 0000000..14695c8 --- /dev/null +++ b/frontend/src/lib/components/ui/table/index.ts @@ -0,0 +1,28 @@ +import Root from "./table.svelte"; +import Body from "./table-body.svelte"; +import Caption from "./table-caption.svelte"; +import Cell from "./table-cell.svelte"; +import Footer from "./table-footer.svelte"; +import Head from "./table-head.svelte"; +import Header from "./table-header.svelte"; +import Row from "./table-row.svelte"; + +export { + Root, + Body, + Caption, + Cell, + Footer, + Head, + Header, + Row, + // + Root as Table, + Body as TableBody, + Caption as TableCaption, + Cell as TableCell, + Footer as TableFooter, + Head as TableHead, + Header as TableHeader, + Row as TableRow, +}; diff --git a/frontend/src/lib/components/ui/table/table-body.svelte b/frontend/src/lib/components/ui/table/table-body.svelte new file mode 100644 index 0000000..29e9687 --- /dev/null +++ b/frontend/src/lib/components/ui/table/table-body.svelte @@ -0,0 +1,20 @@ + + + + {@render children?.()} + diff --git a/frontend/src/lib/components/ui/table/table-caption.svelte b/frontend/src/lib/components/ui/table/table-caption.svelte new file mode 100644 index 0000000..4696cff --- /dev/null +++ b/frontend/src/lib/components/ui/table/table-caption.svelte @@ -0,0 +1,20 @@ + + + + {@render children?.()} + diff --git a/frontend/src/lib/components/ui/table/table-cell.svelte b/frontend/src/lib/components/ui/table/table-cell.svelte new file mode 100644 index 0000000..2c0c26a --- /dev/null +++ b/frontend/src/lib/components/ui/table/table-cell.svelte @@ -0,0 +1,23 @@ + + + + {@render children?.()} + diff --git a/frontend/src/lib/components/ui/table/table-footer.svelte b/frontend/src/lib/components/ui/table/table-footer.svelte new file mode 100644 index 0000000..b9b14eb --- /dev/null +++ b/frontend/src/lib/components/ui/table/table-footer.svelte @@ -0,0 +1,20 @@ + + +tr]:last:border-b-0", className)} + {...restProps} +> + {@render children?.()} + diff --git a/frontend/src/lib/components/ui/table/table-head.svelte b/frontend/src/lib/components/ui/table/table-head.svelte new file mode 100644 index 0000000..b67a6f9 --- /dev/null +++ b/frontend/src/lib/components/ui/table/table-head.svelte @@ -0,0 +1,23 @@ + + + + {@render children?.()} + diff --git a/frontend/src/lib/components/ui/table/table-header.svelte b/frontend/src/lib/components/ui/table/table-header.svelte new file mode 100644 index 0000000..f47d259 --- /dev/null +++ b/frontend/src/lib/components/ui/table/table-header.svelte @@ -0,0 +1,20 @@ + + + + {@render children?.()} + diff --git a/frontend/src/lib/components/ui/table/table-row.svelte b/frontend/src/lib/components/ui/table/table-row.svelte new file mode 100644 index 0000000..0df769e --- /dev/null +++ b/frontend/src/lib/components/ui/table/table-row.svelte @@ -0,0 +1,23 @@ + + +svelte-css-wrapper]:[&>th,td]:bg-muted/50 data-[state=selected]:bg-muted border-b transition-colors", + className + )} + {...restProps} +> + {@render children?.()} + diff --git a/frontend/src/lib/components/ui/table/table.svelte b/frontend/src/lib/components/ui/table/table.svelte new file mode 100644 index 0000000..a334956 --- /dev/null +++ b/frontend/src/lib/components/ui/table/table.svelte @@ -0,0 +1,22 @@ + + +
+ + {@render children?.()} +
+
diff --git a/frontend/src/lib/components/ui/tabs/index.ts b/frontend/src/lib/components/ui/tabs/index.ts new file mode 100644 index 0000000..12d4327 --- /dev/null +++ b/frontend/src/lib/components/ui/tabs/index.ts @@ -0,0 +1,16 @@ +import Root from "./tabs.svelte"; +import Content from "./tabs-content.svelte"; +import List from "./tabs-list.svelte"; +import Trigger from "./tabs-trigger.svelte"; + +export { + Root, + Content, + List, + Trigger, + // + Root as Tabs, + Content as TabsContent, + List as TabsList, + Trigger as TabsTrigger, +}; diff --git a/frontend/src/lib/components/ui/tabs/tabs-content.svelte b/frontend/src/lib/components/ui/tabs/tabs-content.svelte new file mode 100644 index 0000000..340d65c --- /dev/null +++ b/frontend/src/lib/components/ui/tabs/tabs-content.svelte @@ -0,0 +1,17 @@ + + + diff --git a/frontend/src/lib/components/ui/tabs/tabs-list.svelte b/frontend/src/lib/components/ui/tabs/tabs-list.svelte new file mode 100644 index 0000000..08932b6 --- /dev/null +++ b/frontend/src/lib/components/ui/tabs/tabs-list.svelte @@ -0,0 +1,20 @@ + + + diff --git a/frontend/src/lib/components/ui/tabs/tabs-trigger.svelte b/frontend/src/lib/components/ui/tabs/tabs-trigger.svelte new file mode 100644 index 0000000..e623b36 --- /dev/null +++ b/frontend/src/lib/components/ui/tabs/tabs-trigger.svelte @@ -0,0 +1,20 @@ + + + diff --git a/frontend/src/lib/components/ui/tabs/tabs.svelte b/frontend/src/lib/components/ui/tabs/tabs.svelte new file mode 100644 index 0000000..ef6cada --- /dev/null +++ b/frontend/src/lib/components/ui/tabs/tabs.svelte @@ -0,0 +1,19 @@ + + + diff --git a/frontend/src/lib/types.ts b/frontend/src/lib/types.ts new file mode 100644 index 0000000..d225660 --- /dev/null +++ b/frontend/src/lib/types.ts @@ -0,0 +1,286 @@ +// ─── Enums ────────────────────────────────────────────────────────────────── + +export type AbsorptionSpeed = 'very_fast' | 'fast' | 'moderate' | 'slow' | 'very_slow'; +export type BarrierState = 'intact' | 'mildly_compromised' | 'compromised'; +export type DayTime = 'am' | 'pm' | 'both'; +export type EvidenceLevel = 'low' | 'mixed' | 'moderate' | 'high'; +export type GroomingAction = 'shaving_razor' | 'shaving_oneblade' | 'dermarolling'; +export type IngredientFunction = + | 'humectant' + | 'emollient' + | 'occlusive' + | 'exfoliant_aha' + | 'exfoliant_bha' + | 'exfoliant_pha' + | 'retinoid' + | 'antioxidant' + | 'soothing' + | 'barrier_support' + | 'brightening' + | 'anti_acne' + | 'ceramide' + | 'niacinamide' + | 'sunscreen' + | 'peptide' + | 'hair_growth_stimulant' + | 'prebiotic' + | 'vitamin_c'; +export type InteractionScope = 'same_step' | 'same_day' | 'same_period'; +export type MedicationKind = 'prescription' | 'otc' | 'supplement' | 'herbal' | 'other'; +export type OverallSkinState = 'excellent' | 'good' | 'fair' | 'poor'; +export type PartOfDay = 'am' | 'pm'; +export type PriceTier = 'budget' | 'mid' | 'premium' | 'luxury'; +export type ProductCategory = + | 'cleanser' + | 'toner' + | 'essence' + | 'serum' + | 'moisturizer' + | 'spf' + | 'mask' + | 'exfoliant' + | 'hair_treatment' + | 'tool' + | 'spot_treatment' + | 'oil'; +export type ResultFlag = 'N' | 'ABN' | 'POS' | 'NEG' | 'L' | 'H'; +export type RoutineRole = + | 'cleanse' + | 'prepare' + | 'treatment_active' + | 'treatment_support' + | 'seal' + | 'protect' + | 'hair_treatment'; +export type SkinConcern = + | 'acne' + | 'rosacea' + | 'hyperpigmentation' + | 'aging' + | 'dehydration' + | 'redness' + | 'damaged_barrier' + | 'pore_visibility' + | 'uneven_texture' + | 'hair_growth' + | 'sebum_excess'; +export type SkinTrend = 'improving' | 'stable' | 'worsening' | 'fluctuating'; +export type SkinType = 'dry' | 'oily' | 'combination' | 'sensitive' | 'normal' | 'acne_prone'; +export type StrengthLevel = 1 | 2 | 3; +export type TextureType = 'watery' | 'gel' | 'emulsion' | 'cream' | 'oil' | 'balm' | 'foam' | 'fluid'; +export type UsageFrequency = + | 'daily' + | 'twice_daily' + | 'every_other_day' + | 'twice_weekly' + | 'three_times_weekly' + | 'weekly' + | 'as_needed'; + +// ─── Product types ─────────────────────────────────────────────────────────── + +export interface ActiveIngredient { + name: string; + percent?: number; + functions: IngredientFunction[]; + strength_level?: StrengthLevel; + irritation_potential?: StrengthLevel; + cumulative_with?: IngredientFunction[]; +} + +export interface ProductEffectProfile { + hydration_immediate: number; + hydration_long_term: number; + barrier_repair_strength: number; + soothing_strength: number; + exfoliation_strength: number; + retinoid_strength: number; + irritation_risk: number; + comedogenic_risk: number; + barrier_disruption_risk: number; + dryness_risk: number; + brightening_strength: number; + anti_acne_strength: number; + anti_aging_strength: number; +} + +export interface ProductInteraction { + target: string; + scope: InteractionScope; + reason?: string; +} + +export interface ProductContext { + safe_after_shaving?: boolean; + safe_after_acids?: boolean; + safe_after_retinoids?: boolean; + safe_with_compromised_barrier?: boolean; + low_uv_only?: boolean; +} + +export interface ProductInventory { + id: string; + product_id: string; + is_opened: boolean; + opened_at?: string; + finished_at?: string; + expiry_date?: string; + current_weight_g?: number; + notes?: string; + created_at: string; + product?: Product; +} + +export interface Product { + id: string; + name: string; + brand: string; + line_name?: string; + sku?: string; + url?: string; + barcode?: string; + category: ProductCategory; + routine_role: RoutineRole; + recommended_time: DayTime; + texture?: TextureType; + absorption_speed?: AbsorptionSpeed; + leave_on: boolean; + price_tier?: PriceTier; + size_ml?: number; + pao_months?: number; + inci: string[]; + actives?: ActiveIngredient[]; + recommended_for: SkinType[]; + recommended_frequency?: UsageFrequency; + targets: SkinConcern[]; + contraindications: string[]; + usage_notes?: string; + evidence_level?: EvidenceLevel; + claims: string[]; + fragrance_free?: boolean; + essential_oils_free?: boolean; + alcohol_denat_free?: boolean; + pregnancy_safe?: boolean; + product_effect_profile: ProductEffectProfile; + ph_min?: number; + ph_max?: number; + incompatible_with?: ProductInteraction[]; + synergizes_with?: string[]; + context_rules?: ProductContext; + min_interval_hours?: number; + max_frequency_per_week?: number; + is_medication: boolean; + is_tool: boolean; + needle_length_mm?: number; + personal_rating?: number; + personal_tolerance_notes?: string; + personal_repurchase_intent?: boolean; + created_at: string; + updated_at: string; + inventory: ProductInventory[]; +} + +// ─── Routine types ─────────────────────────────────────────────────────────── + +export interface RoutineStep { + id: string; + routine_id: string; + product_id?: string; + order_index: number; + action_type?: GroomingAction; + action_notes?: string; + dose?: string; + region?: string; + product?: Product; +} + +export interface Routine { + id: string; + routine_date: string; + part_of_day: PartOfDay; + notes?: string; + created_at: string; + updated_at: string; + steps: RoutineStep[]; +} + +export interface GroomingSchedule { + id: string; + day_of_week: number; + action: GroomingAction; + notes?: string; +} + +// ─── Health types ──────────────────────────────────────────────────────────── + +export interface MedicationUsage { + record_id: string; + medication_record_id: string; + dose_value?: number; + dose_unit?: string; + frequency?: string; + schedule_text?: string; + as_needed: boolean; + valid_from: string; + valid_to?: string; + source_file?: string; + notes?: string; + created_at: string; + updated_at: string; +} + +export interface MedicationEntry { + record_id: string; + kind: MedicationKind; + product_name: string; + active_substance?: string; + formulation?: string; + route?: string; + source_file?: string; + notes?: string; + created_at: string; + updated_at: string; + usage_history: MedicationUsage[]; +} + +export interface LabResult { + record_id: string; + collected_at: string; + test_code: string; + test_name_original?: string; + test_name_loinc?: string; + value_num?: number; + value_text?: string; + value_bool?: boolean; + unit_original?: string; + unit_ucum?: string; + ref_low?: number; + ref_high?: number; + ref_text?: string; + flag?: ResultFlag; + lab?: string; + source_file?: string; + notes?: string; + created_at: string; + updated_at: string; +} + +// ─── Skin types ────────────────────────────────────────────────────────────── + +export interface SkinConditionSnapshot { + id: string; + snapshot_date: string; + overall_state?: OverallSkinState; + trend?: SkinTrend; + skin_type?: SkinType; + hydration_level?: number; + sebum_tzone?: number; + sebum_cheeks?: number; + sensitivity_level?: number; + barrier_state?: BarrierState; + active_concerns: SkinConcern[]; + risks: string[]; + priorities: string[]; + notes?: string; + created_at: string; +} diff --git a/frontend/src/routes/+layout.svelte b/frontend/src/routes/+layout.svelte index 9cebde5..aaeb524 100644 --- a/frontend/src/routes/+layout.svelte +++ b/frontend/src/routes/+layout.svelte @@ -1,11 +1,51 @@ - - - +
+ + -{@render children()} + +
+ {@render children()} +
+
diff --git a/frontend/src/routes/+page.server.ts b/frontend/src/routes/+page.server.ts new file mode 100644 index 0000000..5466205 --- /dev/null +++ b/frontend/src/routes/+page.server.ts @@ -0,0 +1,19 @@ +import { getRoutines, getSkinSnapshots } from '$lib/api'; +import type { PageServerLoad } from './$types'; + +export const load: PageServerLoad = async () => { + const [routines, snapshots] = await Promise.all([ + getRoutines({ from_date: recentDate(14) }), + getSkinSnapshots({ from_date: recentDate(60) }) + ]); + return { + recentRoutines: routines.slice(0, 10), + latestSnapshot: snapshots.at(-1) ?? null + }; +}; + +function recentDate(daysAgo: number): string { + const d = new Date(); + d.setDate(d.getDate() - daysAgo); + return d.toISOString().slice(0, 10); +} diff --git a/frontend/src/routes/+page.svelte b/frontend/src/routes/+page.svelte index cc88df0..0947393 100644 --- a/frontend/src/routes/+page.svelte +++ b/frontend/src/routes/+page.svelte @@ -1,2 +1,87 @@ -

Welcome to SvelteKit

-

Visit svelte.dev/docs/kit to read the documentation

+ + +Dashboard — innercontext + +
+
+

Dashboard

+

Your recent health & skincare overview

+
+ +
+ + + + Latest Skin Snapshot + + + {#if data.latestSnapshot} + {@const s = data.latestSnapshot} +
+
+ {s.snapshot_date} + {#if s.overall_state} + + {s.overall_state} + + {/if} +
+ {#if s.trend} +

Trend: {s.trend}

+ {/if} + {#if s.active_concerns.length} +
+ {#each s.active_concerns as concern} + {concern.replace(/_/g, ' ')} + {/each} +
+ {/if} + {#if s.notes} +

{s.notes}

+ {/if} +
+ {:else} +

No skin snapshots yet.

+ {/if} +
+
+ + + + + Recent Routines + + + {#if data.recentRoutines.length} + + {:else} +

No routines in the past 2 weeks.

+ {/if} +
+
+
+
diff --git a/frontend/src/routes/health/lab-results/+page.server.ts b/frontend/src/routes/health/lab-results/+page.server.ts new file mode 100644 index 0000000..167f773 --- /dev/null +++ b/frontend/src/routes/health/lab-results/+page.server.ts @@ -0,0 +1,44 @@ +import { createLabResult, getLabResults } from '$lib/api'; +import { fail } from '@sveltejs/kit'; +import type { Actions, PageServerLoad } from './$types'; + +export const load: PageServerLoad = async ({ url }) => { + const flag = url.searchParams.get('flag') ?? undefined; + const from_date = url.searchParams.get('from_date') ?? undefined; + const results = await getLabResults({ flag, from_date }); + return { results, flag }; +}; + +export const actions: Actions = { + create: async ({ request }) => { + const form = await request.formData(); + const collected_at = form.get('collected_at') as string; + const test_code = form.get('test_code') as string; + const test_name_original = form.get('test_name_original') as string; + const value_num = form.get('value_num') as string; + const unit_original = form.get('unit_original') as string; + const flag = form.get('flag') as string; + const lab = form.get('lab') as string; + + if (!collected_at || !test_code) { + return fail(400, { error: 'Date and test code are required' }); + } + + const body: Record = { + collected_at, + test_code + }; + if (test_name_original) body.test_name_original = test_name_original; + if (value_num) body.value_num = Number(value_num); + if (unit_original) body.unit_original = unit_original; + if (flag) body.flag = flag; + if (lab) body.lab = lab; + + try { + await createLabResult(body); + return { created: true }; + } catch (e) { + return fail(500, { error: (e as Error).message }); + } + } +}; diff --git a/frontend/src/routes/health/lab-results/+page.svelte b/frontend/src/routes/health/lab-results/+page.svelte new file mode 100644 index 0000000..17e77f5 --- /dev/null +++ b/frontend/src/routes/health/lab-results/+page.svelte @@ -0,0 +1,176 @@ + + +Lab Results — innercontext + +
+
+
+

Lab Results

+

{data.results.length} results

+
+ +
+ + {#if form?.error} +
{form.error}
+ {/if} + {#if form?.created} +
Result added.
+ {/if} + + +
+ Flag: + +
+ + {#if showForm} + + New lab result + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + + +
+
+ +
+
+
+
+ {/if} + +
+ + + + Date + Test + LOINC + Value + Flag + Lab + + + + {#each data.results as r} + + {r.collected_at.slice(0, 10)} + {r.test_name_original ?? r.test_code} + {r.test_code} + + {#if r.value_num != null} + {r.value_num} {r.unit_original ?? ''} + {:else if r.value_text} + {r.value_text} + {:else} + — + {/if} + + + {#if r.flag} + + {r.flag} + + {:else} + — + {/if} + + {r.lab ?? '—'} + + {:else} + + + No lab results found. + + + {/each} + +
+
+
diff --git a/frontend/src/routes/health/medications/+page.server.ts b/frontend/src/routes/health/medications/+page.server.ts new file mode 100644 index 0000000..04ba5e4 --- /dev/null +++ b/frontend/src/routes/health/medications/+page.server.ts @@ -0,0 +1,35 @@ +import { createMedication, getMedications } from '$lib/api'; +import { fail } from '@sveltejs/kit'; +import type { Actions, PageServerLoad } from './$types'; + +export const load: PageServerLoad = async ({ url }) => { + const kind = url.searchParams.get('kind') ?? undefined; + const medications = await getMedications({ kind }); + return { medications, kind }; +}; + +export const actions: Actions = { + create: async ({ request }) => { + const form = await request.formData(); + const kind = form.get('kind') as string; + const product_name = form.get('product_name') as string; + const active_substance = form.get('active_substance') as string; + const notes = form.get('notes') as string; + + if (!kind || !product_name) { + return fail(400, { error: 'Kind and product name are required' }); + } + + try { + await createMedication({ + kind, + product_name, + active_substance: active_substance || undefined, + notes: notes || undefined + }); + return { created: true }; + } catch (e) { + return fail(500, { error: (e as Error).message }); + } + } +}; diff --git a/frontend/src/routes/health/medications/+page.svelte b/frontend/src/routes/health/medications/+page.svelte new file mode 100644 index 0000000..412b237 --- /dev/null +++ b/frontend/src/routes/health/medications/+page.svelte @@ -0,0 +1,106 @@ + + +Medications — innercontext + +
+
+
+

Medications

+

{data.medications.length} entries

+
+ +
+ + {#if form?.error} +
{form.error}
+ {/if} + {#if form?.created} +
Medication added.
+ {/if} + + {#if showForm} + + New medication + +
+
+ + + +
+
+ + +
+
+ + +
+
+ + +
+
+ +
+
+
+
+ {/if} + +
+ {#each data.medications as med} +
+
+
+ + {med.kind} + + {med.product_name} + {#if med.active_substance} + {med.active_substance} + {/if} +
+ {med.usage_history.length} usages +
+ {#if med.notes} +

{med.notes}

+ {/if} +
+ {:else} +

No medications recorded.

+ {/each} +
+
diff --git a/frontend/src/routes/products/+page.server.ts b/frontend/src/routes/products/+page.server.ts new file mode 100644 index 0000000..6bb8800 --- /dev/null +++ b/frontend/src/routes/products/+page.server.ts @@ -0,0 +1,8 @@ +import { getProducts } from '$lib/api'; +import type { PageServerLoad } from './$types'; + +export const load: PageServerLoad = async ({ url }) => { + const category = url.searchParams.get('category') ?? undefined; + const products = await getProducts({ category }); + return { products, category }; +}; diff --git a/frontend/src/routes/products/+page.svelte b/frontend/src/routes/products/+page.svelte new file mode 100644 index 0000000..ed524c7 --- /dev/null +++ b/frontend/src/routes/products/+page.svelte @@ -0,0 +1,114 @@ + + +Products — innercontext + +
+
+
+

Products

+

{data.products.length} products

+
+ +
+ +
+ Filter by category: + +
+ +
+ + + + Name + Brand + Category + Targets + Time + Rating + + + + {#each data.products as product} + + + + {product.name} + + + {product.brand} + + {product.category.replace(/_/g, ' ')} + + +
+ {#each product.targets.slice(0, 3) as t} + {t.replace(/_/g, ' ')} + {/each} + {#if product.targets.length > 3} + +{product.targets.length - 3} + {/if} +
+
+ {product.recommended_time} + + {#if product.personal_rating} + {product.personal_rating}/10 + {:else} + + {/if} + +
+ {:else} + + + No products found. + + + {/each} +
+
+
+
diff --git a/frontend/src/routes/products/[id]/+page.server.ts b/frontend/src/routes/products/[id]/+page.server.ts new file mode 100644 index 0000000..f4bf922 --- /dev/null +++ b/frontend/src/routes/products/[id]/+page.server.ts @@ -0,0 +1,58 @@ +import { createInventory, deleteProduct, getProduct, updateProduct } from '$lib/api'; +import { error, fail, redirect } from '@sveltejs/kit'; +import type { Actions, PageServerLoad } from './$types'; + +export const load: PageServerLoad = async ({ params }) => { + try { + const product = await getProduct(params.id); + return { product }; + } catch { + error(404, 'Product not found'); + } +}; + +export const actions: Actions = { + update: async ({ params, request }) => { + const form = await request.formData(); + const body: Record = {}; + for (const [key, value] of form.entries()) { + if (value !== '') body[key] = value; + } + if ('leave_on' in body) body.leave_on = body.leave_on === 'true'; + if ('personal_rating' in body) body.personal_rating = Number(body.personal_rating); + try { + const product = await updateProduct(params.id, body); + return { success: true, product }; + } catch (e) { + return fail(500, { error: (e as Error).message }); + } + }, + + delete: async ({ params }) => { + try { + await deleteProduct(params.id); + } catch (e) { + return fail(500, { error: (e as Error).message }); + } + redirect(303, '/products'); + }, + + addInventory: async ({ params, request }) => { + const form = await request.formData(); + const body: Record = { + is_opened: form.get('is_opened') === 'true' + }; + const expiry = form.get('expiry_date'); + if (expiry) body.expiry_date = expiry; + const weight = form.get('current_weight_g'); + if (weight) body.current_weight_g = Number(weight); + const notes = form.get('notes'); + if (notes) body.notes = notes; + try { + await createInventory(params.id, body); + return { inventoryAdded: true }; + } catch (e) { + return fail(500, { error: (e as Error).message }); + } + } +}; diff --git a/frontend/src/routes/products/[id]/+page.svelte b/frontend/src/routes/products/[id]/+page.svelte new file mode 100644 index 0000000..099510a --- /dev/null +++ b/frontend/src/routes/products/[id]/+page.svelte @@ -0,0 +1,185 @@ + + +{product.name} — innercontext + +
+
+ ← Products +

{product.name}

+ {product.category.replace(/_/g, ' ')} +
+ + {#if form?.error} +
{form.error}
+ {/if} + {#if form?.success} +
Saved.
+ {/if} + + + + Details + +
+ Brand{product.brand} + Line{product.line_name ?? '—'} + Routine role{product.routine_role.replace(/_/g, ' ')} + Time{product.recommended_time} + Leave-on{product.leave_on ? 'Yes' : 'No'} + Texture{product.texture ?? '—'} + pH + + {#if product.ph_min != null && product.ph_max != null} + {product.ph_min}–{product.ph_max} + {:else} + — + {/if} + + Rating + {product.personal_rating != null ? `${product.personal_rating}/10` : '—'} +
+ {#if product.targets.length} +
+ Targets: + {#each product.targets as t} + {t.replace(/_/g, ' ')} + {/each} +
+ {/if} + {#if product.actives?.length} +
+

Actives:

+
    + {#each product.actives as a} +
  • {a.name}{a.percent != null ? ` ${a.percent}%` : ''}
  • + {/each} +
+
+ {/if} + {#if product.usage_notes} +

{product.usage_notes}

+ {/if} +
+
+ + + + Quick edit + +
+
+ + +
+
+ + +
+
+ +
+
+
+
+ + + + +
+
+

Inventory packages ({product.inventory.length})

+ +
+ + {#if form?.inventoryAdded} +
Package added.
+ {/if} + + {#if showInventoryForm} + + +
+
+ + +
+
+ + +
+
+ + +
+
+ +
+ +
+
+
+ {/if} + + {#if product.inventory.length} +
+ {#each product.inventory as pkg} +
+
+ + {pkg.is_opened ? 'Open' : 'Sealed'} + + {#if pkg.expiry_date} + Exp: {pkg.expiry_date} + {/if} + {#if pkg.current_weight_g} + {pkg.current_weight_g}g remaining + {/if} +
+ {#if pkg.notes} + {pkg.notes} + {/if} +
+ {/each} +
+ {:else} +

No inventory packages.

+ {/if} +
+ + + + +
+
{ if (!confirm('Delete this product?')) e.preventDefault(); }}> + +
+
+
diff --git a/frontend/src/routes/products/new/+page.server.ts b/frontend/src/routes/products/new/+page.server.ts new file mode 100644 index 0000000..6727025 --- /dev/null +++ b/frontend/src/routes/products/new/+page.server.ts @@ -0,0 +1,44 @@ +import { createProduct } from '$lib/api'; +import { fail, redirect } from '@sveltejs/kit'; +import type { Actions, PageServerLoad } from './$types'; + +export const load: PageServerLoad = async () => { + return {}; +}; + +export const actions: Actions = { + default: async ({ request }) => { + const form = await request.formData(); + + const name = form.get('name') as string; + const brand = form.get('brand') as string; + const category = form.get('category') as string; + const recommended_time = form.get('recommended_time') as string; + const routine_role = form.get('routine_role') as string; + const leave_on = form.get('leave_on') === 'true'; + + if (!name || !brand || !category || !recommended_time || !routine_role) { + return fail(400, { error: 'Required fields missing' }); + } + + const targets = (form.get('targets') as string) + ?.split(',') + .map((t) => t.trim()) + .filter(Boolean) ?? []; + + try { + const product = await createProduct({ + name, + brand, + category, + recommended_time, + routine_role, + leave_on, + targets + }); + redirect(303, `/products/${product.id}`); + } catch (e) { + return fail(500, { error: (e as Error).message }); + } + } +}; diff --git a/frontend/src/routes/products/new/+page.svelte b/frontend/src/routes/products/new/+page.svelte new file mode 100644 index 0000000..0eeb394 --- /dev/null +++ b/frontend/src/routes/products/new/+page.svelte @@ -0,0 +1,127 @@ + + +New Product — innercontext + +
+
+ ← Products +

New Product

+
+ + {#if form?.error} +
+ {form.error} +
+ {/if} + + + Product details + +
+
+ + +
+ +
+ + +
+ +
+ + + +
+ +
+ + + +
+ +
+ + + +
+ +
+ + + +
+ +
+ + +
+ +
+ + +
+
+
+
+
diff --git a/frontend/src/routes/routines/+page.server.ts b/frontend/src/routes/routines/+page.server.ts new file mode 100644 index 0000000..a8044d4 --- /dev/null +++ b/frontend/src/routes/routines/+page.server.ts @@ -0,0 +1,15 @@ +import { getRoutines } from '$lib/api'; +import type { PageServerLoad } from './$types'; + +export const load: PageServerLoad = async ({ url }) => { + const part_of_day = url.searchParams.get('part_of_day') ?? undefined; + const from_date = url.searchParams.get('from_date') ?? recentDate(30); + const routines = await getRoutines({ from_date, part_of_day }); + return { routines, part_of_day }; +}; + +function recentDate(daysAgo: number): string { + const d = new Date(); + d.setDate(d.getDate() - daysAgo); + return d.toISOString().slice(0, 10); +} diff --git a/frontend/src/routes/routines/+page.svelte b/frontend/src/routes/routines/+page.svelte new file mode 100644 index 0000000..ad71de5 --- /dev/null +++ b/frontend/src/routes/routines/+page.svelte @@ -0,0 +1,84 @@ + + +Routines — innercontext + +
+
+
+

Routines

+

{data.routines.length} routines (last 30 days)

+
+ +
+ +
+ + + +
+ + {#if sortedDates.length} +
+ {#each sortedDates as date} + + {/each} +
+ {:else} +

No routines found.

+ {/if} +
diff --git a/frontend/src/routes/routines/[id]/+page.server.ts b/frontend/src/routes/routines/[id]/+page.server.ts new file mode 100644 index 0000000..73dcbdf --- /dev/null +++ b/frontend/src/routes/routines/[id]/+page.server.ts @@ -0,0 +1,54 @@ +import { addRoutineStep, deleteRoutine, deleteRoutineStep, getProducts, getRoutine } from '$lib/api'; +import { error, fail, redirect } from '@sveltejs/kit'; +import type { Actions, PageServerLoad } from './$types'; + +export const load: PageServerLoad = async ({ params }) => { + try { + const [routine, products] = await Promise.all([getRoutine(params.id), getProducts()]); + return { routine, products }; + } catch { + error(404, 'Routine not found'); + } +}; + +export const actions: Actions = { + addStep: async ({ params, request }) => { + const form = await request.formData(); + const product_id = form.get('product_id') as string; + const order_index = Number(form.get('order_index') ?? 0); + const dose = form.get('dose') as string; + const region = form.get('region') as string; + + const body: Record = { order_index }; + if (product_id) body.product_id = product_id; + if (dose) body.dose = dose; + if (region) body.region = region; + + try { + await addRoutineStep(params.id, body); + return { stepAdded: true }; + } catch (e) { + return fail(500, { error: (e as Error).message }); + } + }, + + removeStep: async ({ request }) => { + const form = await request.formData(); + const step_id = form.get('step_id') as string; + try { + await deleteRoutineStep(step_id); + return { stepRemoved: true }; + } catch (e) { + return fail(500, { error: (e as Error).message }); + } + }, + + delete: async ({ params }) => { + try { + await deleteRoutine(params.id); + } catch (e) { + return fail(500, { error: (e as Error).message }); + } + redirect(303, '/routines'); + } +}; diff --git a/frontend/src/routes/routines/[id]/+page.svelte b/frontend/src/routes/routines/[id]/+page.svelte new file mode 100644 index 0000000..f1d5235 --- /dev/null +++ b/frontend/src/routes/routines/[id]/+page.svelte @@ -0,0 +1,131 @@ + + +Routine {routine.routine_date} {routine.part_of_day.toUpperCase()} — innercontext + +
+
+ ← Routines +

{routine.routine_date}

+ + {routine.part_of_day.toUpperCase()} + +
+ + {#if form?.error} +
{form.error}
+ {/if} + + {#if routine.notes} +

{routine.notes}

+ {/if} + + +
+
+

Steps ({routine.steps.length})

+ +
+ + {#if showStepForm} + + Add step + +
+
+ + + +
+ +
+
+ + +
+
+ + +
+
+ +
+
+
+ {/if} + + {#if routine.steps.length} +
+ {#each routine.steps.toSorted((a, b) => a.order_index - b.order_index) as step} +
+
+ {step.order_index + 1} +
+ {#if step.product} +

{step.product.name}

+

{step.product.brand}

+ {:else if step.action_type} +

{step.action_type.replace(/_/g, ' ')}

+ {:else} +

Unknown step

+ {/if} +
+ {#if step.dose} + {step.dose} + {/if} +
+
+ + +
+
+ {/each} +
+ {:else} +

No steps yet.

+ {/if} +
+ + + +
{ if (!confirm('Delete this routine?')) e.preventDefault(); }}> + +
+
diff --git a/frontend/src/routes/routines/new/+page.server.ts b/frontend/src/routes/routines/new/+page.server.ts new file mode 100644 index 0000000..b3c033d --- /dev/null +++ b/frontend/src/routes/routines/new/+page.server.ts @@ -0,0 +1,27 @@ +import { createRoutine } from '$lib/api'; +import { fail, redirect } from '@sveltejs/kit'; +import type { Actions, PageServerLoad } from './$types'; + +export const load: PageServerLoad = async () => { + return { today: new Date().toISOString().slice(0, 10) }; +}; + +export const actions: Actions = { + default: async ({ request }) => { + const form = await request.formData(); + const routine_date = form.get('routine_date') as string; + const part_of_day = form.get('part_of_day') as string; + const notes = form.get('notes') as string; + + if (!routine_date || !part_of_day) { + return fail(400, { error: 'Date and AM/PM are required' }); + } + + try { + const routine = await createRoutine({ routine_date, part_of_day, notes: notes || undefined }); + redirect(303, `/routines/${routine.id}`); + } catch (e) { + return fail(500, { error: (e as Error).message }); + } + } +}; diff --git a/frontend/src/routes/routines/new/+page.svelte b/frontend/src/routes/routines/new/+page.svelte new file mode 100644 index 0000000..9de88b0 --- /dev/null +++ b/frontend/src/routes/routines/new/+page.svelte @@ -0,0 +1,59 @@ + + +New Routine — innercontext + +
+
+ ← Routines +

New Routine

+
+ + {#if form?.error} +
{form.error}
+ {/if} + + + Routine details + +
+
+ + +
+ +
+ + + +
+ +
+ + +
+ +
+ + +
+
+
+
+
diff --git a/frontend/src/routes/skin/+page.server.ts b/frontend/src/routes/skin/+page.server.ts new file mode 100644 index 0000000..994559c --- /dev/null +++ b/frontend/src/routes/skin/+page.server.ts @@ -0,0 +1,47 @@ +import { createSkinSnapshot, getSkinSnapshots } from '$lib/api'; +import { fail } from '@sveltejs/kit'; +import type { Actions, PageServerLoad } from './$types'; + +export const load: PageServerLoad = async ({ url }) => { + const overall_state = url.searchParams.get('overall_state') ?? undefined; + const snapshots = await getSkinSnapshots({ overall_state }); + return { snapshots, overall_state }; +}; + +export const actions: Actions = { + create: async ({ request }) => { + const form = await request.formData(); + const snapshot_date = form.get('snapshot_date') as string; + const overall_state = form.get('overall_state') as string; + const trend = form.get('trend') as string; + const notes = form.get('notes') as string; + const hydration_level = form.get('hydration_level') as string; + const sensitivity_level = form.get('sensitivity_level') as string; + const barrier_state = form.get('barrier_state') as string; + const active_concerns_raw = form.get('active_concerns') as string; + + if (!snapshot_date) { + return fail(400, { error: 'Date is required' }); + } + + const active_concerns = active_concerns_raw + ?.split(',') + .map((c) => c.trim()) + .filter(Boolean) ?? []; + + const body: Record = { snapshot_date, active_concerns }; + if (overall_state) body.overall_state = overall_state; + if (trend) body.trend = trend; + if (notes) body.notes = notes; + if (hydration_level) body.hydration_level = Number(hydration_level); + if (sensitivity_level) body.sensitivity_level = Number(sensitivity_level); + if (barrier_state) body.barrier_state = barrier_state; + + try { + await createSkinSnapshot(body); + return { created: true }; + } catch (e) { + return fail(500, { error: (e as Error).message }); + } + } +}; diff --git a/frontend/src/routes/skin/+page.svelte b/frontend/src/routes/skin/+page.svelte new file mode 100644 index 0000000..be4cf74 --- /dev/null +++ b/frontend/src/routes/skin/+page.svelte @@ -0,0 +1,177 @@ + + +Skin — innercontext + +
+
+
+

Skin Snapshots

+

{data.snapshots.length} snapshots

+
+ +
+ + {#if form?.error} +
{form.error}
+ {/if} + {#if form?.created} +
Snapshot added.
+ {/if} + + {#if showForm} + + New skin snapshot + +
+
+ + +
+
+ + + +
+
+ + + +
+
+ + + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ +
+
+
+
+ {/if} + +
+ {#each sortedSnapshots as snap} + + +
+ {snap.snapshot_date} +
+ {#if snap.overall_state} + + {snap.overall_state} + + {/if} + {#if snap.trend} + {snap.trend} + {/if} +
+
+
+ {#if snap.hydration_level != null} +
+

Hydration

+

{snap.hydration_level}/5

+
+ {/if} + {#if snap.sensitivity_level != null} +
+

Sensitivity

+

{snap.sensitivity_level}/5

+
+ {/if} + {#if snap.barrier_state} +
+

Barrier

+

{snap.barrier_state.replace(/_/g, ' ')}

+
+ {/if} +
+ {#if snap.active_concerns.length} +
+ {#each snap.active_concerns as c} + {c.replace(/_/g, ' ')} + {/each} +
+ {/if} + {#if snap.notes} +

{snap.notes}

+ {/if} +
+
+ {:else} +

No skin snapshots yet.

+ {/each} +
+