Add script to build and push Docker images to registry

- Introduced `push-images.sh` script for building and pushing backend and frontend images.
- Added functionality to select or input image tags.
- Integrated environment variable support for registry configuration.
- Implemented prompts for user confirmation before building and pushing images.
- Updated `docker-compose.prod.yml` with new image tags after pushing.
This commit is contained in:
Daniel Volz
2025-12-20 15:32:38 +01:00
parent dc1dd8c552
commit aac4079c54
15 changed files with 5116 additions and 341 deletions
+7
View File
@@ -22,3 +22,10 @@ SMTP_SECURE=false
# Planner limits
EMAILS_PER_DAY=3
# Container registry (used by scripts/push-images.sh)
REGISTRY_HOST=git.danielvolz.org
REGISTRY_TOKEN=
REGISTRY_USER= # optional; defaults to token if empty
PROJECT_PATH=daniel/medassist
# IMAGE_TAG can stay empty; override via -v flag
+2143
View File
File diff suppressed because it is too large Load Diff
+47
View File
@@ -0,0 +1,47 @@
CREATE TABLE IF NOT EXISTS users (
id integer PRIMARY KEY AUTOINCREMENT,
email text NOT NULL UNIQUE,
password_hash text NOT NULL,
role text NOT NULL DEFAULT 'user',
created_at integer NOT NULL DEFAULT (strftime('%s','now')),
updated_at integer NOT NULL DEFAULT (strftime('%s','now'))
);
CREATE TABLE IF NOT EXISTS medications (
id integer PRIMARY KEY AUTOINCREMENT,
name text NOT NULL UNIQUE,
count integer NOT NULL DEFAULT 0,
strips integer NOT NULL DEFAULT 0,
pack_count integer NOT NULL DEFAULT 1,
strips_per_pack integer NOT NULL DEFAULT 1,
tabs_per_strip integer NOT NULL DEFAULT 1,
loose_tablets integer NOT NULL DEFAULT 0,
usage_json text NOT NULL DEFAULT '[]',
every_json text NOT NULL DEFAULT '[]',
start_json text NOT NULL DEFAULT '[]',
strip_size integer NOT NULL DEFAULT 1,
updated_at integer NOT NULL DEFAULT (strftime('%s','now'))
);
CREATE TABLE IF NOT EXISTS refresh_tokens (
id integer PRIMARY KEY AUTOINCREMENT,
user_id integer NOT NULL,
token_id text NOT NULL UNIQUE,
expires_at integer NOT NULL,
rotated_at integer,
revoked integer NOT NULL DEFAULT 0,
created_at integer NOT NULL DEFAULT (strftime('%s','now')),
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
);
CREATE TABLE IF NOT EXISTS settings (
id integer PRIMARY KEY AUTOINCREMENT,
smtp_host text,
smtp_port integer,
smtp_user text,
smtp_pass_encrypted text,
smtp_from text,
smtp_secure integer NOT NULL DEFAULT 0,
emails_per_day integer NOT NULL DEFAULT 3,
updated_at integer NOT NULL DEFAULT (strftime('%s','now'))
);
@@ -0,0 +1 @@
ALTER TABLE medications ADD COLUMN strips integer NOT NULL DEFAULT 0;
@@ -0,0 +1,15 @@
ALTER TABLE medications ADD COLUMN pack_count integer NOT NULL DEFAULT 1;
ALTER TABLE medications ADD COLUMN strips_per_pack integer NOT NULL DEFAULT 1;
ALTER TABLE medications ADD COLUMN tabs_per_strip integer NOT NULL DEFAULT 1;
ALTER TABLE medications ADD COLUMN loose_tablets integer NOT NULL DEFAULT 0;
-- Backfill from previous fields where possible
UPDATE medications
SET
pack_count = COALESCE(pack_count, 1),
strips_per_pack = CASE WHEN strips IS NOT NULL THEN strips ELSE 1 END,
tabs_per_strip = CASE WHEN strip_size IS NOT NULL THEN strip_size ELSE 1 END,
loose_tablets = CASE
WHEN strip_size IS NOT NULL AND strips IS NOT NULL THEN MAX(count - (strips * strip_size), 0)
ELSE 0
END;
@@ -0,0 +1,7 @@
{
"entries": [
{ "idx": 0, "version": 1, "when": 1734633120, "tag": "0000_init", "breakpoint": false },
{ "idx": 1, "version": 1, "when": 1734633121, "tag": "0001_add_strips", "breakpoint": false },
{ "idx": 2, "version": 1, "when": 1734633122, "tag": "0002_pack_inventory", "breakpoint": false }
]
}
+5
View File
@@ -14,6 +14,11 @@ export const medications = sqliteTable("medications", {
id: integer("id").primaryKey({ autoIncrement: true }),
name: text("name", { length: 100 }).notNull().unique(),
count: integer("count").notNull().default(0),
strips: integer("strips").notNull().default(0),
packCount: integer("pack_count").notNull().default(1),
stripsPerPack: integer("strips_per_pack").notNull().default(1),
tabsPerStrip: integer("tabs_per_strip").notNull().default(1),
looseTablets: integer("loose_tablets").notNull().default(0),
usageJson: text("usage_json").notNull().default("[]"),
everyJson: text("every_json").notNull().default("[]"),
startJson: text("start_json").notNull().default("[]"),
+121 -6
View File
@@ -12,8 +12,11 @@ const sliceSchema = z.object({
const medicationSchema = z.object({
name: z.string().trim().min(1).max(100),
count: z.number().int().min(0),
stripSize: z.number().int().min(1),
packCount: z.number().int().min(0).default(1),
stripsPerPack: z.number().int().min(1).default(1),
tabsPerStrip: z.number().int().min(1).default(1),
looseTablets: z.number().int().min(0).default(0),
// count will be derived on the backend
slices: z.array(sliceSchema).min(1).max(12),
});
@@ -44,7 +47,12 @@ export async function medicationRoutes(app: FastifyInstance) {
id: row.id,
name: row.name,
count: row.count,
strips: row.strips,
stripSize: row.stripSize,
packCount: row.packCount ?? 1,
stripsPerPack: row.stripsPerPack ?? row.strips ?? 1,
tabsPerStrip: row.tabsPerStrip ?? row.stripSize ?? 1,
looseTablets: row.looseTablets ?? 0,
slices: parseSlices(row),
updatedAt: row.updatedAt,
}));
@@ -54,21 +62,40 @@ export async function medicationRoutes(app: FastifyInstance) {
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 { name, packCount, stripsPerPack, tabsPerStrip, looseTablets, 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 derivedCount = deriveTotalTablets(packCount, stripsPerPack, tabsPerStrip, looseTablets);
const [inserted] = await db
.insert(medications)
.values({ name, count, stripSize, usageJson, everyJson, startJson })
.values({
name,
count: derivedCount,
strips: stripsPerPack,
stripSize: tabsPerStrip,
packCount,
stripsPerPack,
tabsPerStrip,
looseTablets,
usageJson,
everyJson,
startJson,
})
.returning();
return {
id: inserted.id,
name: inserted.name,
count: inserted.count,
strips: inserted.strips,
stripSize: inserted.stripSize,
packCount: inserted.packCount,
stripsPerPack: inserted.stripsPerPack,
tabsPerStrip: inserted.tabsPerStrip,
looseTablets: inserted.looseTablets,
slices,
updatedAt: inserted.updatedAt,
};
@@ -80,14 +107,29 @@ export async function medicationRoutes(app: FastifyInstance) {
const idNum = Number(req.params.id);
if (Number.isNaN(idNum)) return reply.badRequest("Invalid id");
const { name, count, stripSize, slices } = parsed.data;
const { name, packCount, stripsPerPack, tabsPerStrip, looseTablets, 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 derivedCount = deriveTotalTablets(packCount, stripsPerPack, tabsPerStrip, looseTablets);
const result = await db
.update(medications)
.set({ name, count, stripSize, usageJson, everyJson, startJson, updatedAt: new Date() })
.set({
name,
count: derivedCount,
strips: stripsPerPack,
stripSize: tabsPerStrip,
packCount,
stripsPerPack,
tabsPerStrip,
looseTablets,
usageJson,
everyJson,
startJson,
updatedAt: new Date(),
})
.where(eq(medications.id, idNum))
.returning();
@@ -97,9 +139,82 @@ export async function medicationRoutes(app: FastifyInstance) {
id: result[0].id,
name: result[0].name,
count: result[0].count,
strips: result[0].strips,
stripSize: result[0].stripSize,
packCount: result[0].packCount,
stripsPerPack: result[0].stripsPerPack,
tabsPerStrip: result[0].tabsPerStrip,
looseTablets: result[0].looseTablets,
slices,
updatedAt: result[0].updatedAt,
};
});
app.delete<{ Params: { id: string } }>("/medications/:id", async (req, reply) => {
const idNum = Number(req.params.id);
if (Number.isNaN(idNum)) return reply.badRequest("Invalid id");
const deleted = await db.delete(medications).where(eq(medications.id, idNum)).returning();
if (!deleted.length) return reply.notFound();
return reply.status(204).send();
});
app.post("/medications/usage", async (req, reply) => {
const schema = z.object({ startDate: z.string().datetime(), endDate: z.string().datetime() });
const parsed = schema.safeParse(req.body);
if (!parsed.success) return reply.status(400).send(parsed.error.format());
const { startDate, endDate } = parsed.data;
const start = new Date(startDate);
const end = new Date(endDate);
if (Number.isNaN(start.getTime()) || Number.isNaN(end.getTime()) || end <= start) {
return reply.badRequest("Invalid date range");
}
const rows = await db.select().from(medications).orderBy(medications.id);
const payload = rows.map((row) => {
const slices = parseSlices(row);
const usageTotal = calculateUsageInRange(slices, start, end);
const tabsPerStrip = row.tabsPerStrip ?? row.stripSize ?? 1;
const packCount = row.packCount ?? 1;
const stripsPerPack = row.stripsPerPack ?? row.strips ?? 1;
const looseTablets = row.looseTablets ?? 0;
const stripsNeeded = tabsPerStrip > 0 ? Math.ceil(usageTotal / tabsPerStrip) : 0;
const stripsAvailable = packCount * stripsPerPack + (tabsPerStrip > 0 ? looseTablets / tabsPerStrip : 0);
const enough = stripsAvailable >= stripsNeeded;
return {
medicationId: row.id,
medicationName: row.name,
plannerUsage: usageTotal,
stripSize: tabsPerStrip,
stripsNeeded,
stripsAvailable,
enough,
};
});
return payload;
});
}
function calculateUsageInRange(slices: Array<{ usage: number; every: number; start: string }>, start: Date, end: Date) {
let total = 0;
slices.forEach((slice) => {
const sliceStart = new Date(slice.start);
if (Number.isNaN(sliceStart.getTime())) return;
// iterate occurrences from sliceStart up to end
for (let dt = new Date(sliceStart); dt < end; dt.setDate(dt.getDate() + slice.every)) {
if (dt >= start && dt < end) total += slice.usage;
}
});
return Number(total.toFixed(2));
}
function deriveTotalTablets(packCount: number, stripsPerPack: number, tabsPerStrip: number, looseTablets: number) {
const packs = packCount || 0;
const strips = stripsPerPack || 0;
const tabs = tabsPerStrip || 1;
const loose = looseTablets || 0;
const packed = packs * strips * tabs;
return packed + loose;
}
+22
View File
@@ -0,0 +1,22 @@
services:
backend:
image: git.danielvolz.org/daniel/medassist/backend:0.0.1
env_file:
- .env
volumes:
- ./backend/data:/app/data
ports:
- "4000:3000"
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:3000/health"]
interval: 30s
timeout: 5s
retries: 3
start_period: 10s
frontend:
image: git.danielvolz.org/daniel/medassist/frontend:0.0.1
ports:
- "4174:80"
depends_on:
- backend
+21 -18
View File
@@ -1,26 +1,29 @@
services:
backend:
image: medassist-backend:latest
build:
context: ./backend
backend-dev:
image: node:25-slim
working_dir: /app
command: sh -c "npm install && npm run dev"
volumes:
- ./backend:/app
- backend_node_modules:/app/node_modules
- ./backend/data:/app/data
env_file:
- .env
volumes:
- ./backend/data:/app/data
ports:
- "3000:3000"
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:3000/health"]
interval: 30s
timeout: 5s
retries: 3
start_period: 10s
frontend:
image: medassist-frontend:latest
build:
context: ./frontend
frontend-dev:
image: node:25-slim
working_dir: /app
command: sh -c "npm install && npm run dev -- --host --port 5173"
volumes:
- ./frontend:/app
- frontend_node_modules:/app/node_modules
ports:
- "4173:80"
- "5173:5173"
depends_on:
- backend
- backend-dev
volumes:
backend_node_modules:
frontend_node_modules:
+1814
View File
File diff suppressed because it is too large Load Diff
+510 -283
View File
@@ -1,334 +1,561 @@
import { useEffect, useMemo, useState } from "react";
import { z } from "zod";
const healthSchema = z.object({ status: z.string() });
type Slice = {
usage: number;
every: number;
start: string; // ISO date string
usage: number;
every: number;
start: string;
};
type Medication = {
id: number;
name: string;
count: number;
stripSize: number;
slices: Slice[];
updatedAt: string | number | null;
id: number;
name: string;
count: number;
strips: number;
stripSize: number;
packCount?: number;
stripsPerPack?: number;
tabsPerStrip?: number;
looseTablets?: number;
slices: Slice[];
updatedAt: string | number | null;
};
type PlannerRow = {
medicationId: number;
medicationName: string;
plannerUsage: number;
stripSize: number;
stripsNeeded: number;
stripsAvailable: number;
enough: boolean;
};
type FormSlice = { usage: string; every: string; start: string };
type FormState = {
name: string;
count: string;
stripSize: string;
slices: Array<{ usage: string; every: string; start: string }>;
name: string;
packCount: string;
stripsPerPack: string;
tabsPerStrip: string;
looseTablets: string;
slices: FormSlice[];
};
const defaultSlice = (): FormState["slices"][number] => ({
usage: "1",
every: "1",
start: new Date().toISOString().slice(0, 16),
});
const defaultSlice = (): FormSlice => ({ usage: "1", every: "1", start: toInputValue(new Date().toISOString()) });
function useHealth() {
const [status, setStatus] = useState<string>("loading");
useEffect(() => {
fetch("/api/health", { credentials: "include" })
.then((res) => res.json())
.then((data) => {
const parsed = healthSchema.safeParse(data);
if (parsed.success) setStatus(parsed.data.status);
else setStatus("error");
})
.catch(() => setStatus("error"));
}, []);
return status;
}
const defaultForm = (): FormState => ({ name: "", packCount: "1", stripsPerPack: "1", tabsPerStrip: "1", looseTablets: "0", slices: [defaultSlice()] });
const todayIso = () => new Date().toISOString();
const plusDaysIso = (days: number) => {
const d = new Date();
d.setDate(d.getDate() + days);
return d.toISOString();
};
type Coverage = {
name: string;
medsLeft: number;
daysLeft: number | null;
depletionDate: string | null;
depletionTime: number | null;
nextDose: string | null;
};
export default function App() {
const status = useHealth();
const [meds, setMeds] = useState<Medication[]>([]);
const [loading, setLoading] = useState(false);
const [saving, setSaving] = useState(false);
const [editingId, setEditingId] = useState<number | null>(null);
const [form, setForm] = useState<FormState>({ name: "", count: "0", stripSize: "1", slices: [defaultSlice()] });
const [meds, setMeds] = useState<Medication[]>([]);
const [plannerRows, setPlannerRows] = useState<PlannerRow[]>([]);
const [plannerLoading, setPlannerLoading] = useState(false);
const [loading, setLoading] = useState(false);
const [saving, setSaving] = useState(false);
const [editingId, setEditingId] = useState<number | null>(null);
const [form, setForm] = useState<FormState>(defaultForm());
const [range, setRange] = useState<{ start: string; end: string }>({ start: toInputValue(todayIso()), end: toInputValue(plusDaysIso(3)) });
const [view, setView] = useState<"dashboard" | "medications" | "planner">("dashboard");
const activeCount = meds.length;
const schedule = useMemo(() => buildSchedulePreview(meds), [meds]);
const totalTablets = useMemo(() => deriveTotal(form), [form]);
const coverage = useMemo(() => calculateCoverage(meds, schedule.events), [meds, schedule.events]);
const depletionByMed = useMemo(() => Object.fromEntries(coverage.all.map((c) => [c.name, c.depletionTime])), [coverage.all]);
const groupedSchedule = useMemo(() => {
const days = new Map<string, { dateStr: string; meds: Map<string, { medName: string; total: number; times: string[]; lastWhen: number }> }>();
schedule.events.slice(0, 30).forEach((event) => {
const day = days.get(event.dateStr) ?? { dateStr: event.dateStr, meds: new Map() };
const medEntry = day.meds.get(event.medName) ?? { medName: event.medName, total: 0, times: [], lastWhen: event.when };
medEntry.total += event.usage;
medEntry.times.push(event.timeStr);
medEntry.lastWhen = Math.max(medEntry.lastWhen, event.when);
day.meds.set(event.medName, medEntry);
days.set(event.dateStr, day);
});
return Array.from(days.values()).map((d) => ({ dateStr: d.dateStr, meds: Array.from(d.meds.values()) }));
}, [schedule.events]);
const schedule = useMemo(() => buildSchedulePreview(meds), [meds]);
useEffect(() => {
loadMeds();
}, []);
useEffect(() => {
loadMeds();
}, []);
function loadMeds() {
setLoading(true);
fetch("/api/medications")
.then((res) => res.json())
.then((data: Medication[]) => setMeds(data))
.catch(() => setMeds([]))
.finally(() => setLoading(false));
}
function loadMeds() {
setLoading(true);
fetch("/medications")
.then((res) => res.json())
.then((data: Medication[]) => setMeds(data))
.catch(() => setMeds([]))
.finally(() => setLoading(false));
}
async function deleteMed(id: number) {
await fetch(`/api/medications/${id}`, { method: "DELETE" }).catch(() => null);
if (editingId === id) resetForm();
loadMeds();
}
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 setSliceValue(idx: number, field: keyof FormSlice, 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 addSlice() {
setForm((prev) => ({ ...prev, slices: [...prev.slices, defaultSlice()] }));
}
function removeSlice(idx: number) {
setForm((prev) => ({ ...prev, slices: prev.slices.filter((_, i) => i !== idx) }));
}
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 startEdit(med: Medication) {
setEditingId(med.id);
setForm({
name: med.name,
packCount: String(med.packCount ?? 1),
stripsPerPack: String(med.stripsPerPack ?? med.strips ?? 1),
tabsPerStrip: String(med.tabsPerStrip ?? med.stripSize ?? 1),
looseTablets: String(med.looseTablets ?? 0),
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()] });
}
function resetForm() {
setEditingId(null);
setForm(defaultForm());
}
async function saveMedication(e: React.FormEvent) {
e.preventDefault();
if (!form.name.trim()) return;
setSaving(true);
function handleValueChange<K extends keyof FormState>(key: K, value: string) {
setForm((prev) => ({ ...prev, [key]: value }));
}
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),
})),
};
async function saveMedication(e: React.FormEvent) {
e.preventDefault();
if (!form.name.trim()) return;
setSaving(true);
const method = editingId ? "PUT" : "POST";
const url = editingId ? `/medications/${editingId}` : "/medications";
const payload = {
name: form.name.trim(),
packCount: Number(form.packCount) || 0,
stripsPerPack: Math.max(1, Number(form.stripsPerPack) || 1),
tabsPerStrip: Math.max(1, Number(form.tabsPerStrip) || 1),
looseTablets: Math.max(0, Number(form.looseTablets) || 0),
slices: form.slices.map((s) => ({ usage: Number(s.usage) || 0, every: Math.max(1, Number(s.every) || 1), start: toIsoString(s.start) })),
};
await fetch(url, {
method,
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
}).catch(() => null);
const method = editingId ? "PUT" : "POST";
const url = editingId ? `/api/medications/${editingId}` : "/api/medications";
setSaving(false);
resetForm();
loadMeds();
}
await fetch(url, { method, headers: { "Content-Type": "application/json" }, body: JSON.stringify(payload) }).catch(() => null);
return (
<main className="page">
<header className="hero">
<div>
<p className="eyebrow">Medassist · Planner</p>
<h1>Medikationspläne anlegen & bearbeiten</h1>
<p className="sub">Slices wie früher: usage, every (days), start (Datum/Uhrzeit), plus Bestand & Strip-Size.</p>
<div className="badges">
<span className="pill success">Backend: {status}</span>
<span className="pill">Einträge: {meds.length}</span>
</div>
</div>
<div className="stats">
<div className="stat">
<p className="label">Heute geplant</p>
<p className="value">{schedule.today}</p>
</div>
<div className="stat">
<p className="label">Nächste 3 Tage</p>
<p className="value">{schedule.nextThree}</p>
</div>
<div className="stat">
<p className="label">Aktive Slices</p>
<p className="value">{schedule.totalSlices}</p>
</div>
</div>
</header>
setSaving(false);
resetForm();
loadMeds();
}
<section className="grid">
<article className="card meds">
<div className="card-head">
<h2>Medikamentenliste</h2>
<span className="pill">{loading ? "lädt..." : `${meds.length} gesamt`}</span>
</div>
<div className="med-list">
{meds.map((med) => (
<div key={med.id} className="med-row">
<div>
<div className="med-name">{med.name}</div>
<div className="muted">Bestand: {med.count} · Strip-Size: {med.stripSize}</div>
<div className="tag subtle">Slices: {med.slices.length}</div>
<div className="slice-list">
{med.slices.map((s, idx) => (
<div key={`${med.id}-${idx}`} className="slice-pill">
<span className="pill">{s.usage} meds</span>
<span className="pill neutral">alle {s.every} Tage</span>
<span className="pill subtle">ab {formatDateTime(s.start)}</span>
</div>
))}
</div>
</div>
<div className="med-actions">
<button className="ghost" onClick={() => startEdit(med)}>Bearbeiten</button>
</div>
</div>
))}
</div>
</article>
async function runPlanner(e: React.FormEvent) {
e.preventDefault();
setPlannerLoading(true);
const body = { startDate: toIsoString(range.start), endDate: toIsoString(range.end) };
const rows = await fetch("/api/medications/usage", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(body) })
.then((res) => res.json())
.catch(() => []) as PlannerRow[];
setPlannerRows(rows);
setPlannerLoading(false);
}
<article className="card form">
<div className="card-head">
<h2>{editingId ? "Eintrag bearbeiten" : "Neuer Eintrag"}</h2>
<span className="pill">Slices wie alte App</span>
</div>
<form className="form-grid" onSubmit={saveMedication}>
<label>
Name
<input value={form.name} onChange={(e) => setForm({ ...form, name: e.target.value })} placeholder="z.B. Lisinopril" required />
</label>
<label>
Bestand (count)
<input type="number" min="0" value={form.count} onChange={(e) => setForm({ ...form, count: e.target.value })} />
</label>
<label>
Strip Size
<input type="number" min="1" value={form.stripSize} onChange={(e) => setForm({ ...form, stripSize: e.target.value })} />
</label>
function resetRange() {
setRange({ start: toInputValue(todayIso()), end: toInputValue(plusDaysIso(3)) });
setPlannerRows([]);
}
<div className="full slices">
<div className="card-head">
<h3>Slice / Plan</h3>
<button type="button" className="ghost" onClick={addSlice}>+ Slice</button>
</div>
{form.slices.map((s, idx) => (
<div key={idx} className="slice-row">
<label>
Usage (meds)
<input type="number" min="0" step="0.1" value={s.usage} onChange={(e) => setSliceValue(idx, "usage", e.target.value)} />
</label>
<label>
Every (days)
<input type="number" min="1" value={s.every} onChange={(e) => setSliceValue(idx, "every", e.target.value)} />
</label>
<label>
Start (Datum/Zeit)
<input type="datetime-local" value={s.start} onChange={(e) => setSliceValue(idx, "start", e.target.value)} />
</label>
{form.slices.length > 1 && (
<button type="button" className="ghost" onClick={() => removeSlice(idx)}>Entfernen</button>
)}
</div>
))}
</div>
return (
<main className="page">
<header className="hero">
<div>
<p className="eyebrow">Medassist · Planner</p>
<h1>Manage medication plans</h1>
</div>
<div className="tabs">
<button className={view === "dashboard" ? "pill primary" : "pill"} onClick={() => setView("dashboard")}>Dashboard</button>
<button className={view === "medications" ? "pill primary" : "pill"} onClick={() => setView("medications")}>Medications</button>
<button className={view === "planner" ? "pill primary" : "pill"} onClick={() => setView("planner")}>Planner</button>
</div>
</header>
<div className="full align-end gap">
{editingId && (
<button type="button" className="ghost" onClick={resetForm}>
Abbrechen
</button>
)}
<button type="submit" disabled={saving}>{saving ? "Speichern..." : "Speichern"}</button>
</div>
</form>
</article>
</section>
{view === "dashboard" && (
<>
<section className="grid">
<article className="card">
<div className="card-head">
<h2>Reorder Reminder</h2>
<span className="pill neutral">Stock watch</span>
</div>
{coverage.low.length === 0 ? (
<p className="success-text">All good, enough stock.</p>
) : (
<div className="table">
<div className="table-head">
<span>Name</span>
<span>Current pills</span>
<span>Days left</span>
<span>Runs out</span>
<span>Next dose</span>
</div>
{coverage.low.map((row) => (
<div key={row.name} className="table-row">
<span>{row.name}</span>
<span className={row.medsLeft <= 0 ? "danger-text" : ""}>{formatNumber(row.medsLeft)}</span>
<span className={row.daysLeft !== null && row.daysLeft <= 0 ? "danger-text" : ""}>{formatNumber(row.daysLeft)}</span>
<span>{row.depletionDate ?? "-"}</span>
<span>{row.nextDose ?? "-"}</span>
</div>
))}
</div>
)}
</article>
</section>
<section className="grid">
<article className="card">
<div className="card-head">
<h2>Nächste Einnahmen (3 Tage)</h2>
<span className="pill neutral">Preview</span>
</div>
<div className="timeline">
{schedule.events.slice(0, 10).map((event) => (
<div key={event.id} className="time-row">
<div className="time-chip">{event.timeStr}</div>
<div>
<div className="med-name">{event.medName}</div>
<div className="muted">{event.dateStr}</div>
<div className="tag subtle">{event.usage} meds</div>
</div>
</div>
))}
</div>
</article>
</section>
</main>
);
<section className="grid">
<article className="card">
<div className="card-head">
<h2>Medication Overview</h2>
<span className="pill neutral">Stock</span>
</div>
<div className="table table-4">
<div className="table-head">
<span>Name</span>
<span>Current pills</span>
<span>Days left</span>
<span>Runs out</span>
</div>
{coverage.all.map((row) => (
<div key={row.name} className="table-row">
<span>{row.name}</span>
<span className={row.medsLeft <= 0 ? "danger-text" : ""}>{formatNumber(row.medsLeft)}</span>
<span>{formatNumber(row.daysLeft)}</span>
<span>{row.depletionDate ?? "-"}</span>
</div>
))}
</div>
</article>
</section>
<section className="grid">
<article className="card">
<div className="card-head">
<h2>Upcoming Schedules</h2>
<span className="pill neutral">Next 10</span>
</div>
<div className="timeline">
{groupedSchedule.map((day) => (
<div key={day.dateStr} className="day-block">
<div className="day-divider">{day.dateStr}</div>
{day.meds.map((item) => {
const depletionTime = depletionByMed[item.medName];
const outOfStock = typeof depletionTime === "number" && item.lastWhen > depletionTime;
return (
<div key={`${day.dateStr}-${item.medName}`} className="time-row">
<div className="time-main">
<div className="med-name">{item.medName}</div>
<div className="tag-row">
<span className="tag subtle">{item.total} pills total</span>
<span className={`tag ${outOfStock ? "danger" : "success"}`}>
{outOfStock ? "⚠ No pills left" : "✓ Stock OK"}
</span>
</div>
</div>
<div className="time-col">
<div className="time-chip times-chip">{item.times.join(" · ")}</div>
</div>
</div>
);
})}
</div>
))}
</div>
</article>
</section>
</>
)}
{view === "medications" && (
<section className="grid">
<article className="card meds">
<div className="card-head">
<h2>Medication list</h2>
<span className="pill neutral">{loading ? "Loading..." : `${meds.length} entries`}</span>
</div>
<div className="med-list">
{meds.map((med) => (
<div key={med.id} className="med-row">
<div className="med-header">
<div className="med-info">
<div className="med-name">{med.name}</div>
<div className="med-details">
<span>Packs: <strong>{med.packCount ?? 1}</strong></span>
<span>Blisters per pack: <strong>{med.stripsPerPack ?? med.strips ?? 1}</strong></span>
<span>Pills per blister: <strong>{med.tabsPerStrip ?? med.stripSize}</strong></span>
<span>Loose: <strong>{med.looseTablets ?? 0}</strong></span>
</div>
<div className="med-total">Total: {med.count} pills</div>
</div>
<div className="med-actions">
<button className="ghost" onClick={() => startEdit(med)}>Edit</button>
<button className="ghost danger" onClick={() => deleteMed(med.id)}>Delete</button>
</div>
</div>
<div className="slice-list">
{med.slices.map((s, idx) => (
<div key={`${med.id}-${idx}`} className="slice-row-simple">
{s.usage} {s.usage === 1 ? "pill" : "pills"} · every {s.every} {s.every === 1 ? "day" : "days"} · from {formatDateTime(s.start)}
</div>
))}
</div>
</div>
))}
</div>
</article>
<article className="card form">
<div className="card-head">
<h2>{editingId ? "Edit entry" : "New entry"}</h2>
<span className="pill">Packs + loose pills</span>
</div>
<form className="form-grid" onSubmit={saveMedication}>
<label>
Name
<input value={form.name} onChange={(e) => setForm({ ...form, name: e.target.value })} placeholder="z.B. Lisinopril" required />
</label>
<label>
Packs
<input type="number" min="0" value={form.packCount} onChange={(e) => handleValueChange("packCount", e.target.value)} />
</label>
<label>
Blisters per pack
<input type="number" min="1" value={form.stripsPerPack} onChange={(e) => handleValueChange("stripsPerPack", e.target.value)} />
</label>
<label>
Pills per blister
<input type="number" min="1" value={form.tabsPerStrip} onChange={(e) => handleValueChange("tabsPerStrip", e.target.value)} />
</label>
<label>
Loose pills
<input type="number" min="0" value={form.looseTablets} onChange={(e) => handleValueChange("looseTablets", e.target.value)} />
</label>
<label>
Total (pills)
<div className="static-value">{formatNumber(totalTablets)}</div>
</label>
<div className="full slices">
<div className="card-head">
<h3>Intake schedule</h3>
<button type="button" className="ghost" onClick={addSlice}>+ Intake</button>
</div>
{form.slices.map((s, idx) => (
<div key={idx} className="slice-row">
<div className="slice-inputs">
<label>
Usage (pills)
<input type="number" min="0" step="0.1" value={s.usage} onChange={(e) => setSliceValue(idx, "usage", e.target.value)} />
</label>
<label>
Every (days)
<input type="number" min="1" value={s.every} onChange={(e) => setSliceValue(idx, "every", e.target.value)} />
</label>
<label>
Start (date/time)
<input type="datetime-local" step="60" value={s.start} onChange={(e) => setSliceValue(idx, "start", e.target.value)} />
</label>
</div>
{form.slices.length > 1 && (
<button type="button" className="ghost" onClick={() => removeSlice(idx)}>Remove</button>
)}
</div>
))}
</div>
<div className="full align-end gap">
{editingId && (
<button type="button" className="ghost" onClick={resetForm}>
Cancel
</button>
)}
<button type="submit" disabled={saving}>{saving ? "Saving..." : "Save"}</button>
</div>
</form>
</article>
</section>
)}
{view === "planner" && (
<section className="grid">
<article className="card">
<div className="card-head">
<h2>Demand Calculator</h2>
<span className="pill neutral">Plan your supply</span>
</div>
<form className="planner" onSubmit={runPlanner}>
<label>
From
<input type="datetime-local" step="60" value={range.start} onChange={(e) => setRange({ ...range, start: e.target.value })} />
</label>
<label>
Until
<input type="datetime-local" step="60" value={range.end} onChange={(e) => setRange({ ...range, end: e.target.value })} />
</label>
<div className="planner-actions">
<button type="button" className="ghost" onClick={resetRange}>Reset</button>
<button type="submit" disabled={plannerLoading}>{plannerLoading ? "Calculating..." : "Calculate"}</button>
</div>
</form>
{plannerRows.length > 0 && (
<div className="table">
<div className="table-head">
<span>Medication</span>
<span>Usage</span>
<span>Blisters needed</span>
<span>Available</span>
<span>Status</span>
</div>
{plannerRows.map((row) => (
<div key={row.medicationId} className="table-row">
<span>{row.medicationName}</span>
<span><strong>{row.plannerUsage}</strong> pills</span>
<span>{row.stripsNeeded} × {row.stripSize}</span>
<span>{row.stripsAvailable}</span>
<span className={row.enough ? "status-chip success" : "status-chip danger"}>{row.enough ? "Enough" : "Low"}</span>
</div>
))}
</div>
)}
</article>
</section>
)}
</main>
);
}
function deriveTotal(form: FormState) {
const packCount = Number(form.packCount) || 0;
const stripsPerPack = Number(form.stripsPerPack) || 0;
const tabsPerStrip = Number(form.tabsPerStrip) || 1;
const looseTablets = Number(form.looseTablets) || 0;
return packCount * stripsPerPack * tabsPerStrip + looseTablets;
}
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();
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);
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" });
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();
const end = new Date();
end.setDate(end.getDate() + 3);
const events: Array<{ id: string; medName: string; timeStr: string; dateStr: string; usage: number; when: number }> = [];
const now = new Date();
const end = new Date();
end.setDate(end.getDate() + 3);
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" }),
});
}
});
});
meds.forEach((med) => {
med.slices.forEach((slice, idx) => {
const start = new Date(slice.start);
if (Number.isNaN(start.getTime())) return;
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" }),
});
}
});
});
events.sort((a, b) => a.when - b.when);
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;
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) };
return { events, today: todayCount, nextThree: events.length, totalSlices: meds.reduce((acc, m) => acc + m.slices.length, 0) };
}
function formatNumber(value: number | null) {
if (value === null || Number.isNaN(value)) return "-";
if (Math.abs(value % 1) < 0.05) return Math.round(value).toLocaleString();
return value.toFixed(1);
}
function calculateCoverage(meds: Medication[], events: Array<{ medName: string; when: number }>) {
const MS_PER_DAY = 86_400_000;
const now = Date.now();
const coverage: Coverage[] = meds.map((m) => {
const dailyRate = m.slices.reduce((sum, s) => sum + (s.every > 0 ? s.usage / s.every : 0), 0);
let consumed = 0;
m.slices.forEach((s) => {
const start = new Date(s.start).getTime();
if (Number.isNaN(start) || start > now) return;
const period = Math.max(1, s.every) * MS_PER_DAY;
const occurrences = Math.floor((now - start) / period) + 1; // include today if started
consumed += occurrences * s.usage;
});
const medsLeft = Math.max(0, m.count - consumed);
const rawDaysLeft = dailyRate > 0 ? medsLeft / dailyRate : null;
const daysLeft = rawDaysLeft !== null ? Math.max(0, Math.floor(rawDaysLeft)) : null; // conservative: round down
const depletionMs = daysLeft !== null ? now + daysLeft * MS_PER_DAY : null;
const depletionDate = depletionMs !== null ? new Date(depletionMs).toLocaleDateString([], { weekday: "short", day: "2-digit", month: "short" }) : null;
const nextEvent = events.find((e) => e.medName === m.name);
return {
name: m.name,
medsLeft: Number(medsLeft.toFixed(1)),
daysLeft,
depletionDate,
depletionTime: depletionMs,
nextDose: nextEvent ? new Date(nextEvent.when).toLocaleString([], { weekday: "short", day: "2-digit", month: "short", hour: "2-digit", minute: "2-digit" }) : null,
};
});
const low = coverage.filter((c) => c.medsLeft <= 0 || (c.daysLeft !== null && c.daysLeft <= 3));
return { low, all: coverage };
}
+213 -34
View File
@@ -16,20 +16,26 @@ body {
}
.hero {
background: linear-gradient(135deg, rgba(67, 106, 255, 0.12), rgba(115, 195, 255, 0.1));
border: 1px solid rgba(73, 117, 255, 0.25);
border-radius: 20px;
padding: 1.6rem;
display: grid;
grid-template-columns: 2fr 1fr;
background: linear-gradient(135deg, rgba(67, 106, 255, 0.08), rgba(115, 195, 255, 0.06));
border: 1px solid rgba(73, 117, 255, 0.2);
border-radius: 16px;
padding: 1.25rem 1.5rem;
display: flex;
justify-content: space-between;
align-items: center;
gap: 1.5rem;
box-shadow: 0 18px 45px rgba(0, 0, 0, 0.3);
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.2);
margin-bottom: 1.5rem;
}
.hero h1 { margin: 0.2rem 0 0.4rem; font-size: 2rem; }
.hero h1 { margin: 0.15rem 0 0; font-size: 1.6rem; font-weight: 600; }
.sub { color: #b7c2e5; margin: 0; }
.eyebrow { letter-spacing: 0.08em; text-transform: uppercase; color: #7ca7ff; font-size: 0.8rem; margin: 0; }
.eyebrow { letter-spacing: 0.06em; text-transform: uppercase; color: #7ca7ff; font-size: 0.75rem; margin: 0; font-weight: 500; }
.tabs { display: flex; gap: 0.5rem; }
.tabs .pill { cursor: pointer; transition: all 150ms ease; }
.tabs .pill:hover { background: rgba(47, 134, 246, 0.15); }
.tabs .pill.primary { background: rgba(47, 134, 246, 0.25); border-color: #2f86f6; }
.stats { display: grid; grid-template-columns: repeat(3, 1fr); gap: 0.75rem; align-items: center; }
.stat { background: rgba(255, 255, 255, 0.04); border: 1px solid rgba(255, 255, 255, 0.08); border-radius: 14px; padding: 0.9rem; }
@@ -55,60 +61,233 @@ body {
.badges { display: flex; gap: 0.5rem; flex-wrap: wrap; margin-top: 0.6rem; }
.meds .med-list { display: flex; flex-direction: column; gap: 0.9rem; }
.med-row { display: flex; justify-content: space-between; gap: 1rem; border: 1px solid #1f2a3d; padding: 0.9rem; border-radius: 12px; background: #0d1424; }
.med-name { font-weight: 600; }
.meds .med-list { display: flex; flex-direction: column; gap: 0.75rem; }
.med-row { display: flex; flex-direction: column; gap: 0.75rem; border: 1px solid #1f2a3d; padding: 1rem; border-radius: 10px; background: #0d1424; position: relative; }
.med-header { display: flex; justify-content: space-between; align-items: flex-start; gap: 1rem; }
.med-info { flex: 1; min-width: 0; }
.med-name { font-weight: 600; font-size: 1.1rem; margin-bottom: 0.4rem; }
.med-details { display: grid; grid-template-columns: repeat(2, 1fr); gap: 0.25rem 1.5rem; color: #a3adc2; font-size: 0.9rem; margin-bottom: 0.5rem; }
.med-details strong { color: #dceaff; font-weight: 600; margin-left: 0.25rem; }
.med-total { color: #dceaff; font-weight: 600; font-size: 0.95rem; margin-bottom: 0.5rem; }
.muted { color: #a3adc2; font-size: 0.95rem; }
.small { font-size: 0.9rem; }
.tag { display: inline-block; background: rgba(255, 255, 255, 0.06); border-radius: 10px; padding: 0.2rem 0.55rem; margin-top: 0.35rem; color: #dce3f5; font-size: 0.85rem; }
.tag.subtle { background: rgba(255, 255, 255, 0.04); color: #c1cbe0; }
.slice-list { display: flex; flex-direction: column; gap: 0.4rem; margin-top: 0.5rem; width: 100%; }
.slice-row-simple { color: #cbd5f5; font-size: 0.9rem; padding: 0.5rem 0.75rem; background: #0f192c; border: 1px solid #1f2a3d; border-radius: 6px; width: 100%; }
.med-actions { display: flex; align-items: center; gap: 0.5rem; }
.tag { display: inline-flex; align-items: center; gap: 0.3rem; background: rgba(255, 255, 255, 0.06); border-radius: 6px; padding: 0.3rem 0.6rem; color: #dce3f5; font-size: 0.8rem; font-weight: 500; }
.tag.subtle { background: rgba(255, 255, 255, 0.04); color: #a3adc2; font-size: 0.85rem; }
.tag.success { background: rgba(57, 217, 138, 0.12); color: #6ee7b7; border: 1px solid rgba(57, 217, 138, 0.25); }
.tag.danger { background: rgba(255, 94, 94, 0.12); color: #fca5a5; border: 1px solid rgba(255, 94, 94, 0.3); }
.tag-row { display: flex; gap: 0.5rem; align-items: center; flex-wrap: wrap; margin-top: 0.25rem; }
.danger-text { color: #ff8f8f; font-weight: 700; }
.success-text { color: #9be8c7; font-weight: 700; }
.med-actions { display: flex; align-items: center; gap: 0.5rem; flex-shrink: 0; }
.med-actions button { padding: 0.5rem 0.9rem; }
@media (max-width: 600px) {
.med-header { flex-direction: column; }
.med-actions { align-self: flex-start; }
}
.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; }
.slice-row { display: flex; flex-direction: column; gap: 0.75rem; background: rgba(15, 25, 44, 0.5); border: 1px solid #1f2a3d; padding: 1rem; border-radius: 8px; margin-bottom: 0.65rem; }
.slice-row .slice-inputs { display: grid; grid-template-columns: 1fr 1fr 2fr; gap: 1rem; align-items: end; }
.slice-row button { align-self: flex-end; width: auto; }
.slice-row:last-child { margin-bottom: 0; }
.slices h3 { margin: 0; }
.gap { gap: 0.6rem; }
button {
padding: 0.65rem 1rem;
border-radius: 10px;
border: 1px solid #2f86f6;
padding: 0.7rem 1.25rem;
border-radius: 8px;
border: none;
background: linear-gradient(135deg, #2f86f6, #3fa9f5);
color: white;
cursor: pointer;
font-weight: 600;
transition: transform 120ms ease, box-shadow 120ms ease;
font-size: 0.9rem;
transition: transform 120ms ease, box-shadow 120ms ease, opacity 120ms ease;
}
button:hover { transform: translateY(-1px); box-shadow: 0 10px 25px rgba(47, 134, 246, 0.35); }
button.ghost { background: transparent; border-color: #3a475f; color: #d0d8ec; box-shadow: none; }
button.ghost:hover { background: rgba(255, 255, 255, 0.05); transform: none; }
button:hover { transform: translateY(-1px); box-shadow: 0 8px 20px rgba(47, 134, 246, 0.35); }
button:active { transform: translateY(0); }
button.ghost { background: transparent; border: 1px solid #3a475f; color: #d0d8ec; box-shadow: none; }
button.ghost:hover { background: rgba(255, 255, 255, 0.06); transform: none; }
button.ghost.danger { border-color: #5a3a3a; color: #ff9a9a; }
button.ghost.danger:hover { background: rgba(255, 94, 94, 0.1); }
input, select {
width: 100%;
padding: 0.65rem;
border-radius: 10px;
border: 1px solid #1f2a3d;
background: #0d1322;
padding: 0.7rem 0.85rem;
border-radius: 8px;
border: 1px solid #2a3a4d;
background: #0a1018;
color: #e5e7eb;
font-size: 0.95rem;
transition: border-color 150ms ease, box-shadow 150ms ease;
}
input:focus, select:focus {
outline: none;
border-color: #2f86f6;
box-shadow: 0 0 0 3px rgba(47, 134, 246, 0.15);
}
.form-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); gap: 0.75rem 1rem; }
.form-grid label { display: flex; flex-direction: column; gap: 0.35rem; color: #cfd8f1; font-weight: 500; }
.form-grid .full { grid-column: 1 / -1; }
.align-end { display: flex; justify-content: flex-end; }
.static-value {
padding: 0.7rem 0.85rem;
border-radius: 8px;
background: rgba(47, 134, 246, 0.08);
border: 1px solid #2f86f6;
color: #dceaff;
font-weight: 600;
font-size: 1rem;
text-align: right;
}
.timeline { display: flex; flex-direction: column; gap: 0.85rem; }
.time-row { display: flex; gap: 0.9rem; align-items: center; border-bottom: 1px solid #1f2a3d; padding-bottom: 0.75rem; }
.form-grid { display: grid; grid-template-columns: repeat(2, 1fr); gap: 1rem 1.25rem; }
.form-grid label { display: flex; flex-direction: column; gap: 0.4rem; color: #a3b3c8; font-size: 0.85rem; font-weight: 500; text-transform: uppercase; letter-spacing: 0.03em; }
.form-grid .full { grid-column: 1 / -1; }
.align-end { display: flex; justify-content: flex-end; gap: 0.75rem; }
.timeline { display: flex; flex-direction: column; gap: 1rem; }
.day-block { border: 1px solid #1f2a3d; border-radius: 16px; padding: 1rem 1.25rem; background: linear-gradient(135deg, #0d1322 0%, #111827 100%); box-shadow: 0 8px 32px rgba(0,0,0,0.25); }
.day-divider {
margin: 0 0 0.75rem;
padding-bottom: 0.5rem;
border-bottom: 1px solid rgba(47, 134, 246, 0.2);
color: #7ca7ff;
font-weight: 700;
font-size: 0.95rem;
letter-spacing: 0.02em;
}
.time-row { display: grid; grid-template-columns: minmax(200px, 280px) 1fr; align-items: center; gap: 1rem; padding: 0.75rem 0; border-bottom: 1px solid rgba(255,255,255,0.05); }
.time-row:last-child { border-bottom: none; padding-bottom: 0; }
.time-chip { min-width: 80px; text-align: center; border: 1px solid #2f86f6; border-radius: 10px; padding: 0.45rem 0.6rem; background: rgba(47, 134, 246, 0.1); color: #dceaff; font-weight: 600; }
.time-main { display: flex; flex-direction: column; gap: 0.4rem; }
.time-main .med-name { font-size: 1rem; font-weight: 600; color: #e5e7eb; margin: 0; }
.time-col { display: flex; align-items: center; justify-content: flex-start; }
.time-chip {
display: inline-flex;
align-items: center;
gap: 0.5rem;
border: 1px solid rgba(47, 134, 246, 0.4);
border-radius: 8px;
padding: 0.5rem 0.75rem;
background: rgba(47, 134, 246, 0.08);
color: #93c5fd;
font-weight: 600;
font-size: 0.9rem;
font-variant-numeric: tabular-nums;
}
.times-chip { white-space: nowrap; }
.highlights { display: grid; grid-template-columns: repeat(auto-fit, minmax(140px, 1fr)); gap: 0.75rem; margin-bottom: 0.75rem; }
.card p { margin: 0; }
.table { width: 100%; display: flex; flex-direction: column; gap: 0; margin-top: 0.5rem; }
.table-head, .table-row {
display: grid;
grid-template-columns: minmax(180px, 2fr) 100px 140px 140px 120px;
align-items: center;
gap: 1rem;
padding: 0.75rem 1rem;
}
.table-head {
color: #7ca7ff;
font-weight: 600;
font-size: 0.8rem;
text-transform: uppercase;
letter-spacing: 0.04em;
background: rgba(47, 134, 246, 0.06);
border-radius: 8px;
margin-bottom: 0.5rem;
}
.table-row {
background: rgba(255, 255, 255, 0.02);
border-radius: 8px;
margin-bottom: 0.35rem;
transition: background 150ms ease;
}
.table-row:hover { background: rgba(255, 255, 255, 0.04); }
.table-row:last-child { margin-bottom: 0; }
.table-4 .table-head, .table-4 .table-row {
grid-template-columns: minmax(200px, 2.2fr) 150px 130px 170px;
}
.status-chip {
display: inline-flex;
align-items: center;
gap: 0.4rem;
padding: 0.4rem 0.75rem;
border-radius: 6px;
font-size: 0.8rem;
font-weight: 600;
}
.status-chip.success {
background: rgba(57, 217, 138, 0.15);
color: #6ee7b7;
border: 1px solid rgba(57, 217, 138, 0.3);
}
.status-chip.success::before {
content: "✓";
font-size: 0.75rem;
}
.status-chip.danger {
background: rgba(255, 94, 94, 0.15);
color: #fca5a5;
border: 1px solid rgba(255, 94, 94, 0.3);
}
.status-chip.danger::before {
content: "!";
font-weight: 700;
}
@media (max-width: 760px) {
.table-head, .table-row {
grid-template-columns: 1.6fr 1fr 1fr;
grid-auto-flow: row;
grid-auto-rows: auto;
}
.table-head span:nth-child(n+4), .table-row span:nth-child(n+4) { display: none; }
}
@media (max-width: 900px) {
.hero { grid-template-columns: 1fr; }
.stats { grid-template-columns: repeat(auto-fit, minmax(160px, 1fr)); }
}
.planner {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 1rem;
padding: 1rem;
background: linear-gradient(135deg, rgba(47, 134, 246, 0.04), rgba(115, 195, 255, 0.02));
border: 1px solid rgba(47, 134, 246, 0.15);
border-radius: 12px;
margin-bottom: 1rem;
}
.planner label {
display: flex;
flex-direction: column;
gap: 0.5rem;
color: #93a3b8;
font-size: 0.8rem;
font-weight: 500;
text-transform: uppercase;
letter-spacing: 0.04em;
}
.planner-actions {
grid-column: 1 / -1;
display: flex;
justify-content: flex-end;
gap: 0.75rem;
padding-top: 0.5rem;
border-top: 1px solid rgba(255, 255, 255, 0.06);
margin-top: 0.5rem;
}
@media (max-width: 600px) {
.planner { grid-template-columns: 1fr; }
}
+7
View File
@@ -6,5 +6,12 @@ export default defineConfig({
server: {
port: 5173,
strictPort: true,
proxy: {
"/api": {
target: "http://backend-dev:3000",
changeOrigin: true,
rewrite: (path) => path.replace(/^\/api/, ""),
},
},
},
});
+183
View File
@@ -0,0 +1,183 @@
#!/usr/bin/env bash
set -euo pipefail
# Builds (optional) and pushes images to the registry.
# Required env: REGISTRY_TOKEN (registry access token).
# Optional env: REGISTRY_USER (defaults to token), REGISTRY_HOST (default git.danielvolz.org), PROJECT_PATH (default daniel/medassist), IMAGE_TAG (set via -v or prompt).
# Flag: -v <tag> to set image tag (e.g. -v 1.0.0).
SCRIPT_DIR=$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)
REPO_ROOT=$(cd "$SCRIPT_DIR/.." && pwd)
usage() {
cat >&2 <<'EOF'
Usage: REGISTRY_TOKEN=... [REGISTRY_USER=...] ./scripts/push-images.sh [-v <tag>]
Flow:
1) Tag wählen (per -v oder Auswahl/Prompt)
2) Optional bauen (Backend/Frontend)
3) Push bestätigen
Options:
-v <tag> Set image tag (default: prompt if unset)
-h Show this help
Env (can be supplied via .env):
REGISTRY_TOKEN Required registry access token
REGISTRY_USER Optional; defaults to REGISTRY_TOKEN
REGISTRY_HOST Default git.danielvolz.org
PROJECT_PATH Default daniel/medassist
IMAGE_TAG If set, used as default tag
EOF
}
prompt_yes_no() {
local prompt="$1" default="$2" answer
local suffix="[y/N]"
[[ "$default" == "y" ]] && suffix="[Y/n]"
while true; do
read -r -p "$prompt $suffix " answer
answer=${answer:-$default}
case "$answer" in
y|Y) return 0 ;;
n|N) return 1 ;;
*) echo "Please answer y or n." ;;
esac
done
}
select_tag() {
if [[ -n "${IMAGE_TAG:-}" ]]; then
echo "Using tag: $IMAGE_TAG"
return
fi
mapfile -t tags < <(docker images --format '{{.Tag}}' medassist-backend 2>/dev/null | grep -v '<none>' | sort -u)
if ((${#tags[@]} > 0)); then
echo "Select tag to use:"
local i=1
for t in "${tags[@]}"; do
echo " [$i] $t"
((i++))
done
echo " [n] Enter new tag"
read -r -p "Choice: " choice
if [[ "$choice" =~ ^[0-9]+$ ]] && (( choice >= 1 && choice <= ${#tags[@]} )); then
IMAGE_TAG="${tags[choice-1]}"
echo "Using tag: $IMAGE_TAG"
return
fi
fi
while [[ -z "${IMAGE_TAG:-}" ]]; do
read -r -p "Enter tag (e.g. 1.0.0): " IMAGE_TAG
done
echo "Using tag: $IMAGE_TAG"
}
if [[ -f "$REPO_ROOT/.env" ]]; then
set -a
# shellcheck source=/dev/null
source "$REPO_ROOT/.env"
set +a
fi
REGISTRY_HOST=${REGISTRY_HOST:-git.danielvolz.org}
PROJECT_PATH=${PROJECT_PATH:-daniel/medassist}
IMAGE_TAG=${IMAGE_TAG:-}
while getopts ":v:h" opt; do
case "$opt" in
v)
IMAGE_TAG="$OPTARG"
;;
h)
usage
exit 0
;;
\?)
echo "Unknown option -$OPTARG" >&2
usage
exit 1
;;
:)
echo "Option -$OPTARG requires an argument" >&2
usage
exit 1
;;
esac
done
select_tag
REGISTRY_TOKEN=${REGISTRY_TOKEN:-}
REGISTRY_USER=${REGISTRY_USER:-$REGISTRY_TOKEN}
if [[ -z "$REGISTRY_TOKEN" ]]; then
echo "Missing REGISTRY_TOKEN. Set it in your env or in .env." >&2
usage
exit 1
fi
build_images() {
echo "Building medassist-backend:${IMAGE_TAG}..."
docker build -t "medassist-backend:${IMAGE_TAG}" "$REPO_ROOT/backend"
echo "Building medassist-frontend:${IMAGE_TAG}..."
docker build -t "medassist-frontend:${IMAGE_TAG}" "$REPO_ROOT/frontend"
}
BACKEND_LOCAL="medassist-backend:${IMAGE_TAG}"
FRONTEND_LOCAL="medassist-frontend:${IMAGE_TAG}"
BACKEND_REMOTE="${REGISTRY_HOST}/${PROJECT_PATH}/backend:${IMAGE_TAG}"
FRONTEND_REMOTE="${REGISTRY_HOST}/${PROJECT_PATH}/frontend:${IMAGE_TAG}"
update_compose_prod() {
local compose_file="$REPO_ROOT/docker-compose.prod.yml"
local sed_inplace
case "$(uname -s)" in
Darwin*) sed_inplace=("-i" "") ;;
*) sed_inplace=("-i") ;;
esac
if [[ -f "$compose_file" ]]; then
# Replace image tags in prod compose to the selected tag
sed "${sed_inplace[@]}" \
-e "s|^\s*image: ${REGISTRY_HOST}/${PROJECT_PATH}/backend:.*| image: ${REGISTRY_HOST}/${PROJECT_PATH}/backend:${IMAGE_TAG}|" \
-e "s|^\s*image: ${REGISTRY_HOST}/${PROJECT_PATH}/frontend:.*| image: ${REGISTRY_HOST}/${PROJECT_PATH}/frontend:${IMAGE_TAG}|" \
"$compose_file"
echo "Updated docker-compose.prod.yml with tag ${IMAGE_TAG}."
else
echo "Warning: docker-compose.prod.yml not found; skipped updating tag." >&2
fi
}
built=0
if prompt_yes_no "Build images for tag ${IMAGE_TAG}?" "y"; then
build_images
built=1
else
echo "Skipping build. Using existing local images for tag ${IMAGE_TAG}."
fi
push_default="n"
[[ $built -eq 1 ]] && push_default="y"
if ! prompt_yes_no "Push images for tag ${IMAGE_TAG} to ${REGISTRY_HOST}/${PROJECT_PATH}?" "$push_default"; then
echo "Push cancelled."
exit 0
fi
printf 'Logging in to %s...\n' "$REGISTRY_HOST"
echo "$REGISTRY_TOKEN" | docker login "$REGISTRY_HOST" --username "$REGISTRY_USER" --password-stdin
docker tag "$BACKEND_LOCAL" "$BACKEND_REMOTE"
docker tag "$FRONTEND_LOCAL" "$FRONTEND_REMOTE"
docker push "$BACKEND_REMOTE"
docker push "$FRONTEND_REMOTE"
printf 'Pushed:\n %s\n %s\n' "$BACKEND_REMOTE" "$FRONTEND_REMOTE"
update_compose_prod