feat: add API client, types, layout, and all page routes

This commit is contained in:
Piotr Oleszczyk 2026-02-26 20:45:54 +01:00
parent 2e4e3fba50
commit b140c55cda
71 changed files with 3237 additions and 58 deletions

1
frontend/.env.example Normal file
View file

@ -0,0 +1 @@
PUBLIC_API_BASE=http://localhost:8000

View file

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

View file

@ -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: {}

View file

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

192
frontend/src/lib/api.ts Normal file
View file

@ -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<T>(path: string, init: RequestInit = {}): Promise<T> {
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: <T>(path: string) => request<T>(path),
post: <T>(path: string, body: unknown) =>
request<T>(path, { method: 'POST', body: JSON.stringify(body) }),
patch: <T>(path: string, body: unknown) =>
request<T>(path, { method: 'PATCH', body: JSON.stringify(body) }),
del: (path: string) => request<void>(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<Product[]> {
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<Product> => api.get(`/products/${id}`);
export const createProduct = (body: Record<string, unknown>): Promise<Product> =>
api.post('/products', body);
export const updateProduct = (id: string, body: Record<string, unknown>): Promise<Product> =>
api.patch(`/products/${id}`, body);
export const deleteProduct = (id: string): Promise<void> => api.del(`/products/${id}`);
export const getInventory = (productId: string): Promise<ProductInventory[]> =>
api.get(`/products/${productId}/inventory`);
export const createInventory = (
productId: string,
body: Record<string, unknown>
): Promise<ProductInventory> => 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<Routine[]> {
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<Routine> => api.get(`/routines/${id}`);
export const createRoutine = (body: Record<string, unknown>): Promise<Routine> =>
api.post('/routines', body);
export const updateRoutine = (id: string, body: Record<string, unknown>): Promise<Routine> =>
api.patch(`/routines/${id}`, body);
export const deleteRoutine = (id: string): Promise<void> => api.del(`/routines/${id}`);
export const addRoutineStep = (routineId: string, body: Record<string, unknown>): Promise<RoutineStep> =>
api.post(`/routines/${routineId}/steps`, body);
export const updateRoutineStep = (stepId: string, body: Record<string, unknown>): Promise<RoutineStep> =>
api.patch(`/routines/steps/${stepId}`, body);
export const deleteRoutineStep = (stepId: string): Promise<void> =>
api.del(`/routines/steps/${stepId}`);
// ─── Health Medications ────────────────────────────────────────────────────
export interface MedicationListParams {
kind?: string;
product_name?: string;
}
export function getMedications(params: MedicationListParams = {}): Promise<MedicationEntry[]> {
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<MedicationEntry> =>
api.get(`/health/medications/${id}`);
export const createMedication = (body: Record<string, unknown>): Promise<MedicationEntry> =>
api.post('/health/medications', body);
export const updateMedication = (
id: string,
body: Record<string, unknown>
): Promise<MedicationEntry> => api.patch(`/health/medications/${id}`, body);
export const deleteMedication = (id: string): Promise<void> =>
api.del(`/health/medications/${id}`);
export const getMedicationUsages = (medicationId: string): Promise<MedicationUsage[]> =>
api.get(`/health/medications/${medicationId}/usages`);
export const createMedicationUsage = (
medicationId: string,
body: Record<string, unknown>
): Promise<MedicationUsage> => 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<LabResult[]> {
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<LabResult> =>
api.get(`/health/lab-results/${id}`);
export const createLabResult = (body: Record<string, unknown>): Promise<LabResult> =>
api.post('/health/lab-results', body);
export const updateLabResult = (id: string, body: Record<string, unknown>): Promise<LabResult> =>
api.patch(`/health/lab-results/${id}`, body);
export const deleteLabResult = (id: string): Promise<void> =>
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<SkinConditionSnapshot[]> {
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<SkinConditionSnapshot> =>
api.get(`/skincare/${id}`);
export const createSkinSnapshot = (body: Record<string, unknown>): Promise<SkinConditionSnapshot> =>
api.post('/skincare', body);
export const updateSkinSnapshot = (
id: string,
body: Record<string, unknown>
): Promise<SkinConditionSnapshot> => api.patch(`/skincare/${id}`, body);
export const deleteSkinSnapshot = (id: string): Promise<void> => api.del(`/skincare/${id}`);

View file

@ -0,0 +1,50 @@
<script lang="ts" module>
import { type VariantProps, tv } from "tailwind-variants";
export const badgeVariants = tv({
base: "focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive inline-flex w-fit shrink-0 items-center justify-center gap-1 overflow-hidden rounded-full border px-2 py-0.5 text-xs font-medium whitespace-nowrap transition-[color,box-shadow] focus-visible:ring-[3px] [&>svg]:pointer-events-none [&>svg]:size-3",
variants: {
variant: {
default:
"bg-primary text-primary-foreground [a&]:hover:bg-primary/90 border-transparent",
secondary:
"bg-secondary text-secondary-foreground [a&]:hover:bg-secondary/90 border-transparent",
destructive:
"bg-destructive [a&]:hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/70 border-transparent text-white",
outline: "text-foreground [a&]:hover:bg-accent [a&]:hover:text-accent-foreground",
},
},
defaultVariants: {
variant: "default",
},
});
export type BadgeVariant = VariantProps<typeof badgeVariants>["variant"];
</script>
<script lang="ts">
import type { HTMLAnchorAttributes } from "svelte/elements";
import { cn, type WithElementRef } from "$lib/utils.js";
let {
ref = $bindable(null),
href,
class: className,
variant = "default",
children,
...restProps
}: WithElementRef<HTMLAnchorAttributes> & {
variant?: BadgeVariant;
} = $props();
</script>
<svelte:element
this={href ? "a" : "span"}
bind:this={ref}
data-slot="badge"
{href}
class={cn(badgeVariants({ variant }), className)}
{...restProps}
>
{@render children?.()}
</svelte:element>

View file

@ -0,0 +1,2 @@
export { default as Badge } from "./badge.svelte";
export { badgeVariants, type BadgeVariant } from "./badge.svelte";

View file

@ -0,0 +1,82 @@
<script lang="ts" module>
import { cn, type WithElementRef } from "$lib/utils.js";
import type { HTMLAnchorAttributes, HTMLButtonAttributes } from "svelte/elements";
import { type VariantProps, tv } from "tailwind-variants";
export const buttonVariants = tv({
base: "focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive inline-flex shrink-0 items-center justify-center gap-2 rounded-md text-sm font-medium whitespace-nowrap transition-all outline-none focus-visible:ring-[3px] disabled:pointer-events-none disabled:opacity-50 aria-disabled:pointer-events-none aria-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
variants: {
variant: {
default: "bg-primary text-primary-foreground hover:bg-primary/90 shadow-xs",
destructive:
"bg-destructive hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60 text-white shadow-xs",
outline:
"bg-background hover:bg-accent hover:text-accent-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50 border shadow-xs",
secondary: "bg-secondary text-secondary-foreground hover:bg-secondary/80 shadow-xs",
ghost: "hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50",
link: "text-primary underline-offset-4 hover:underline",
},
size: {
default: "h-9 px-4 py-2 has-[>svg]:px-3",
sm: "h-8 gap-1.5 rounded-md px-3 has-[>svg]:px-2.5",
lg: "h-10 rounded-md px-6 has-[>svg]:px-4",
icon: "size-9",
"icon-sm": "size-8",
"icon-lg": "size-10",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
});
export type ButtonVariant = VariantProps<typeof buttonVariants>["variant"];
export type ButtonSize = VariantProps<typeof buttonVariants>["size"];
export type ButtonProps = WithElementRef<HTMLButtonAttributes> &
WithElementRef<HTMLAnchorAttributes> & {
variant?: ButtonVariant;
size?: ButtonSize;
};
</script>
<script lang="ts">
let {
class: className,
variant = "default",
size = "default",
ref = $bindable(null),
href = undefined,
type = "button",
disabled,
children,
...restProps
}: ButtonProps = $props();
</script>
{#if href}
<a
bind:this={ref}
data-slot="button"
class={cn(buttonVariants({ variant, size }), className)}
href={disabled ? undefined : href}
aria-disabled={disabled}
role={disabled ? "link" : undefined}
tabindex={disabled ? -1 : undefined}
{...restProps}
>
{@render children?.()}
</a>
{:else}
<button
bind:this={ref}
data-slot="button"
class={cn(buttonVariants({ variant, size }), className)}
{type}
{disabled}
{...restProps}
>
{@render children?.()}
</button>
{/if}

View file

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

View file

@ -0,0 +1,20 @@
<script lang="ts">
import { cn, type WithElementRef } from "$lib/utils.js";
import type { HTMLAttributes } from "svelte/elements";
let {
ref = $bindable(null),
class: className,
children,
...restProps
}: WithElementRef<HTMLAttributes<HTMLDivElement>> = $props();
</script>
<div
bind:this={ref}
data-slot="card-action"
class={cn("col-start-2 row-span-2 row-start-1 self-start justify-self-end", className)}
{...restProps}
>
{@render children?.()}
</div>

View file

@ -0,0 +1,15 @@
<script lang="ts">
import type { HTMLAttributes } from "svelte/elements";
import { cn, type WithElementRef } from "$lib/utils.js";
let {
ref = $bindable(null),
class: className,
children,
...restProps
}: WithElementRef<HTMLAttributes<HTMLDivElement>> = $props();
</script>
<div bind:this={ref} data-slot="card-content" class={cn("px-6", className)} {...restProps}>
{@render children?.()}
</div>

View file

@ -0,0 +1,20 @@
<script lang="ts">
import type { HTMLAttributes } from "svelte/elements";
import { cn, type WithElementRef } from "$lib/utils.js";
let {
ref = $bindable(null),
class: className,
children,
...restProps
}: WithElementRef<HTMLAttributes<HTMLParagraphElement>> = $props();
</script>
<p
bind:this={ref}
data-slot="card-description"
class={cn("text-muted-foreground text-sm", className)}
{...restProps}
>
{@render children?.()}
</p>

View file

@ -0,0 +1,20 @@
<script lang="ts">
import { cn, type WithElementRef } from "$lib/utils.js";
import type { HTMLAttributes } from "svelte/elements";
let {
ref = $bindable(null),
class: className,
children,
...restProps
}: WithElementRef<HTMLAttributes<HTMLDivElement>> = $props();
</script>
<div
bind:this={ref}
data-slot="card-footer"
class={cn("flex items-center px-6 [.border-t]:pt-6", className)}
{...restProps}
>
{@render children?.()}
</div>

View file

@ -0,0 +1,23 @@
<script lang="ts">
import { cn, type WithElementRef } from "$lib/utils.js";
import type { HTMLAttributes } from "svelte/elements";
let {
ref = $bindable(null),
class: className,
children,
...restProps
}: WithElementRef<HTMLAttributes<HTMLDivElement>> = $props();
</script>
<div
bind:this={ref}
data-slot="card-header"
class={cn(
"@container/card-header grid auto-rows-min grid-rows-[auto_auto] items-start gap-1.5 px-6 has-data-[slot=card-action]:grid-cols-[1fr_auto] [.border-b]:pb-6",
className
)}
{...restProps}
>
{@render children?.()}
</div>

View file

@ -0,0 +1,20 @@
<script lang="ts">
import type { HTMLAttributes } from "svelte/elements";
import { cn, type WithElementRef } from "$lib/utils.js";
let {
ref = $bindable(null),
class: className,
children,
...restProps
}: WithElementRef<HTMLAttributes<HTMLDivElement>> = $props();
</script>
<div
bind:this={ref}
data-slot="card-title"
class={cn("leading-none font-semibold", className)}
{...restProps}
>
{@render children?.()}
</div>

View file

@ -0,0 +1,23 @@
<script lang="ts">
import type { HTMLAttributes } from "svelte/elements";
import { cn, type WithElementRef } from "$lib/utils.js";
let {
ref = $bindable(null),
class: className,
children,
...restProps
}: WithElementRef<HTMLAttributes<HTMLDivElement>> = $props();
</script>
<div
bind:this={ref}
data-slot="card"
class={cn(
"bg-card text-card-foreground flex flex-col gap-6 rounded-xl border py-6 shadow-sm",
className
)}
{...restProps}
>
{@render children?.()}
</div>

View file

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

View file

@ -0,0 +1,7 @@
import Root from "./input.svelte";
export {
Root,
//
Root as Input,
};

View file

@ -0,0 +1,52 @@
<script lang="ts">
import type { HTMLInputAttributes, HTMLInputTypeAttribute } from "svelte/elements";
import { cn, type WithElementRef } from "$lib/utils.js";
type InputType = Exclude<HTMLInputTypeAttribute, "file">;
type Props = WithElementRef<
Omit<HTMLInputAttributes, "type"> &
({ type: "file"; files?: FileList } | { type?: InputType; files?: undefined })
>;
let {
ref = $bindable(null),
value = $bindable(),
type,
files = $bindable(),
class: className,
"data-slot": dataSlot = "input",
...restProps
}: Props = $props();
</script>
{#if type === "file"}
<input
bind:this={ref}
data-slot={dataSlot}
class={cn(
"selection:bg-primary dark:bg-input/30 selection:text-primary-foreground border-input ring-offset-background placeholder:text-muted-foreground flex h-9 w-full min-w-0 rounded-md border bg-transparent px-3 pt-1.5 text-sm font-medium shadow-xs transition-[color,box-shadow] outline-none disabled:cursor-not-allowed disabled:opacity-50",
"focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]",
"aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
className
)}
type="file"
bind:files
bind:value
{...restProps}
/>
{:else}
<input
bind:this={ref}
data-slot={dataSlot}
class={cn(
"border-input bg-background selection:bg-primary dark:bg-input/30 selection:text-primary-foreground ring-offset-background placeholder:text-muted-foreground flex h-9 w-full min-w-0 rounded-md border px-3 py-1 text-base shadow-xs transition-[color,box-shadow] outline-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
"focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]",
"aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
className
)}
{type}
bind:value
{...restProps}
/>
{/if}

View file

@ -0,0 +1,7 @@
import Root from "./label.svelte";
export {
Root,
//
Root as Label,
};

View file

@ -0,0 +1,20 @@
<script lang="ts">
import { Label as LabelPrimitive } from "bits-ui";
import { cn } from "$lib/utils.js";
let {
ref = $bindable(null),
class: className,
...restProps
}: LabelPrimitive.RootProps = $props();
</script>
<LabelPrimitive.Root
bind:ref
data-slot="label"
class={cn(
"flex items-center gap-2 text-sm leading-none font-medium select-none group-data-[disabled=true]:pointer-events-none group-data-[disabled=true]:opacity-50 peer-disabled:cursor-not-allowed peer-disabled:opacity-50",
className
)}
{...restProps}
/>

View file

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

View file

@ -0,0 +1,45 @@
<script lang="ts">
import { Select as SelectPrimitive } from "bits-ui";
import SelectPortal from "./select-portal.svelte";
import SelectScrollUpButton from "./select-scroll-up-button.svelte";
import SelectScrollDownButton from "./select-scroll-down-button.svelte";
import { cn, type WithoutChild } from "$lib/utils.js";
import type { ComponentProps } from "svelte";
import type { WithoutChildrenOrChild } from "$lib/utils.js";
let {
ref = $bindable(null),
class: className,
sideOffset = 4,
portalProps,
children,
preventScroll = true,
...restProps
}: WithoutChild<SelectPrimitive.ContentProps> & {
portalProps?: WithoutChildrenOrChild<ComponentProps<typeof SelectPortal>>;
} = $props();
</script>
<SelectPortal {...portalProps}>
<SelectPrimitive.Content
bind:ref
{sideOffset}
{preventScroll}
data-slot="select-content"
class={cn(
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-end-2 data-[side=right]:slide-in-from-start-2 data-[side=top]:slide-in-from-bottom-2 relative z-50 max-h-(--bits-select-content-available-height) min-w-[8rem] origin-(--bits-select-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border shadow-md data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1",
className
)}
{...restProps}
>
<SelectScrollUpButton />
<SelectPrimitive.Viewport
class={cn(
"h-(--bits-select-anchor-height) w-full min-w-(--bits-select-anchor-width) scroll-my-1 p-1"
)}
>
{@render children?.()}
</SelectPrimitive.Viewport>
<SelectScrollDownButton />
</SelectPrimitive.Content>
</SelectPortal>

View file

@ -0,0 +1,21 @@
<script lang="ts">
import { Select as SelectPrimitive } from "bits-ui";
import { cn } from "$lib/utils.js";
import type { ComponentProps } from "svelte";
let {
ref = $bindable(null),
class: className,
children,
...restProps
}: ComponentProps<typeof SelectPrimitive.GroupHeading> = $props();
</script>
<SelectPrimitive.GroupHeading
bind:ref
data-slot="select-group-heading"
class={cn("text-muted-foreground px-2 py-1.5 text-xs", className)}
{...restProps}
>
{@render children?.()}
</SelectPrimitive.GroupHeading>

View file

@ -0,0 +1,7 @@
<script lang="ts">
import { Select as SelectPrimitive } from "bits-ui";
let { ref = $bindable(null), ...restProps }: SelectPrimitive.GroupProps = $props();
</script>
<SelectPrimitive.Group bind:ref data-slot="select-group" {...restProps} />

View file

@ -0,0 +1,38 @@
<script lang="ts">
import CheckIcon from "@lucide/svelte/icons/check";
import { Select as SelectPrimitive } from "bits-ui";
import { cn, type WithoutChild } from "$lib/utils.js";
let {
ref = $bindable(null),
class: className,
value,
label,
children: childrenProp,
...restProps
}: WithoutChild<SelectPrimitive.ItemProps> = $props();
</script>
<SelectPrimitive.Item
bind:ref
{value}
data-slot="select-item"
class={cn(
"data-[highlighted]:bg-accent data-[highlighted]:text-accent-foreground [&_svg:not([class*='text-'])]:text-muted-foreground relative flex w-full cursor-default items-center gap-2 rounded-sm py-1.5 ps-2 pe-8 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 *:[span]:last:flex *:[span]:last:items-center *:[span]:last:gap-2",
className
)}
{...restProps}
>
{#snippet children({ selected, highlighted })}
<span class="absolute end-2 flex size-3.5 items-center justify-center">
{#if selected}
<CheckIcon class="size-4" />
{/if}
</span>
{#if childrenProp}
{@render childrenProp({ selected, highlighted })}
{:else}
{label || value}
{/if}
{/snippet}
</SelectPrimitive.Item>

View file

@ -0,0 +1,20 @@
<script lang="ts">
import { cn, type WithElementRef } from "$lib/utils.js";
import type { HTMLAttributes } from "svelte/elements";
let {
ref = $bindable(null),
class: className,
children,
...restProps
}: WithElementRef<HTMLAttributes<HTMLDivElement>> & {} = $props();
</script>
<div
bind:this={ref}
data-slot="select-label"
class={cn("text-muted-foreground px-2 py-1.5 text-xs", className)}
{...restProps}
>
{@render children?.()}
</div>

View file

@ -0,0 +1,7 @@
<script lang="ts">
import { Select as SelectPrimitive } from "bits-ui";
let { ...restProps }: SelectPrimitive.PortalProps = $props();
</script>
<SelectPrimitive.Portal {...restProps} />

View file

@ -0,0 +1,20 @@
<script lang="ts">
import ChevronDownIcon from "@lucide/svelte/icons/chevron-down";
import { Select as SelectPrimitive } from "bits-ui";
import { cn, type WithoutChildrenOrChild } from "$lib/utils.js";
let {
ref = $bindable(null),
class: className,
...restProps
}: WithoutChildrenOrChild<SelectPrimitive.ScrollDownButtonProps> = $props();
</script>
<SelectPrimitive.ScrollDownButton
bind:ref
data-slot="select-scroll-down-button"
class={cn("flex cursor-default items-center justify-center py-1", className)}
{...restProps}
>
<ChevronDownIcon class="size-4" />
</SelectPrimitive.ScrollDownButton>

View file

@ -0,0 +1,20 @@
<script lang="ts">
import ChevronUpIcon from "@lucide/svelte/icons/chevron-up";
import { Select as SelectPrimitive } from "bits-ui";
import { cn, type WithoutChildrenOrChild } from "$lib/utils.js";
let {
ref = $bindable(null),
class: className,
...restProps
}: WithoutChildrenOrChild<SelectPrimitive.ScrollUpButtonProps> = $props();
</script>
<SelectPrimitive.ScrollUpButton
bind:ref
data-slot="select-scroll-up-button"
class={cn("flex cursor-default items-center justify-center py-1", className)}
{...restProps}
>
<ChevronUpIcon class="size-4" />
</SelectPrimitive.ScrollUpButton>

View file

@ -0,0 +1,18 @@
<script lang="ts">
import type { Separator as SeparatorPrimitive } from "bits-ui";
import { Separator } from "$lib/components/ui/separator/index.js";
import { cn } from "$lib/utils.js";
let {
ref = $bindable(null),
class: className,
...restProps
}: SeparatorPrimitive.RootProps = $props();
</script>
<Separator
bind:ref
data-slot="select-separator"
class={cn("bg-border pointer-events-none -mx-1 my-1 h-px", className)}
{...restProps}
/>

View file

@ -0,0 +1,29 @@
<script lang="ts">
import { Select as SelectPrimitive } from "bits-ui";
import ChevronDownIcon from "@lucide/svelte/icons/chevron-down";
import { cn, type WithoutChild } from "$lib/utils.js";
let {
ref = $bindable(null),
class: className,
children,
size = "default",
...restProps
}: WithoutChild<SelectPrimitive.TriggerProps> & {
size?: "sm" | "default";
} = $props();
</script>
<SelectPrimitive.Trigger
bind:ref
data-slot="select-trigger"
data-size={size}
class={cn(
"border-input data-[placeholder]:text-muted-foreground [&_svg:not([class*='text-'])]:text-muted-foreground focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:bg-input/30 dark:hover:bg-input/50 flex w-fit items-center justify-between gap-2 rounded-md border bg-transparent px-3 py-2 text-sm whitespace-nowrap shadow-xs transition-[color,box-shadow] outline-none select-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50 data-[size=default]:h-9 data-[size=sm]:h-8 *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-2 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...restProps}
>
{@render children?.()}
<ChevronDownIcon class="size-4 opacity-50" />
</SelectPrimitive.Trigger>

View file

@ -0,0 +1,11 @@
<script lang="ts">
import { Select as SelectPrimitive } from "bits-ui";
let {
open = $bindable(false),
value = $bindable(),
...restProps
}: SelectPrimitive.RootProps = $props();
</script>
<SelectPrimitive.Root bind:open bind:value={value as never} {...restProps} />

View file

@ -0,0 +1,7 @@
import Root from "./separator.svelte";
export {
Root,
//
Root as Separator,
};

View file

@ -0,0 +1,21 @@
<script lang="ts">
import { Separator as SeparatorPrimitive } from "bits-ui";
import { cn } from "$lib/utils.js";
let {
ref = $bindable(null),
class: className,
"data-slot": dataSlot = "separator",
...restProps
}: SeparatorPrimitive.RootProps = $props();
</script>
<SeparatorPrimitive.Root
bind:ref
data-slot={dataSlot}
class={cn(
"bg-border shrink-0 data-[orientation=horizontal]:h-px data-[orientation=horizontal]:w-full data-[orientation=vertical]:min-h-full data-[orientation=vertical]:w-px",
className
)}
{...restProps}
/>

View file

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

View file

@ -0,0 +1,20 @@
<script lang="ts">
import { cn, type WithElementRef } from "$lib/utils.js";
import type { HTMLAttributes } from "svelte/elements";
let {
ref = $bindable(null),
class: className,
children,
...restProps
}: WithElementRef<HTMLAttributes<HTMLTableSectionElement>> = $props();
</script>
<tbody
bind:this={ref}
data-slot="table-body"
class={cn("[&_tr:last-child]:border-0", className)}
{...restProps}
>
{@render children?.()}
</tbody>

View file

@ -0,0 +1,20 @@
<script lang="ts">
import { cn, type WithElementRef } from "$lib/utils.js";
import type { HTMLAttributes } from "svelte/elements";
let {
ref = $bindable(null),
class: className,
children,
...restProps
}: WithElementRef<HTMLAttributes<HTMLElement>> = $props();
</script>
<caption
bind:this={ref}
data-slot="table-caption"
class={cn("text-muted-foreground mt-4 text-sm", className)}
{...restProps}
>
{@render children?.()}
</caption>

View file

@ -0,0 +1,23 @@
<script lang="ts">
import { cn, type WithElementRef } from "$lib/utils.js";
import type { HTMLTdAttributes } from "svelte/elements";
let {
ref = $bindable(null),
class: className,
children,
...restProps
}: WithElementRef<HTMLTdAttributes> = $props();
</script>
<td
bind:this={ref}
data-slot="table-cell"
class={cn(
"bg-clip-padding p-2 align-middle whitespace-nowrap [&:has([role=checkbox])]:pe-0",
className
)}
{...restProps}
>
{@render children?.()}
</td>

View file

@ -0,0 +1,20 @@
<script lang="ts">
import { cn, type WithElementRef } from "$lib/utils.js";
import type { HTMLAttributes } from "svelte/elements";
let {
ref = $bindable(null),
class: className,
children,
...restProps
}: WithElementRef<HTMLAttributes<HTMLTableSectionElement>> = $props();
</script>
<tfoot
bind:this={ref}
data-slot="table-footer"
class={cn("bg-muted/50 border-t font-medium [&>tr]:last:border-b-0", className)}
{...restProps}
>
{@render children?.()}
</tfoot>

View file

@ -0,0 +1,23 @@
<script lang="ts">
import { cn, type WithElementRef } from "$lib/utils.js";
import type { HTMLThAttributes } from "svelte/elements";
let {
ref = $bindable(null),
class: className,
children,
...restProps
}: WithElementRef<HTMLThAttributes> = $props();
</script>
<th
bind:this={ref}
data-slot="table-head"
class={cn(
"text-foreground h-10 bg-clip-padding px-2 text-start align-middle font-medium whitespace-nowrap [&:has([role=checkbox])]:pe-0",
className
)}
{...restProps}
>
{@render children?.()}
</th>

View file

@ -0,0 +1,20 @@
<script lang="ts">
import { cn, type WithElementRef } from "$lib/utils.js";
import type { HTMLAttributes } from "svelte/elements";
let {
ref = $bindable(null),
class: className,
children,
...restProps
}: WithElementRef<HTMLAttributes<HTMLTableSectionElement>> = $props();
</script>
<thead
bind:this={ref}
data-slot="table-header"
class={cn("[&_tr]:border-b", className)}
{...restProps}
>
{@render children?.()}
</thead>

View file

@ -0,0 +1,23 @@
<script lang="ts">
import { cn, type WithElementRef } from "$lib/utils.js";
import type { HTMLAttributes } from "svelte/elements";
let {
ref = $bindable(null),
class: className,
children,
...restProps
}: WithElementRef<HTMLAttributes<HTMLTableRowElement>> = $props();
</script>
<tr
bind:this={ref}
data-slot="table-row"
class={cn(
"hover:[&,&>svelte-css-wrapper]:[&>th,td]:bg-muted/50 data-[state=selected]:bg-muted border-b transition-colors",
className
)}
{...restProps}
>
{@render children?.()}
</tr>

View file

@ -0,0 +1,22 @@
<script lang="ts">
import type { HTMLTableAttributes } from "svelte/elements";
import { cn, type WithElementRef } from "$lib/utils.js";
let {
ref = $bindable(null),
class: className,
children,
...restProps
}: WithElementRef<HTMLTableAttributes> = $props();
</script>
<div data-slot="table-container" class="relative w-full overflow-x-auto">
<table
bind:this={ref}
data-slot="table"
class={cn("w-full caption-bottom text-sm", className)}
{...restProps}
>
{@render children?.()}
</table>
</div>

View file

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

View file

@ -0,0 +1,17 @@
<script lang="ts">
import { Tabs as TabsPrimitive } from "bits-ui";
import { cn } from "$lib/utils.js";
let {
ref = $bindable(null),
class: className,
...restProps
}: TabsPrimitive.ContentProps = $props();
</script>
<TabsPrimitive.Content
bind:ref
data-slot="tabs-content"
class={cn("flex-1 outline-none", className)}
{...restProps}
/>

View file

@ -0,0 +1,20 @@
<script lang="ts">
import { Tabs as TabsPrimitive } from "bits-ui";
import { cn } from "$lib/utils.js";
let {
ref = $bindable(null),
class: className,
...restProps
}: TabsPrimitive.ListProps = $props();
</script>
<TabsPrimitive.List
bind:ref
data-slot="tabs-list"
class={cn(
"bg-muted text-muted-foreground inline-flex h-9 w-fit items-center justify-center rounded-lg p-[3px]",
className
)}
{...restProps}
/>

View file

@ -0,0 +1,20 @@
<script lang="ts">
import { Tabs as TabsPrimitive } from "bits-ui";
import { cn } from "$lib/utils.js";
let {
ref = $bindable(null),
class: className,
...restProps
}: TabsPrimitive.TriggerProps = $props();
</script>
<TabsPrimitive.Trigger
bind:ref
data-slot="tabs-trigger"
class={cn(
"data-[state=active]:bg-background dark:data-[state=active]:text-foreground focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:outline-ring dark:data-[state=active]:border-input dark:data-[state=active]:bg-input/30 text-foreground dark:text-muted-foreground inline-flex h-[calc(100%-1px)] flex-1 items-center justify-center gap-1.5 rounded-md border border-transparent px-2 py-1 text-sm font-medium whitespace-nowrap transition-[color,box-shadow] focus-visible:ring-[3px] focus-visible:outline-1 disabled:pointer-events-none disabled:opacity-50 data-[state=active]:shadow-sm [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...restProps}
/>

View file

@ -0,0 +1,19 @@
<script lang="ts">
import { Tabs as TabsPrimitive } from "bits-ui";
import { cn } from "$lib/utils.js";
let {
ref = $bindable(null),
value = $bindable(""),
class: className,
...restProps
}: TabsPrimitive.RootProps = $props();
</script>
<TabsPrimitive.Root
bind:ref
bind:value
data-slot="tabs"
class={cn("flex flex-col gap-2", className)}
{...restProps}
/>

286
frontend/src/lib/types.ts Normal file
View file

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

View file

@ -1,11 +1,51 @@
<script lang="ts">
import favicon from '$lib/assets/favicon.svg';
import '../app.css';
import { page } from '$app/state';
let { children } = $props();
const navItems = [
{ href: '/', label: 'Dashboard', icon: '⌂' },
{ href: '/products', label: 'Products', icon: '🧴' },
{ href: '/routines', label: 'Routines', icon: '📋' },
{ href: '/health/medications', label: 'Medications', icon: '💊' },
{ href: '/health/lab-results', label: 'Lab Results', icon: '🔬' },
{ href: '/skin', label: 'Skin', icon: '✨' }
];
function isActive(href: string) {
if (href === '/') return page.url.pathname === '/';
return page.url.pathname.startsWith(href);
}
</script>
<svelte:head>
<link rel="icon" href={favicon} />
</svelte:head>
<div class="flex min-h-screen bg-background">
<!-- Sidebar -->
<nav class="w-56 shrink-0 border-r border-border bg-card px-3 py-6">
<div class="mb-8 px-3">
<h1 class="text-lg font-semibold tracking-tight">innercontext</h1>
<p class="text-xs text-muted-foreground">personal health & skincare</p>
</div>
<ul class="space-y-1">
{#each navItems as item}
<li>
<a
href={item.href}
class="flex items-center gap-3 rounded-md px-3 py-2 text-sm transition-colors
{isActive(item.href)
? 'bg-accent text-accent-foreground font-medium'
: 'text-muted-foreground hover:bg-accent hover:text-accent-foreground'}"
>
<span class="text-base">{item.icon}</span>
{item.label}
</a>
</li>
{/each}
</ul>
</nav>
{@render children()}
<!-- Main content -->
<main class="flex-1 overflow-auto p-8">
{@render children()}
</main>
</div>

View file

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

View file

@ -1,2 +1,87 @@
<h1>Welcome to SvelteKit</h1>
<p>Visit <a href="https://svelte.dev/docs/kit">svelte.dev/docs/kit</a> to read the documentation</p>
<script lang="ts">
import type { PageData } from './$types';
import { Badge } from '$lib/components/ui/badge';
import { Card, CardContent, CardHeader, CardTitle } from '$lib/components/ui/card';
let { data }: { data: PageData } = $props();
const stateColors: Record<string, string> = {
excellent: 'bg-green-100 text-green-800',
good: 'bg-blue-100 text-blue-800',
fair: 'bg-yellow-100 text-yellow-800',
poor: 'bg-red-100 text-red-800'
};
</script>
<svelte:head><title>Dashboard — innercontext</title></svelte:head>
<div class="space-y-8">
<div>
<h2 class="text-2xl font-bold tracking-tight">Dashboard</h2>
<p class="text-muted-foreground">Your recent health & skincare overview</p>
</div>
<div class="grid gap-6 md:grid-cols-2">
<!-- Latest skin snapshot -->
<Card>
<CardHeader>
<CardTitle>Latest Skin Snapshot</CardTitle>
</CardHeader>
<CardContent>
{#if data.latestSnapshot}
{@const s = data.latestSnapshot}
<div class="space-y-3">
<div class="flex items-center justify-between">
<span class="text-sm text-muted-foreground">{s.snapshot_date}</span>
{#if s.overall_state}
<span class="rounded-full px-2 py-0.5 text-xs font-medium {stateColors[s.overall_state] ?? ''}">
{s.overall_state}
</span>
{/if}
</div>
{#if s.trend}
<p class="text-sm">Trend: <span class="font-medium">{s.trend}</span></p>
{/if}
{#if s.active_concerns.length}
<div class="flex flex-wrap gap-1">
{#each s.active_concerns as concern}
<Badge variant="secondary">{concern.replace(/_/g, ' ')}</Badge>
{/each}
</div>
{/if}
{#if s.notes}
<p class="text-sm text-muted-foreground">{s.notes}</p>
{/if}
</div>
{:else}
<p class="text-sm text-muted-foreground">No skin snapshots yet.</p>
{/if}
</CardContent>
</Card>
<!-- Recent routines -->
<Card>
<CardHeader>
<CardTitle>Recent Routines</CardTitle>
</CardHeader>
<CardContent>
{#if data.recentRoutines.length}
<ul class="space-y-2">
{#each data.recentRoutines as routine}
<li class="flex items-center justify-between">
<a href="/routines/{routine.id}" class="text-sm hover:underline">
{routine.routine_date}
</a>
<Badge variant={routine.part_of_day === 'am' ? 'default' : 'secondary'}>
{routine.part_of_day.toUpperCase()}
</Badge>
</li>
{/each}
</ul>
{:else}
<p class="text-sm text-muted-foreground">No routines in the past 2 weeks.</p>
{/if}
</CardContent>
</Card>
</div>
</div>

View file

@ -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<string, unknown> = {
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 });
}
}
};

View file

@ -0,0 +1,176 @@
<script lang="ts">
import { enhance } from '$app/forms';
import type { ActionData, PageData } from './$types';
import { Badge } from '$lib/components/ui/badge';
import { Button } from '$lib/components/ui/button';
import { Card, CardContent, CardHeader, CardTitle } from '$lib/components/ui/card';
import { Input } from '$lib/components/ui/input';
import { Label } from '$lib/components/ui/label';
import { Select, SelectContent, SelectItem, SelectTrigger } from '$lib/components/ui/select';
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow
} from '$lib/components/ui/table';
import { goto } from '$app/navigation';
let { data, form }: { data: PageData; form: ActionData } = $props();
const flags = ['N', 'ABN', 'POS', 'NEG', 'L', 'H'];
const flagColors: Record<string, string> = {
N: 'bg-green-100 text-green-800',
ABN: 'bg-red-100 text-red-800',
POS: 'bg-orange-100 text-orange-800',
NEG: 'bg-blue-100 text-blue-800',
L: 'bg-yellow-100 text-yellow-800',
H: 'bg-red-100 text-red-800'
};
let showForm = $state(false);
let selectedFlag = $state('');
let filterFlag = $derived(data.flag ?? '');
</script>
<svelte:head><title>Lab Results — innercontext</title></svelte:head>
<div class="space-y-6">
<div class="flex items-center justify-between">
<div>
<h2 class="text-2xl font-bold tracking-tight">Lab Results</h2>
<p class="text-muted-foreground">{data.results.length} results</p>
</div>
<Button variant="outline" onclick={() => (showForm = !showForm)}>
{showForm ? 'Cancel' : '+ Add result'}
</Button>
</div>
{#if form?.error}
<div class="rounded-md bg-destructive/10 px-4 py-3 text-sm text-destructive">{form.error}</div>
{/if}
{#if form?.created}
<div class="rounded-md bg-green-50 px-4 py-3 text-sm text-green-700">Result added.</div>
{/if}
<!-- Filter -->
<div class="flex items-center gap-3">
<span class="text-sm text-muted-foreground">Flag:</span>
<Select
type="single"
value={filterFlag}
onValueChange={(v) => {
filterFlag = v;
goto(v ? `/health/lab-results?flag=${v}` : '/health/lab-results');
}}
>
<SelectTrigger class="w-32">{filterFlag || 'All'}</SelectTrigger>
<SelectContent>
<SelectItem value="">All</SelectItem>
{#each flags as f}
<SelectItem value={f}>{f}</SelectItem>
{/each}
</SelectContent>
</Select>
</div>
{#if showForm}
<Card>
<CardHeader><CardTitle>New lab result</CardTitle></CardHeader>
<CardContent>
<form method="POST" action="?/create" use:enhance class="grid grid-cols-2 gap-4">
<div class="space-y-1">
<Label for="collected_at">Date *</Label>
<Input id="collected_at" name="collected_at" type="date" required />
</div>
<div class="space-y-1">
<Label for="test_code">LOINC code * <span class="text-xs text-muted-foreground">(e.g. 718-7)</span></Label>
<Input id="test_code" name="test_code" required placeholder="718-7" />
</div>
<div class="space-y-1">
<Label for="test_name_original">Test name</Label>
<Input id="test_name_original" name="test_name_original" placeholder="e.g. Hemoglobin" />
</div>
<div class="space-y-1">
<Label for="lab">Lab</Label>
<Input id="lab" name="lab" placeholder="e.g. LabCorp" />
</div>
<div class="space-y-1">
<Label for="value_num">Value</Label>
<Input id="value_num" name="value_num" type="number" step="any" />
</div>
<div class="space-y-1">
<Label for="unit_original">Unit</Label>
<Input id="unit_original" name="unit_original" placeholder="e.g. g/dL" />
</div>
<div class="space-y-1">
<Label>Flag</Label>
<input type="hidden" name="flag" value={selectedFlag} />
<Select type="single" value={selectedFlag} onValueChange={(v) => (selectedFlag = v)}>
<SelectTrigger>{selectedFlag || 'None'}</SelectTrigger>
<SelectContent>
<SelectItem value="">None</SelectItem>
{#each flags as f}
<SelectItem value={f}>{f}</SelectItem>
{/each}
</SelectContent>
</Select>
</div>
<div class="flex items-end">
<Button type="submit">Add</Button>
</div>
</form>
</CardContent>
</Card>
{/if}
<div class="rounded-md border border-border">
<Table>
<TableHeader>
<TableRow>
<TableHead>Date</TableHead>
<TableHead>Test</TableHead>
<TableHead>LOINC</TableHead>
<TableHead>Value</TableHead>
<TableHead>Flag</TableHead>
<TableHead>Lab</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{#each data.results as r}
<TableRow>
<TableCell class="text-sm">{r.collected_at.slice(0, 10)}</TableCell>
<TableCell class="font-medium">{r.test_name_original ?? r.test_code}</TableCell>
<TableCell class="text-xs text-muted-foreground font-mono">{r.test_code}</TableCell>
<TableCell>
{#if r.value_num != null}
{r.value_num} {r.unit_original ?? ''}
{:else if r.value_text}
{r.value_text}
{:else}
{/if}
</TableCell>
<TableCell>
{#if r.flag}
<span class="rounded-full px-2 py-0.5 text-xs font-medium {flagColors[r.flag] ?? ''}">
{r.flag}
</span>
{:else}
{/if}
</TableCell>
<TableCell class="text-sm text-muted-foreground">{r.lab ?? '—'}</TableCell>
</TableRow>
{:else}
<TableRow>
<TableCell colspan={6} class="text-center text-muted-foreground py-8">
No lab results found.
</TableCell>
</TableRow>
{/each}
</TableBody>
</Table>
</div>
</div>

View file

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

View file

@ -0,0 +1,106 @@
<script lang="ts">
import { enhance } from '$app/forms';
import type { ActionData, PageData } from './$types';
import { Badge } from '$lib/components/ui/badge';
import { Button } from '$lib/components/ui/button';
import { Card, CardContent, CardHeader, CardTitle } from '$lib/components/ui/card';
import { Input } from '$lib/components/ui/input';
import { Label } from '$lib/components/ui/label';
import { Select, SelectContent, SelectItem, SelectTrigger } from '$lib/components/ui/select';
let { data, form }: { data: PageData; form: ActionData } = $props();
const kinds = ['prescription', 'otc', 'supplement', 'herbal', 'other'];
let showForm = $state(false);
let kind = $state('supplement');
const kindColors: Record<string, string> = {
prescription: 'bg-purple-100 text-purple-800',
otc: 'bg-blue-100 text-blue-800',
supplement: 'bg-green-100 text-green-800',
herbal: 'bg-emerald-100 text-emerald-800',
other: 'bg-gray-100 text-gray-700'
};
</script>
<svelte:head><title>Medications — innercontext</title></svelte:head>
<div class="space-y-6">
<div class="flex items-center justify-between">
<div>
<h2 class="text-2xl font-bold tracking-tight">Medications</h2>
<p class="text-muted-foreground">{data.medications.length} entries</p>
</div>
<Button variant="outline" onclick={() => (showForm = !showForm)}>
{showForm ? 'Cancel' : '+ Add medication'}
</Button>
</div>
{#if form?.error}
<div class="rounded-md bg-destructive/10 px-4 py-3 text-sm text-destructive">{form.error}</div>
{/if}
{#if form?.created}
<div class="rounded-md bg-green-50 px-4 py-3 text-sm text-green-700">Medication added.</div>
{/if}
{#if showForm}
<Card>
<CardHeader><CardTitle>New medication</CardTitle></CardHeader>
<CardContent>
<form method="POST" action="?/create" use:enhance class="grid grid-cols-2 gap-4">
<div class="space-y-1 col-span-2">
<Label>Kind</Label>
<input type="hidden" name="kind" value={kind} />
<Select type="single" value={kind} onValueChange={(v) => (kind = v)}>
<SelectTrigger>{kind}</SelectTrigger>
<SelectContent>
{#each kinds as k}
<SelectItem value={k}>{k}</SelectItem>
{/each}
</SelectContent>
</Select>
</div>
<div class="space-y-1">
<Label for="product_name">Product name *</Label>
<Input id="product_name" name="product_name" required placeholder="e.g. Vitamin D3" />
</div>
<div class="space-y-1">
<Label for="active_substance">Active substance</Label>
<Input id="active_substance" name="active_substance" placeholder="e.g. cholecalciferol" />
</div>
<div class="space-y-1 col-span-2">
<Label for="notes">Notes</Label>
<Input id="notes" name="notes" />
</div>
<div class="col-span-2">
<Button type="submit">Add</Button>
</div>
</form>
</CardContent>
</Card>
{/if}
<div class="space-y-3">
{#each data.medications as med}
<div class="rounded-md border border-border px-4 py-3">
<div class="flex items-center justify-between">
<div class="flex items-center gap-3">
<span class="rounded-full px-2 py-0.5 text-xs font-medium {kindColors[med.kind] ?? ''}">
{med.kind}
</span>
<span class="font-medium">{med.product_name}</span>
{#if med.active_substance}
<span class="text-sm text-muted-foreground">{med.active_substance}</span>
{/if}
</div>
<Badge variant="secondary">{med.usage_history.length} usages</Badge>
</div>
{#if med.notes}
<p class="mt-1 text-sm text-muted-foreground">{med.notes}</p>
{/if}
</div>
{:else}
<p class="text-sm text-muted-foreground">No medications recorded.</p>
{/each}
</div>
</div>

View file

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

View file

@ -0,0 +1,114 @@
<script lang="ts">
import type { PageData } from './$types';
import { Badge } from '$lib/components/ui/badge';
import { Button } from '$lib/components/ui/button';
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow
} from '$lib/components/ui/table';
import { Select, SelectContent, SelectItem, SelectTrigger } from '$lib/components/ui/select';
import { goto } from '$app/navigation';
let { data }: { data: PageData } = $props();
const categories = [
'cleanser', 'toner', 'essence', 'serum', 'moisturizer',
'spf', 'mask', 'exfoliant', 'hair_treatment', 'tool', 'spot_treatment', 'oil'
];
let selectedCategory = $derived(data.category ?? '');
function filterByCategory(cat: string) {
selectedCategory = cat;
goto(cat ? `/products?category=${cat}` : '/products');
}
</script>
<svelte:head><title>Products — innercontext</title></svelte:head>
<div class="space-y-6">
<div class="flex items-center justify-between">
<div>
<h2 class="text-2xl font-bold tracking-tight">Products</h2>
<p class="text-muted-foreground">{data.products.length} products</p>
</div>
<Button href="/products/new">+ Add product</Button>
</div>
<div class="flex items-center gap-3">
<span class="text-sm text-muted-foreground">Filter by category:</span>
<Select
type="single"
value={selectedCategory}
onValueChange={filterByCategory}
>
<SelectTrigger class="w-48">
{selectedCategory ? selectedCategory.replace(/_/g, ' ') : 'All categories'}
</SelectTrigger>
<SelectContent>
<SelectItem value="">All categories</SelectItem>
{#each categories as cat}
<SelectItem value={cat}>{cat.replace(/_/g, ' ')}</SelectItem>
{/each}
</SelectContent>
</Select>
</div>
<div class="rounded-md border border-border">
<Table>
<TableHeader>
<TableRow>
<TableHead>Name</TableHead>
<TableHead>Brand</TableHead>
<TableHead>Category</TableHead>
<TableHead>Targets</TableHead>
<TableHead>Time</TableHead>
<TableHead>Rating</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{#each data.products as product}
<TableRow class="cursor-pointer hover:bg-muted/50">
<TableCell>
<a href="/products/{product.id}" class="font-medium hover:underline">
{product.name}
</a>
</TableCell>
<TableCell class="text-muted-foreground">{product.brand}</TableCell>
<TableCell>
<Badge variant="outline">{product.category.replace(/_/g, ' ')}</Badge>
</TableCell>
<TableCell>
<div class="flex flex-wrap gap-1">
{#each product.targets.slice(0, 3) as t}
<Badge variant="secondary" class="text-xs">{t.replace(/_/g, ' ')}</Badge>
{/each}
{#if product.targets.length > 3}
<span class="text-xs text-muted-foreground">+{product.targets.length - 3}</span>
{/if}
</div>
</TableCell>
<TableCell class="uppercase text-sm">{product.recommended_time}</TableCell>
<TableCell>
{#if product.personal_rating}
{product.personal_rating}/10
{:else}
<span class="text-muted-foreground"></span>
{/if}
</TableCell>
</TableRow>
{:else}
<TableRow>
<TableCell colspan={6} class="text-center text-muted-foreground py-8">
No products found.
</TableCell>
</TableRow>
{/each}
</TableBody>
</Table>
</div>
</div>

View file

@ -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<string, unknown> = {};
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<string, unknown> = {
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 });
}
}
};

View file

@ -0,0 +1,185 @@
<script lang="ts">
import { enhance } from '$app/forms';
import type { ActionData, PageData } from './$types';
import { Badge } from '$lib/components/ui/badge';
import { Button } from '$lib/components/ui/button';
import { Card, CardContent, CardHeader, CardTitle } from '$lib/components/ui/card';
import { Input } from '$lib/components/ui/input';
import { Label } from '$lib/components/ui/label';
import { Separator } from '$lib/components/ui/separator';
let { data, form }: { data: PageData; form: ActionData } = $props();
let { product } = $derived(data);
let showInventoryForm = $state(false);
</script>
<svelte:head><title>{product.name} — innercontext</title></svelte:head>
<div class="max-w-2xl space-y-6">
<div class="flex items-center gap-4">
<a href="/products" class="text-sm text-muted-foreground hover:underline">← Products</a>
<h2 class="text-2xl font-bold tracking-tight">{product.name}</h2>
<Badge variant="outline">{product.category.replace(/_/g, ' ')}</Badge>
</div>
{#if form?.error}
<div class="rounded-md bg-destructive/10 px-4 py-3 text-sm text-destructive">{form.error}</div>
{/if}
{#if form?.success}
<div class="rounded-md bg-green-50 px-4 py-3 text-sm text-green-700">Saved.</div>
{/if}
<!-- Product info -->
<Card>
<CardHeader><CardTitle>Details</CardTitle></CardHeader>
<CardContent class="space-y-3 text-sm">
<div class="grid grid-cols-2 gap-2">
<span class="text-muted-foreground">Brand</span><span>{product.brand}</span>
<span class="text-muted-foreground">Line</span><span>{product.line_name ?? '—'}</span>
<span class="text-muted-foreground">Routine role</span><span>{product.routine_role.replace(/_/g, ' ')}</span>
<span class="text-muted-foreground">Time</span><span class="uppercase">{product.recommended_time}</span>
<span class="text-muted-foreground">Leave-on</span><span>{product.leave_on ? 'Yes' : 'No'}</span>
<span class="text-muted-foreground">Texture</span><span>{product.texture ?? '—'}</span>
<span class="text-muted-foreground">pH</span>
<span>
{#if product.ph_min != null && product.ph_max != null}
{product.ph_min}{product.ph_max}
{:else}
{/if}
</span>
<span class="text-muted-foreground">Rating</span>
<span>{product.personal_rating != null ? `${product.personal_rating}/10` : '—'}</span>
</div>
{#if product.targets.length}
<div>
<span class="text-muted-foreground">Targets: </span>
{#each product.targets as t}
<Badge variant="secondary" class="mr-1">{t.replace(/_/g, ' ')}</Badge>
{/each}
</div>
{/if}
{#if product.actives?.length}
<div>
<p class="text-muted-foreground mb-1">Actives:</p>
<ul class="list-disc pl-4 space-y-0.5">
{#each product.actives as a}
<li>{a.name}{a.percent != null ? ` ${a.percent}%` : ''}</li>
{/each}
</ul>
</div>
{/if}
{#if product.usage_notes}
<p class="text-muted-foreground">{product.usage_notes}</p>
{/if}
</CardContent>
</Card>
<!-- Quick edit -->
<Card>
<CardHeader><CardTitle>Quick edit</CardTitle></CardHeader>
<CardContent>
<form method="POST" action="?/update" use:enhance class="grid grid-cols-2 gap-4">
<div class="space-y-1">
<Label for="personal_rating">Personal rating (1-10)</Label>
<Input
id="personal_rating"
name="personal_rating"
type="number"
min="1"
max="10"
value={product.personal_rating ?? ''}
/>
</div>
<div class="space-y-1">
<Label for="personal_tolerance_notes">Tolerance notes</Label>
<Input
id="personal_tolerance_notes"
name="personal_tolerance_notes"
value={product.personal_tolerance_notes ?? ''}
/>
</div>
<div class="col-span-2">
<Button type="submit" size="sm">Save</Button>
</div>
</form>
</CardContent>
</Card>
<Separator />
<!-- Inventory -->
<div class="space-y-3">
<div class="flex items-center justify-between">
<h3 class="text-lg font-semibold">Inventory packages ({product.inventory.length})</h3>
<Button variant="outline" size="sm" onclick={() => (showInventoryForm = !showInventoryForm)}>
{showInventoryForm ? 'Cancel' : '+ Add package'}
</Button>
</div>
{#if form?.inventoryAdded}
<div class="rounded-md bg-green-50 px-4 py-3 text-sm text-green-700">Package added.</div>
{/if}
{#if showInventoryForm}
<Card>
<CardContent class="pt-4">
<form method="POST" action="?/addInventory" use:enhance class="grid grid-cols-2 gap-4">
<div class="space-y-1">
<Label for="expiry_date">Expiry date</Label>
<Input id="expiry_date" name="expiry_date" type="date" />
</div>
<div class="space-y-1">
<Label for="current_weight_g">Current weight (g)</Label>
<Input id="current_weight_g" name="current_weight_g" type="number" min="0" />
</div>
<div class="space-y-1">
<Label for="notes">Notes</Label>
<Input id="notes" name="notes" />
</div>
<div class="flex items-end">
<Button type="submit" size="sm">Add</Button>
</div>
<input type="hidden" name="is_opened" value="false" />
</form>
</CardContent>
</Card>
{/if}
{#if product.inventory.length}
<div class="space-y-2">
{#each product.inventory as pkg}
<div class="rounded-md border border-border px-4 py-3 text-sm flex items-center justify-between">
<div class="space-x-3">
<Badge variant={pkg.is_opened ? 'default' : 'secondary'}>
{pkg.is_opened ? 'Open' : 'Sealed'}
</Badge>
{#if pkg.expiry_date}
<span class="text-muted-foreground">Exp: {pkg.expiry_date}</span>
{/if}
{#if pkg.current_weight_g}
<span class="text-muted-foreground">{pkg.current_weight_g}g remaining</span>
{/if}
</div>
{#if pkg.notes}
<span class="text-muted-foreground">{pkg.notes}</span>
{/if}
</div>
{/each}
</div>
{:else}
<p class="text-sm text-muted-foreground">No inventory packages.</p>
{/if}
</div>
<Separator />
<!-- Danger zone -->
<div>
<form method="POST" action="?/delete" use:enhance
onsubmit={(e) => { if (!confirm('Delete this product?')) e.preventDefault(); }}>
<Button type="submit" variant="destructive" size="sm">Delete product</Button>
</form>
</div>
</div>

View file

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

View file

@ -0,0 +1,127 @@
<script lang="ts">
import { enhance } from '$app/forms';
import type { ActionData } from './$types';
import { Button } from '$lib/components/ui/button';
import { Input } from '$lib/components/ui/input';
import { Label } from '$lib/components/ui/label';
import { Select, SelectContent, SelectItem, SelectTrigger } from '$lib/components/ui/select';
import { Card, CardContent, CardHeader, CardTitle } from '$lib/components/ui/card';
let { form }: { form: ActionData } = $props();
const categories = [
'cleanser', 'toner', 'essence', 'serum', 'moisturizer',
'spf', 'mask', 'exfoliant', 'hair_treatment', 'tool', 'spot_treatment', 'oil'
];
const routineRoles = [
'cleanse', 'prepare', 'treatment_active', 'treatment_support',
'seal', 'protect', 'hair_treatment'
];
const skinConcerns = [
'acne', 'rosacea', 'hyperpigmentation', 'aging', 'dehydration',
'redness', 'damaged_barrier', 'pore_visibility', 'uneven_texture',
'hair_growth', 'sebum_excess'
];
let category = $state('');
let routineRole = $state('');
let recommendedTime = $state('');
let leaveOn = $state('true');
</script>
<svelte:head><title>New Product — innercontext</title></svelte:head>
<div class="max-w-xl space-y-6">
<div class="flex items-center gap-4">
<a href="/products" class="text-sm text-muted-foreground hover:underline">← Products</a>
<h2 class="text-2xl font-bold tracking-tight">New Product</h2>
</div>
{#if form?.error}
<div class="rounded-md bg-destructive/10 px-4 py-3 text-sm text-destructive">
{form.error}
</div>
{/if}
<Card>
<CardHeader><CardTitle>Product details</CardTitle></CardHeader>
<CardContent>
<form method="POST" use:enhance class="space-y-5">
<div class="space-y-2">
<Label for="name">Name *</Label>
<Input id="name" name="name" required placeholder="e.g. Hydro Boost Water Gel" />
</div>
<div class="space-y-2">
<Label for="brand">Brand *</Label>
<Input id="brand" name="brand" required placeholder="e.g. Neutrogena" />
</div>
<div class="space-y-2">
<Label>Category *</Label>
<input type="hidden" name="category" value={category} />
<Select type="single" value={category} onValueChange={(v) => (category = v)}>
<SelectTrigger>{category ? category.replace(/_/g, ' ') : 'Select category'}</SelectTrigger>
<SelectContent>
{#each categories as cat}
<SelectItem value={cat}>{cat.replace(/_/g, ' ')}</SelectItem>
{/each}
</SelectContent>
</Select>
</div>
<div class="space-y-2">
<Label>Routine role *</Label>
<input type="hidden" name="routine_role" value={routineRole} />
<Select type="single" value={routineRole} onValueChange={(v) => (routineRole = v)}>
<SelectTrigger>{routineRole ? routineRole.replace(/_/g, ' ') : 'Select role'}</SelectTrigger>
<SelectContent>
{#each routineRoles as role}
<SelectItem value={role}>{role.replace(/_/g, ' ')}</SelectItem>
{/each}
</SelectContent>
</Select>
</div>
<div class="space-y-2">
<Label>Recommended time *</Label>
<input type="hidden" name="recommended_time" value={recommendedTime} />
<Select type="single" value={recommendedTime} onValueChange={(v) => (recommendedTime = v)}>
<SelectTrigger>{recommendedTime ? recommendedTime.toUpperCase() : 'AM / PM / Both'}</SelectTrigger>
<SelectContent>
<SelectItem value="am">AM</SelectItem>
<SelectItem value="pm">PM</SelectItem>
<SelectItem value="both">Both</SelectItem>
</SelectContent>
</Select>
</div>
<div class="space-y-2">
<Label>Leave-on?</Label>
<input type="hidden" name="leave_on" value={leaveOn} />
<Select type="single" value={leaveOn} onValueChange={(v) => (leaveOn = v)}>
<SelectTrigger>{leaveOn === 'true' ? 'Yes (leave-on)' : 'No (rinse-off)'}</SelectTrigger>
<SelectContent>
<SelectItem value="true">Yes (leave-on)</SelectItem>
<SelectItem value="false">No (rinse-off)</SelectItem>
</SelectContent>
</Select>
</div>
<div class="space-y-2">
<Label for="targets">Target concerns (comma-separated)</Label>
<Input
id="targets"
name="targets"
placeholder={skinConcerns.slice(0, 3).join(', ')}
/>
</div>
<div class="flex gap-3 pt-2">
<Button type="submit">Create product</Button>
<Button variant="outline" href="/products">Cancel</Button>
</div>
</form>
</CardContent>
</Card>
</div>

View file

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

View file

@ -0,0 +1,84 @@
<script lang="ts">
import type { PageData } from './$types';
import { Badge } from '$lib/components/ui/badge';
import { Button } from '$lib/components/ui/button';
import { goto } from '$app/navigation';
let { data }: { data: PageData } = $props();
function filterPod(pod: string) {
goto(pod ? `/routines?part_of_day=${pod}` : '/routines');
}
// Group by date
const byDate = $derived(
data.routines.reduce(
(acc, r) => {
(acc[r.routine_date] ??= []).push(r);
return acc;
},
{} as Record<string, typeof data.routines>
)
);
const sortedDates = $derived(Object.keys(byDate).sort((a, b) => b.localeCompare(a)));
</script>
<svelte:head><title>Routines — innercontext</title></svelte:head>
<div class="space-y-6">
<div class="flex items-center justify-between">
<div>
<h2 class="text-2xl font-bold tracking-tight">Routines</h2>
<p class="text-muted-foreground">{data.routines.length} routines (last 30 days)</p>
</div>
<Button href="/routines/new">+ New routine</Button>
</div>
<div class="flex gap-2">
<Button
variant={!data.part_of_day ? 'default' : 'outline'}
size="sm"
onclick={() => filterPod('')}
>All</Button>
<Button
variant={data.part_of_day === 'am' ? 'default' : 'outline'}
size="sm"
onclick={() => filterPod('am')}
>AM</Button>
<Button
variant={data.part_of_day === 'pm' ? 'default' : 'outline'}
size="sm"
onclick={() => filterPod('pm')}
>PM</Button>
</div>
{#if sortedDates.length}
<div class="space-y-4">
{#each sortedDates as date}
<div>
<h3 class="text-sm font-semibold text-muted-foreground mb-2">{date}</h3>
<div class="space-y-1">
{#each byDate[date] as routine}
<a
href="/routines/{routine.id}"
class="flex items-center justify-between rounded-md border border-border px-4 py-3 hover:bg-muted/50 transition-colors"
>
<div class="flex items-center gap-3">
<Badge variant={routine.part_of_day === 'am' ? 'default' : 'secondary'}>
{routine.part_of_day.toUpperCase()}
</Badge>
<span class="text-sm">{routine.steps.length} steps</span>
</div>
{#if routine.notes}
<span class="text-sm text-muted-foreground truncate max-w-xs">{routine.notes}</span>
{/if}
</a>
{/each}
</div>
</div>
{/each}
</div>
{:else}
<p class="text-sm text-muted-foreground">No routines found.</p>
{/if}
</div>

View file

@ -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<string, unknown> = { 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');
}
};

View file

@ -0,0 +1,131 @@
<script lang="ts">
import { enhance } from '$app/forms';
import type { ActionData, PageData } from './$types';
import { Badge } from '$lib/components/ui/badge';
import { Button } from '$lib/components/ui/button';
import { Card, CardContent, CardHeader, CardTitle } from '$lib/components/ui/card';
import { Input } from '$lib/components/ui/input';
import { Label } from '$lib/components/ui/label';
import { Select, SelectContent, SelectItem, SelectTrigger } from '$lib/components/ui/select';
import { Separator } from '$lib/components/ui/separator';
let { data, form }: { data: PageData; form: ActionData } = $props();
let { routine, products } = $derived(data);
let showStepForm = $state(false);
let selectedProductId = $state('');
const nextOrderIndex = $derived(
routine.steps.length ? Math.max(...routine.steps.map((s) => s.order_index)) + 1 : 0
);
</script>
<svelte:head><title>Routine {routine.routine_date} {routine.part_of_day.toUpperCase()} — innercontext</title></svelte:head>
<div class="max-w-2xl space-y-6">
<div class="flex items-center gap-4">
<a href="/routines" class="text-sm text-muted-foreground hover:underline">← Routines</a>
<h2 class="text-2xl font-bold tracking-tight">{routine.routine_date}</h2>
<Badge variant={routine.part_of_day === 'am' ? 'default' : 'secondary'}>
{routine.part_of_day.toUpperCase()}
</Badge>
</div>
{#if form?.error}
<div class="rounded-md bg-destructive/10 px-4 py-3 text-sm text-destructive">{form.error}</div>
{/if}
{#if routine.notes}
<p class="text-sm text-muted-foreground">{routine.notes}</p>
{/if}
<!-- Steps -->
<div class="space-y-3">
<div class="flex items-center justify-between">
<h3 class="text-lg font-semibold">Steps ({routine.steps.length})</h3>
<Button variant="outline" size="sm" onclick={() => (showStepForm = !showStepForm)}>
{showStepForm ? 'Cancel' : '+ Add step'}
</Button>
</div>
{#if showStepForm}
<Card>
<CardHeader><CardTitle class="text-base">Add step</CardTitle></CardHeader>
<CardContent>
<form method="POST" action="?/addStep" use:enhance class="space-y-4">
<div class="space-y-1">
<Label>Product</Label>
<input type="hidden" name="product_id" value={selectedProductId} />
<Select type="single" value={selectedProductId} onValueChange={(v) => (selectedProductId = v)}>
<SelectTrigger>
{#if selectedProductId}
{products.find((p) => p.id === selectedProductId)?.name ?? 'Select product'}
{:else}
Select product
{/if}
</SelectTrigger>
<SelectContent>
{#each products as p}
<SelectItem value={p.id}>{p.name} ({p.brand})</SelectItem>
{/each}
</SelectContent>
</Select>
</div>
<input type="hidden" name="order_index" value={nextOrderIndex} />
<div class="grid grid-cols-2 gap-3">
<div class="space-y-1">
<Label for="dose">Dose</Label>
<Input id="dose" name="dose" placeholder="e.g. 2 pumps" />
</div>
<div class="space-y-1">
<Label for="region">Region</Label>
<Input id="region" name="region" placeholder="e.g. face" />
</div>
</div>
<Button type="submit" size="sm">Add step</Button>
</form>
</CardContent>
</Card>
{/if}
{#if routine.steps.length}
<div class="space-y-2">
{#each routine.steps.toSorted((a, b) => a.order_index - b.order_index) as step}
<div class="flex items-center justify-between rounded-md border border-border px-4 py-3">
<div class="flex items-center gap-3">
<span class="text-xs text-muted-foreground w-4">{step.order_index + 1}</span>
<div>
{#if step.product}
<p class="text-sm font-medium">{step.product.name}</p>
<p class="text-xs text-muted-foreground">{step.product.brand}</p>
{:else if step.action_type}
<p class="text-sm font-medium">{step.action_type.replace(/_/g, ' ')}</p>
{:else}
<p class="text-sm text-muted-foreground">Unknown step</p>
{/if}
</div>
{#if step.dose}
<span class="text-xs text-muted-foreground">{step.dose}</span>
{/if}
</div>
<form method="POST" action="?/removeStep" use:enhance>
<input type="hidden" name="step_id" value={step.id} />
<Button type="submit" variant="ghost" size="sm" class="text-destructive hover:text-destructive">
×
</Button>
</form>
</div>
{/each}
</div>
{:else}
<p class="text-sm text-muted-foreground">No steps yet.</p>
{/if}
</div>
<Separator />
<form method="POST" action="?/delete" use:enhance
onsubmit={(e) => { if (!confirm('Delete this routine?')) e.preventDefault(); }}>
<Button type="submit" variant="destructive" size="sm">Delete routine</Button>
</form>
</div>

View file

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

View file

@ -0,0 +1,59 @@
<script lang="ts">
import { enhance } from '$app/forms';
import type { ActionData, PageData } from './$types';
import { Button } from '$lib/components/ui/button';
import { Input } from '$lib/components/ui/input';
import { Label } from '$lib/components/ui/label';
import { Select, SelectContent, SelectItem, SelectTrigger } from '$lib/components/ui/select';
import { Card, CardContent, CardHeader, CardTitle } from '$lib/components/ui/card';
let { data, form }: { data: PageData; form: ActionData } = $props();
let partOfDay = $state('am');
</script>
<svelte:head><title>New Routine — innercontext</title></svelte:head>
<div class="max-w-md space-y-6">
<div class="flex items-center gap-4">
<a href="/routines" class="text-sm text-muted-foreground hover:underline">← Routines</a>
<h2 class="text-2xl font-bold tracking-tight">New Routine</h2>
</div>
{#if form?.error}
<div class="rounded-md bg-destructive/10 px-4 py-3 text-sm text-destructive">{form.error}</div>
{/if}
<Card>
<CardHeader><CardTitle>Routine details</CardTitle></CardHeader>
<CardContent>
<form method="POST" use:enhance class="space-y-5">
<div class="space-y-2">
<Label for="routine_date">Date *</Label>
<Input id="routine_date" name="routine_date" type="date" value={data.today} required />
</div>
<div class="space-y-2">
<Label>AM or PM *</Label>
<input type="hidden" name="part_of_day" value={partOfDay} />
<Select type="single" value={partOfDay} onValueChange={(v) => (partOfDay = v)}>
<SelectTrigger>{partOfDay.toUpperCase()}</SelectTrigger>
<SelectContent>
<SelectItem value="am">AM</SelectItem>
<SelectItem value="pm">PM</SelectItem>
</SelectContent>
</Select>
</div>
<div class="space-y-2">
<Label for="notes">Notes</Label>
<Input id="notes" name="notes" placeholder="Optional notes" />
</div>
<div class="flex gap-3 pt-2">
<Button type="submit">Create routine</Button>
<Button variant="outline" href="/routines">Cancel</Button>
</div>
</form>
</CardContent>
</Card>
</div>

View file

@ -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<string, unknown> = { 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 });
}
}
};

View file

@ -0,0 +1,177 @@
<script lang="ts">
import { enhance } from '$app/forms';
import type { ActionData, PageData } from './$types';
import { Badge } from '$lib/components/ui/badge';
import { Button } from '$lib/components/ui/button';
import { Card, CardContent, CardHeader, CardTitle } from '$lib/components/ui/card';
import { Input } from '$lib/components/ui/input';
import { Label } from '$lib/components/ui/label';
import { Select, SelectContent, SelectItem, SelectTrigger } from '$lib/components/ui/select';
let { data, form }: { data: PageData; form: ActionData } = $props();
const states = ['excellent', 'good', 'fair', 'poor'];
const trends = ['improving', 'stable', 'worsening', 'fluctuating'];
const barrierStates = ['intact', 'mildly_compromised', 'compromised'];
const stateColors: Record<string, string> = {
excellent: 'bg-green-100 text-green-800',
good: 'bg-blue-100 text-blue-800',
fair: 'bg-yellow-100 text-yellow-800',
poor: 'bg-red-100 text-red-800'
};
let showForm = $state(false);
let overallState = $state('');
let trend = $state('');
let barrierState = $state('');
const sortedSnapshots = $derived(
[...data.snapshots].sort((a, b) => b.snapshot_date.localeCompare(a.snapshot_date))
);
</script>
<svelte:head><title>Skin — innercontext</title></svelte:head>
<div class="space-y-6">
<div class="flex items-center justify-between">
<div>
<h2 class="text-2xl font-bold tracking-tight">Skin Snapshots</h2>
<p class="text-muted-foreground">{data.snapshots.length} snapshots</p>
</div>
<Button variant="outline" onclick={() => (showForm = !showForm)}>
{showForm ? 'Cancel' : '+ Add snapshot'}
</Button>
</div>
{#if form?.error}
<div class="rounded-md bg-destructive/10 px-4 py-3 text-sm text-destructive">{form.error}</div>
{/if}
{#if form?.created}
<div class="rounded-md bg-green-50 px-4 py-3 text-sm text-green-700">Snapshot added.</div>
{/if}
{#if showForm}
<Card>
<CardHeader><CardTitle>New skin snapshot</CardTitle></CardHeader>
<CardContent>
<form method="POST" action="?/create" use:enhance class="grid grid-cols-2 gap-4">
<div class="space-y-1">
<Label for="snapshot_date">Date *</Label>
<Input id="snapshot_date" name="snapshot_date" type="date"
value={new Date().toISOString().slice(0, 10)} required />
</div>
<div class="space-y-1">
<Label>Overall state</Label>
<input type="hidden" name="overall_state" value={overallState} />
<Select type="single" value={overallState} onValueChange={(v) => (overallState = v)}>
<SelectTrigger>{overallState || 'Select'}</SelectTrigger>
<SelectContent>
{#each states as s}
<SelectItem value={s}>{s}</SelectItem>
{/each}
</SelectContent>
</Select>
</div>
<div class="space-y-1">
<Label>Trend</Label>
<input type="hidden" name="trend" value={trend} />
<Select type="single" value={trend} onValueChange={(v) => (trend = v)}>
<SelectTrigger>{trend || 'Select'}</SelectTrigger>
<SelectContent>
{#each trends as t}
<SelectItem value={t}>{t}</SelectItem>
{/each}
</SelectContent>
</Select>
</div>
<div class="space-y-1">
<Label>Barrier state</Label>
<input type="hidden" name="barrier_state" value={barrierState} />
<Select type="single" value={barrierState} onValueChange={(v) => (barrierState = v)}>
<SelectTrigger>{barrierState ? barrierState.replace(/_/g, ' ') : 'Select'}</SelectTrigger>
<SelectContent>
{#each barrierStates as b}
<SelectItem value={b}>{b.replace(/_/g, ' ')}</SelectItem>
{/each}
</SelectContent>
</Select>
</div>
<div class="space-y-1">
<Label for="hydration_level">Hydration (15)</Label>
<Input id="hydration_level" name="hydration_level" type="number" min="1" max="5" />
</div>
<div class="space-y-1">
<Label for="sensitivity_level">Sensitivity (15)</Label>
<Input id="sensitivity_level" name="sensitivity_level" type="number" min="1" max="5" />
</div>
<div class="space-y-1 col-span-2">
<Label for="active_concerns">Active concerns (comma-separated)</Label>
<Input id="active_concerns" name="active_concerns" placeholder="acne, redness, dehydration" />
</div>
<div class="space-y-1 col-span-2">
<Label for="notes">Notes</Label>
<Input id="notes" name="notes" />
</div>
<div class="col-span-2">
<Button type="submit">Add snapshot</Button>
</div>
</form>
</CardContent>
</Card>
{/if}
<div class="space-y-4">
{#each sortedSnapshots as snap}
<Card>
<CardContent class="pt-4">
<div class="flex items-center justify-between mb-3">
<span class="font-medium">{snap.snapshot_date}</span>
<div class="flex gap-2">
{#if snap.overall_state}
<span class="rounded-full px-2 py-0.5 text-xs font-medium {stateColors[snap.overall_state] ?? ''}">
{snap.overall_state}
</span>
{/if}
{#if snap.trend}
<Badge variant="secondary">{snap.trend}</Badge>
{/if}
</div>
</div>
<div class="grid grid-cols-3 gap-3 text-sm mb-3">
{#if snap.hydration_level != null}
<div>
<p class="text-xs text-muted-foreground">Hydration</p>
<p class="font-medium">{snap.hydration_level}/5</p>
</div>
{/if}
{#if snap.sensitivity_level != null}
<div>
<p class="text-xs text-muted-foreground">Sensitivity</p>
<p class="font-medium">{snap.sensitivity_level}/5</p>
</div>
{/if}
{#if snap.barrier_state}
<div>
<p class="text-xs text-muted-foreground">Barrier</p>
<p class="font-medium">{snap.barrier_state.replace(/_/g, ' ')}</p>
</div>
{/if}
</div>
{#if snap.active_concerns.length}
<div class="flex flex-wrap gap-1">
{#each snap.active_concerns as c}
<Badge variant="secondary" class="text-xs">{c.replace(/_/g, ' ')}</Badge>
{/each}
</div>
{/if}
{#if snap.notes}
<p class="mt-2 text-sm text-muted-foreground">{snap.notes}</p>
{/if}
</CardContent>
</Card>
{:else}
<p class="text-sm text-muted-foreground">No skin snapshots yet.</p>
{/each}
</div>
</div>