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:
@@ -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
|
||||
|
||||
Generated
+2143
File diff suppressed because it is too large
Load Diff
@@ -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 }
|
||||
]
|
||||
}
|
||||
@@ -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("[]"),
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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
@@ -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:
|
||||
|
||||
Generated
+1814
File diff suppressed because it is too large
Load Diff
+510
-283
@@ -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
@@ -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; }
|
||||
}
|
||||
|
||||
@@ -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/, ""),
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
Executable
+183
@@ -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
|
||||
Reference in New Issue
Block a user