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

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>