diff --git a/backend/src/index.ts b/backend/src/index.ts index 66bae95..b7ee121 100644 --- a/backend/src/index.ts +++ b/backend/src/index.ts @@ -8,6 +8,7 @@ import jwt from "@fastify/jwt"; import { env } from "./plugins/env.js"; import { healthRoutes } from "./routes/health.js"; import { authRoutes } from "./routes/auth.js"; +import { medicationRoutes } from "./routes/medications.js"; const app = Fastify({ logger: { @@ -54,6 +55,7 @@ await app.register(jwt, { secret: env.JWT_SECRET, cookie: { cookieName: "access_ await app.register(healthRoutes); await app.register(authRoutes); +await app.register(medicationRoutes); const start = async () => { try { diff --git a/backend/src/routes/medications.ts b/backend/src/routes/medications.ts new file mode 100644 index 0000000..b31012e --- /dev/null +++ b/backend/src/routes/medications.ts @@ -0,0 +1,105 @@ +import { FastifyInstance } from "fastify"; +import { z } from "zod"; +import { db } from "../db/client.js"; +import { medications } from "../db/schema.js"; +import { eq } from "drizzle-orm"; + +const sliceSchema = z.object({ + usage: z.number().nonnegative(), + every: z.number().int().min(1), + start: z.string().datetime(), +}); + +const medicationSchema = z.object({ + name: z.string().trim().min(1).max(100), + count: z.number().int().min(0), + stripSize: z.number().int().min(1), + slices: z.array(sliceSchema).min(1).max(12), +}); + +function zipSlices(usage: number[], every: number[], start: string[]) { + const len = Math.min(usage.length, every.length, start.length); + const slices: Array<{ usage: number; every: number; start: string }> = []; + for (let i = 0; i < len; i++) { + slices.push({ usage: usage[i], every: every[i], start: start[i] }); + } + return slices; +} + +function parseSlices(row: typeof medications.$inferSelect) { + try { + const usage = JSON.parse(row.usageJson) as number[]; + const every = JSON.parse(row.everyJson) as number[]; + const start = JSON.parse(row.startJson) as string[]; + return zipSlices(usage, every, start); + } catch (err) { + return []; + } +} + +export async function medicationRoutes(app: FastifyInstance) { + app.get("/medications", async () => { + const rows = await db.select().from(medications).orderBy(medications.id); + return rows.map((row) => ({ + id: row.id, + name: row.name, + count: row.count, + stripSize: row.stripSize, + slices: parseSlices(row), + updatedAt: row.updatedAt, + })); + }); + + app.post("/medications", async (req, reply) => { + const parsed = medicationSchema.safeParse(req.body); + if (!parsed.success) return reply.status(400).send(parsed.error.format()); + + const { name, count, stripSize, slices } = parsed.data; + const usageJson = JSON.stringify(slices.map((s) => s.usage)); + const everyJson = JSON.stringify(slices.map((s) => s.every)); + const startJson = JSON.stringify(slices.map((s) => s.start)); + + const [inserted] = await db + .insert(medications) + .values({ name, count, stripSize, usageJson, everyJson, startJson }) + .returning(); + + return { + id: inserted.id, + name: inserted.name, + count: inserted.count, + stripSize: inserted.stripSize, + slices, + updatedAt: inserted.updatedAt, + }; + }); + + app.put<{ Params: { id: string } }>("/medications/:id", async (req, reply) => { + const parsed = medicationSchema.safeParse(req.body); + if (!parsed.success) return reply.status(400).send(parsed.error.format()); + const idNum = Number(req.params.id); + if (Number.isNaN(idNum)) return reply.badRequest("Invalid id"); + + const { name, count, stripSize, slices } = parsed.data; + const usageJson = JSON.stringify(slices.map((s) => s.usage)); + const everyJson = JSON.stringify(slices.map((s) => s.every)); + const startJson = JSON.stringify(slices.map((s) => s.start)); + + const result = await db + .update(medications) + .set({ name, count, stripSize, usageJson, everyJson, startJson, updatedAt: new Date() }) + .where(eq(medications.id, idNum)) + .returning(); + + if (!result.length) return reply.notFound(); + + return { + id: result[0].id, + name: result[0].name, + count: result[0].count, + stripSize: result[0].stripSize, + slices, + updatedAt: result[0].updatedAt, + }; + }); +} \ No newline at end of file diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 25435ef..dae369b 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -3,26 +3,34 @@ import { z } from "zod"; const healthSchema = z.object({ status: z.string() }); +type Slice = { + usage: number; + every: number; + start: string; // ISO date string +}; + type Medication = { - id: string; + id: number; name: string; - dosage: string; - frequency: string; - timeOfDay: string; - startDate: string; - notes?: string; - active: boolean; + count: number; + stripSize: number; + slices: Slice[]; + updatedAt: string | number | null; }; -type DoseEvent = { - id: string; - medId: string; - medName: string; - time: Date; - notes?: string; +type FormState = { + name: string; + count: string; + stripSize: string; + slices: Array<{ usage: string; every: string; start: string }>; }; -// Simple backend health check +const defaultSlice = (): FormState["slices"][number] => ({ + usage: "1", + every: "1", + start: new Date().toISOString().slice(0, 16), +}); + function useHealth() { const [status, setStatus] = useState("loading"); useEffect(() => { @@ -38,136 +46,120 @@ function useHealth() { return status; } -const starterMeds: Medication[] = [ - { - id: "med-1", - name: "Lisinopril", - dosage: "10 mg", - frequency: "Daily", - timeOfDay: "08:00", - startDate: "2025-11-28", - notes: "Blood pressure", - active: true, - }, - { - id: "med-2", - name: "Metformin", - dosage: "500 mg", - frequency: "2x daily", - timeOfDay: "08:00", - startDate: "2025-12-01", - notes: "Before meals", - active: true, - }, - { - id: "med-3", - name: "Vitamin D", - dosage: "2000 IU", - frequency: "Weekly", - timeOfDay: "09:00", - startDate: "2025-12-05", - notes: "Sunday dose", - active: false, - }, -]; - -const frequencies = ["Daily", "2x daily", "Weekly", "As needed"] as const; - export default function App() { const status = useHealth(); - const [meds, setMeds] = useState(starterMeds); - const [form, setForm] = useState({ - name: "", - dosage: "", - frequency: "Daily", - timeOfDay: "08:00", - notes: "", - }); + const [meds, setMeds] = useState([]); + const [loading, setLoading] = useState(false); + const [saving, setSaving] = useState(false); + const [editingId, setEditingId] = useState(null); + const [form, setForm] = useState({ name: "", count: "0", stripSize: "1", slices: [defaultSlice()] }); - const activeCount = meds.filter((m) => m.active).length; + const activeCount = meds.length; - const schedule: DoseEvent[] = useMemo(() => { - const daysAhead = 3; - const events: DoseEvent[] = []; - const now = new Date(); - for (let d = 0; d <= daysAhead; d++) { - const base = new Date(now); - base.setDate(now.getDate() + d); - meds - .filter((m) => m.active) - .forEach((m) => { - if (m.frequency === "As needed") return; - const [h, min] = m.timeOfDay.split(":").map(Number); - const when = new Date(base); - when.setHours(h ?? 8, min ?? 0, 0, 0); - events.push({ - id: `${m.id}-${when.toISOString()}`, - medId: m.id, - medName: m.name, - time: when, - notes: m.notes, - }); - if (m.frequency === "2x daily") { - const evening = new Date(when); - evening.setHours(20, 0, 0, 0); - events.push({ - id: `${m.id}-${evening.toISOString()}`, - medId: m.id, - medName: m.name, - time: evening, - notes: m.notes, - }); - } - }); - } - return events.sort((a, b) => a.time.getTime() - b.time.getTime()); - }, [meds]); + const schedule = useMemo(() => buildSchedulePreview(meds), [meds]); - const handleAdd = (e: React.FormEvent) => { + useEffect(() => { + loadMeds(); + }, []); + + function loadMeds() { + setLoading(true); + fetch("/medications") + .then((res) => res.json()) + .then((data: Medication[]) => setMeds(data)) + .catch(() => setMeds([])) + .finally(() => setLoading(false)); + } + + function setSliceValue(idx: number, field: keyof FormState["slices"][number], value: string) { + setForm((prev) => { + const next = [...prev.slices]; + next[idx] = { ...next[idx], [field]: value }; + return { ...prev, slices: next }; + }); + } + + function addSlice() { + setForm((prev) => ({ ...prev, slices: [...prev.slices, defaultSlice()] })); + } + + function removeSlice(idx: number) { + setForm((prev) => ({ ...prev, slices: prev.slices.filter((_, i) => i !== idx) })); + } + + function startEdit(med: Medication) { + setEditingId(med.id); + setForm({ + name: med.name, + count: String(med.count), + stripSize: String(med.stripSize), + slices: med.slices.map((s) => ({ + usage: String(s.usage), + every: String(s.every), + start: toInputValue(s.start), + })), + }); + } + + function resetForm() { + setEditingId(null); + setForm({ name: "", count: "0", stripSize: "1", slices: [defaultSlice()] }); + } + + async function saveMedication(e: React.FormEvent) { e.preventDefault(); - if (!form.name.trim() || !form.dosage.trim()) return; - const newMed: Medication = { - id: crypto.randomUUID(), - name: form.name.trim(), - dosage: form.dosage.trim(), - frequency: form.frequency, - timeOfDay: form.timeOfDay, - startDate: new Date().toISOString().slice(0, 10), - notes: form.notes.trim() || undefined, - active: true, - }; - setMeds((prev) => [newMed, ...prev]); - setForm({ name: "", dosage: "", frequency: "Daily", timeOfDay: "08:00", notes: "" }); - }; + if (!form.name.trim()) return; + setSaving(true); - const toggleActive = (id: string) => { - setMeds((prev) => prev.map((m) => (m.id === id ? { ...m, active: !m.active } : m))); - }; + const payload = { + name: form.name.trim(), + count: Number(form.count) || 0, + stripSize: Number(form.stripSize) || 1, + slices: form.slices.map((s) => ({ + usage: Number(s.usage) || 0, + every: Math.max(1, Number(s.every) || 1), + start: toIsoString(s.start), + })), + }; + + const method = editingId ? "PUT" : "POST"; + const url = editingId ? `/medications/${editingId}` : "/medications"; + + await fetch(url, { + method, + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(payload), + }).catch(() => null); + + setSaving(false); + resetForm(); + loadMeds(); + } return (
-

Medassist · Rebuild Preview

-

Deine Medikationszentrale

-

Schneller Überblick über Medikamente, Einnahmeplan und Erinnerungen.

+

Medassist · Planner

+

Medikationspläne anlegen & bearbeiten

+

Slices wie früher: usage, every (days), start (Datum/Uhrzeit), plus Bestand & Strip-Size.

Backend: {status} - Aktive Medikamente: {activeCount} + Einträge: {meds.length}

Heute geplant

-

{schedule.filter((e) => isToday(e.time)).length}

+

{schedule.today}

-

Diese Woche

-

{schedule.length}

+

Nächste 3 Tage

+

{schedule.nextThree}

-

Ohne Login

-

Demo

+

Aktive Slices

+

{schedule.totalSlices}

@@ -176,23 +168,27 @@ export default function App() {

Medikamentenliste

- {meds.length} gesamt + {loading ? "lädt..." : `${meds.length} gesamt`}
{meds.map((med) => (
{med.name}
-
{med.dosage} · {med.frequency} · Start {med.startDate}
- {med.notes &&
{med.notes}
} +
Bestand: {med.count} · Strip-Size: {med.stripSize}
+
Slices: {med.slices.length}
+
+ {med.slices.map((s, idx) => ( +
+ {s.usage} meds + alle {s.every} Tage + ab {formatDateTime(s.start)} +
+ ))} +
- - {med.active ? "aktiv" : "pausiert"} - - +
))} @@ -201,38 +197,56 @@ export default function App() {
-

Neues Medikament

- ohne Login +

{editingId ? "Eintrag bearbeiten" : "Neuer Eintrag"}

+ Slices wie alte App
-
+ - - -
- + +
+
+

Slice / Plan

+ +
+ {form.slices.map((s, idx) => ( +
+ + + + {form.slices.length > 1 && ( + + )} +
+ ))} +
+ +
+ {editingId && ( + + )} +
@@ -241,62 +255,80 @@ export default function App() {
-

Nächste Einnahmen

- 3 Tage Vorschau +

Nächste Einnahmen (3 Tage)

+ Preview
- {schedule.slice(0, 8).map((event) => ( + {schedule.events.slice(0, 10).map((event) => (
-
{formatTime(event.time)}
+
{event.timeStr}
{event.medName}
-
{formatDate(event.time)}
- {event.notes &&
{event.notes}
} +
{event.dateStr}
+
{event.usage} meds
))}
- -
-
-

Highlights

- Demo-Daten -
-
-
-

Aktive Pläne

-

{activeCount}

-
-
-

Starttermine

-

{meds.filter((m) => m.startDate).length}

-
-
-

Backend Health

-

{status}

-
-
-

Login und Synchronisation kommen später – dies ist ein sichtbarer Preview.

-
); } -function isToday(date: Date) { +function toIsoString(value: string) { + // Accept datetime-local value; fallback to now + if (!value) return new Date().toISOString(); + const date = new Date(value); + return Number.isNaN(date.getTime()) ? new Date().toISOString() : date.toISOString(); +} + +function toInputValue(value: string) { + const date = new Date(value); + if (Number.isNaN(date.getTime())) return new Date().toISOString().slice(0, 16); + const iso = date.toISOString(); + return iso.slice(0, 16); +} + +function formatDateTime(value: string) { + const d = new Date(value); + if (Number.isNaN(d.getTime())) return value; + return d.toLocaleString([], { weekday: "short", day: "2-digit", month: "short", hour: "2-digit", minute: "2-digit" }); +} + +function buildSchedulePreview(meds: Medication[]) { + const events: Array<{ id: string; medName: string; timeStr: string; dateStr: string; usage: number; when: number }> = []; const now = new Date(); - return ( - date.getFullYear() === now.getFullYear() && - date.getMonth() === now.getMonth() && - date.getDate() === now.getDate() - ); -} + const end = new Date(); + end.setDate(end.getDate() + 3); -function formatTime(date: Date) { - return date.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" }); -} + meds.forEach((med) => { + med.slices.forEach((slice, idx) => { + const start = new Date(slice.start); + if (Number.isNaN(start.getTime())) return; + // generate occurrences within next 3 days (simplified) + for (let d = new Date(start); d <= end; d.setDate(d.getDate() + slice.every)) { + if (d < now) continue; + const whenMs = d.getTime(); + events.push({ + id: `${med.id}-${idx}-${whenMs}`, + medName: med.name, + usage: slice.usage, + when: whenMs, + timeStr: d.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" }), + dateStr: d.toLocaleDateString([], { weekday: "short", day: "2-digit", month: "short" }), + }); + } + }); + }); -function formatDate(date: Date) { - return date.toLocaleDateString([], { weekday: "short", day: "2-digit", month: "short" }); + events.sort((a, b) => a.when - b.when); + + const todayCount = events.filter((e) => { + const t = new Date(e.when); + const n = new Date(); + return t.getFullYear() === n.getFullYear() && t.getMonth() === n.getMonth() && t.getDate() === n.getDate(); + }).length; + + return { events, today: todayCount, nextThree: events.length, totalSlices: meds.reduce((acc, m) => acc + m.slices.length, 0) }; } diff --git a/frontend/src/styles.css b/frontend/src/styles.css index 1409844..0a72c79 100644 --- a/frontend/src/styles.css +++ b/frontend/src/styles.css @@ -65,6 +65,11 @@ body { .tag.subtle { background: rgba(255, 255, 255, 0.04); color: #c1cbe0; } .med-actions { display: flex; align-items: center; gap: 0.5rem; } +.slice-list { display: flex; flex-wrap: wrap; gap: 0.35rem; margin-top: 0.6rem; } +.slice-pill { display: flex; gap: 0.4rem; flex-wrap: wrap; } +.slice-row { display: grid; grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); gap: 0.65rem; background: #0d1424; border: 1px solid #1f2a3d; padding: 0.85rem; border-radius: 10px; margin-bottom: 0.5rem; } +.slices h3 { margin: 0; } +.gap { gap: 0.6rem; } button { padding: 0.65rem 1rem;