diff --git a/backend/src/db/client.ts b/backend/src/db/client.ts index 5093cd2..55c275c 100644 --- a/backend/src/db/client.ts +++ b/backend/src/db/client.ts @@ -68,6 +68,8 @@ export async function runTableMigrations(client: Client): Promise<{ success: boo `ALTER TABLE user_settings ADD COLUMN repeat_reminders_enabled integer NOT NULL DEFAULT 0`, `ALTER TABLE user_settings ADD COLUMN reminder_repeat_interval_minutes integer NOT NULL DEFAULT 30`, `ALTER TABLE user_settings ADD COLUMN max_nagging_reminders integer NOT NULL DEFAULT 5`, + // Added in v1.2.3 - dismiss missed doses without deducting stock + `ALTER TABLE dose_tracking ADD COLUMN dismissed integer NOT NULL DEFAULT 0`, ]; for (const sql of alterMigrations) { diff --git a/backend/src/db/schema-sql.ts b/backend/src/db/schema-sql.ts index 87c6e89..ab4b461 100644 --- a/backend/src/db/schema-sql.ts +++ b/backend/src/db/schema-sql.ts @@ -97,6 +97,7 @@ export function getTableCreationSQL(): string[] { dose_id text NOT NULL, taken_at integer NOT NULL DEFAULT (strftime('%s','now')), marked_by text, + dismissed integer NOT NULL DEFAULT 0, FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE )`, ]; diff --git a/backend/src/db/schema.ts b/backend/src/db/schema.ts index e23d259..03bccc5 100644 --- a/backend/src/db/schema.ts +++ b/backend/src/db/schema.ts @@ -115,4 +115,5 @@ export const doseTracking = sqliteTable("dose_tracking", { doseId: text("dose_id", { length: 255 }).notNull(), // e.g. "med-5-1-86400000-1735200000000" takenAt: integer("taken_at", { mode: "timestamp" }).notNull().default(sql`(strftime('%s','now'))`), markedBy: text("marked_by", { length: 100 }), // null = user, "Daniel" = via share link + dismissed: integer("dismissed", { mode: "boolean" }).notNull().default(false), // true = missed dose acknowledged without taking }); diff --git a/backend/src/routes/doses.ts b/backend/src/routes/doses.ts index 6994e3f..504ea26 100644 --- a/backend/src/routes/doses.ts +++ b/backend/src/routes/doses.ts @@ -2,7 +2,7 @@ import { FastifyInstance } from "fastify"; import { z } from "zod"; import { db } from "../db/client.js"; import { doseTracking, shareTokens } from "../db/schema.js"; -import { eq, and } from "drizzle-orm"; +import { eq, and, inArray } from "drizzle-orm"; import { requireAuth, getAnonymousUserId } from "../plugins/auth.js"; import { env } from "../plugins/env.js"; import type { AuthUser } from "../types/fastify.js"; @@ -18,6 +18,10 @@ const shareDoseSchema = z.object({ doseId: z.string().min(1, "doseId is required"), }); +const dismissDosesSchema = z.object({ + doseIds: z.array(z.string().min(1)).min(1, "At least one doseId is required"), +}); + // Helper to get user ID from request // Returns anonymous user ID when auth is disabled async function getUserId(request: any, reply: any): Promise { @@ -57,6 +61,7 @@ export async function doseRoutes(app: FastifyInstance) { doseId: d.doseId, takenAt: d.takenAt?.getTime() ?? Date.now(), markedBy: d.markedBy, + dismissed: d.dismissed ?? false, })), }; } @@ -127,6 +132,103 @@ export async function doseRoutes(app: FastifyInstance) { } ); + // --------------------------------------------------------------------------- + // POST /doses/dismiss - PROTECTED: Dismiss missed doses without deducting stock + // --------------------------------------------------------------------------- + app.post<{ Body: z.infer }>( + "/doses/dismiss", + { preHandler: requireAuth }, + async (request, reply) => { + const userId = await getUserId(request, reply); + + const parsed = dismissDosesSchema.safeParse(request.body); + if (!parsed.success) { + return reply.status(400).send({ + error: parsed.error.errors[0]?.message ?? "Invalid input", + }); + } + + const { doseIds } = parsed.data; + + // Insert dismissed records for each dose that doesn't exist yet + let dismissedCount = 0; + for (const doseId of doseIds) { + // Check if already exists (taken or dismissed) + const [existing] = await db.select() + .from(doseTracking) + .where( + and( + eq(doseTracking.userId, userId), + eq(doseTracking.doseId, doseId) + ) + ); + + if (existing) { + // Already exists - update to dismissed if not already + if (!existing.dismissed) { + await db.update(doseTracking) + .set({ dismissed: true }) + .where( + and( + eq(doseTracking.userId, userId), + eq(doseTracking.doseId, doseId) + ) + ); + dismissedCount++; + } + } else { + // Create new dismissed record + await db.insert(doseTracking).values({ + userId, + doseId, + markedBy: null, + dismissed: true, + }); + dismissedCount++; + } + } + + return { success: true, dismissedCount }; + } + ); + + // --------------------------------------------------------------------------- + // DELETE /doses/dismiss - PROTECTED: Clear all dismissed doses (un-dismiss) + // --------------------------------------------------------------------------- + app.delete( + "/doses/dismiss", + { preHandler: requireAuth }, + async (request, reply) => { + const userId = await getUserId(request, reply); + + // Delete all dismissed-only records (not taken ones) + // For taken+dismissed, just remove the dismissed flag + const dismissed = await db.select() + .from(doseTracking) + .where( + and( + eq(doseTracking.userId, userId), + eq(doseTracking.dismissed, true) + ) + ); + + for (const d of dismissed) { + if (d.markedBy !== null || d.takenAt) { + // This was also marked as taken - just remove dismissed flag + await db.update(doseTracking) + .set({ dismissed: false }) + .where(eq(doseTracking.id, d.id)); + } else { + // This was only dismissed - delete it + await db.delete(doseTracking) + .where(eq(doseTracking.id, d.id)); + } + } + + return { success: true, clearedCount: dismissed.length }; + } + ); + // --------------------------------------------------------------------------- // GET /share/:token/doses - PUBLIC: Get taken doses for a share link // --------------------------------------------------------------------------- @@ -151,6 +253,7 @@ export async function doseRoutes(app: FastifyInstance) { doseId: d.doseId, takenAt: d.takenAt?.getTime() ?? Date.now(), markedBy: d.markedBy, + dismissed: d.dismissed ?? false, })), }; } diff --git a/backend/src/test/doses.test.ts b/backend/src/test/doses.test.ts index 7e3e4c0..903ce86 100644 --- a/backend/src/test/doses.test.ts +++ b/backend/src/test/doses.test.ts @@ -80,6 +80,45 @@ async function registerDoseRoutes(ctx: TestContext) { return { success: true }; }); + + // POST /doses/dismiss - Dismiss missed doses without deducting stock + app.post<{ Body: { doseIds: string[] } }>("/doses/dismiss", async (request, reply) => { + const userId = 1; + const { doseIds } = request.body || {}; + + if (!doseIds || !Array.isArray(doseIds) || doseIds.length === 0) { + return reply.status(400).send({ error: "doseIds array is required" }); + } + + let dismissedCount = 0; + for (const doseId of doseIds) { + // Check if already exists + const existing = await client.execute({ + sql: `SELECT id, dismissed FROM dose_tracking WHERE user_id = ? AND dose_id = ?`, + args: [userId, doseId], + }); + + if (existing.rows.length > 0) { + // Update to dismissed if not already + if (!existing.rows[0].dismissed) { + await client.execute({ + sql: `UPDATE dose_tracking SET dismissed = 1 WHERE id = ?`, + args: [existing.rows[0].id], + }); + dismissedCount++; + } + } else { + // Insert new dismissed record + await client.execute({ + sql: `INSERT INTO dose_tracking (user_id, dose_id, dismissed) VALUES (?, ?, 1)`, + args: [userId, doseId], + }); + dismissedCount++; + } + } + + return { success: true, dismissedCount }; + }); } // ============================================================================= @@ -361,4 +400,101 @@ describe("Dose Tracking API", () => { expect(getResponse.json().doses[0].doseId).toBe(doseId); }); }); + + // --------------------------------------------------------------------------- + // Dismiss Doses Tests (POST /doses/dismiss) + // --------------------------------------------------------------------------- + + describe("POST /doses/dismiss", () => { + it("should dismiss multiple doses", async () => { + const doseIds = ["1-0-1735344000000", "1-0-1735430400000"]; + + const response = await ctx.app.inject({ + method: "POST", + url: "/doses/dismiss", + payload: { doseIds }, + }); + + expect(response.statusCode).toBe(200); + expect(response.json()).toEqual({ success: true, dismissedCount: 2 }); + + // Verify in database + const result = await ctx.client.execute({ + sql: `SELECT dose_id, dismissed FROM dose_tracking WHERE user_id = ? AND dismissed = 1`, + args: [userId], + }); + expect(result.rows.length).toBe(2); + }); + + it("should not double-count already dismissed doses", async () => { + const doseId = "1-0-1735344000000"; + + // Dismiss once + await ctx.app.inject({ + method: "POST", + url: "/doses/dismiss", + payload: { doseIds: [doseId] }, + }); + + // Dismiss again + const response = await ctx.app.inject({ + method: "POST", + url: "/doses/dismiss", + payload: { doseIds: [doseId] }, + }); + + expect(response.statusCode).toBe(200); + expect(response.json()).toEqual({ success: true, dismissedCount: 0 }); + }); + + it("should reject empty doseIds array", async () => { + const response = await ctx.app.inject({ + method: "POST", + url: "/doses/dismiss", + payload: { doseIds: [] }, + }); + + expect(response.statusCode).toBe(400); + expect(response.json()).toEqual({ error: "doseIds array is required" }); + }); + + it("should reject missing doseIds", async () => { + const response = await ctx.app.inject({ + method: "POST", + url: "/doses/dismiss", + payload: {}, + }); + + expect(response.statusCode).toBe(400); + expect(response.json()).toEqual({ error: "doseIds array is required" }); + }); + + it("should dismiss a dose that was already taken (convert to dismissed)", async () => { + const doseId = "1-0-1735344000000"; + + // First mark as taken + await ctx.app.inject({ + method: "POST", + url: "/doses/taken", + payload: { doseId }, + }); + + // Then dismiss it + const response = await ctx.app.inject({ + method: "POST", + url: "/doses/dismiss", + payload: { doseIds: [doseId] }, + }); + + expect(response.statusCode).toBe(200); + expect(response.json()).toEqual({ success: true, dismissedCount: 1 }); + + // Verify it's now dismissed + const result = await ctx.client.execute({ + sql: `SELECT dismissed FROM dose_tracking WHERE user_id = ? AND dose_id = ?`, + args: [userId, doseId], + }); + expect(result.rows[0].dismissed).toBe(1); + }); + }); }); diff --git a/backend/src/test/e2e-routes.test.ts b/backend/src/test/e2e-routes.test.ts index 1e03d1e..0e21628 100644 --- a/backend/src/test/e2e-routes.test.ts +++ b/backend/src/test/e2e-routes.test.ts @@ -139,6 +139,7 @@ async function createSchema(client: Client) { dose_id text NOT NULL, taken_at integer NOT NULL DEFAULT (strftime('%s','now')), marked_by text, + dismissed integer NOT NULL DEFAULT 0, FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE )`, ]; diff --git a/backend/src/test/integration.test.ts b/backend/src/test/integration.test.ts index 59877ff..164533a 100644 --- a/backend/src/test/integration.test.ts +++ b/backend/src/test/integration.test.ts @@ -136,6 +136,7 @@ async function createSchema(client: Client) { dose_id text NOT NULL, taken_at integer NOT NULL DEFAULT (strftime('%s','now')), marked_by text, + dismissed integer NOT NULL DEFAULT 0, FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE )`, ]; diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index f015671..27584ca 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -341,6 +341,10 @@ function AppContent() { const [scheduleDays, setScheduleDays] = useState(30); const [showPastDays, setShowPastDays] = useState(false); const [takenDoses, setTakenDoses] = useState>(new Set()); + const [dismissedDoses, setDismissedDoses] = useState>(new Set()); + // Clear missed doses confirmation dialog + const [showClearMissedConfirm, setShowClearMissedConfirm] = useState(false); + const [clearingMissed, setClearingMissed] = useState(false); // Tag input state for "Taken By" field const [takenByInput, setTakenByInput] = useState(""); // Share dialog state @@ -384,7 +388,17 @@ function AppContent() { const res = await fetch("/api/doses/taken", { credentials: "include" }); if (res.ok) { const data = await res.json(); - setTakenDoses(new Set(data.doses.map((d: { doseId: string }) => d.doseId))); + const taken = new Set(); + const dismissed = new Set(); + for (const d of data.doses) { + if (d.dismissed) { + dismissed.add(d.doseId); + } else { + taken.add(d.doseId); + } + } + setTakenDoses(taken); + setDismissedDoses(dismissed); } // Don't reset on error - keep current state } catch { @@ -467,6 +481,35 @@ function AppContent() { } } + // Dismiss missed doses without deducting from stock + async function dismissMissedDoses(doseIds: string[]) { + if (doseIds.length === 0) return; + + setClearingMissed(true); + try { + const res = await fetch("/api/doses/dismiss", { + method: "POST", + headers: { "Content-Type": "application/json" }, + credentials: "include", + body: JSON.stringify({ doseIds }), + }); + + if (res.ok) { + // Update local state - move these from neither set to dismissed set + setDismissedDoses((prev) => { + const next = new Set(prev); + for (const id of doseIds) next.add(id); + return next; + }); + setShowClearMissedConfirm(false); + } + } catch { + // Error - dialog stays open + } finally { + setClearingMissed(false); + } + } + // Close modal on Escape key useEffect(() => { const handleEscape = (e: KeyboardEvent) => { @@ -580,6 +623,20 @@ function AppContent() { const pastDays = useMemo(() => groupedSchedule.filter(d => d.isPast), [groupedSchedule]); const futureDays = useMemo(() => groupedSchedule.filter(d => !d.isPast).slice(0, scheduleDays), [groupedSchedule, scheduleDays]); + // Calculate missed past dose IDs for the "Clear missed" feature + const missedPastDoseIds = useMemo(() => { + const totalPastDoses = pastDays.flatMap(d => + d.meds.flatMap(m => + m.doses.flatMap(dose => + (dose.takenBy || []).length > 0 + ? dose.takenBy.map((p: string) => `${dose.id}-${p}`) + : [dose.id] + ) + ) + ); + return totalPastDoses.filter(id => !takenDoses.has(id) && !dismissedDoses.has(id)); + }, [pastDays, takenDoses, dismissedDoses]); + // Load medications and settings when user changes (or on initial mount) useEffect(() => { loadMeds(); @@ -1467,31 +1524,46 @@ function AppContent() {
{/* Past days toggle */} {pastDays.length > 0 && (() => { + const missedCount = missedPastDoseIds.length; const totalPastDoses = pastDays.flatMap(d => d.meds.flatMap(m => m.doses.flatMap(dose => (dose.takenBy || []).length > 0 ? dose.takenBy.map(p => `${dose.id}-${p}`) : [dose.id]))); - const missedPastDoses = totalPastDoses.filter(id => !takenDoses.has(id)).length; return ( -
0 ? 'has-missed' : ''}`} - onClick={() => setShowPastDays(!showPastDays)} - > - {showPastDays ? '▼' : '▶'} - - {showPastDays ? t('dashboard.schedules.hidePastDays') : t('dashboard.schedules.showPastDays')} - - ({t('dashboard.schedules.pastDaysCount', { count: pastDays.length })}) - {missedPastDoses > 0 ? ( - ⚠️ {missedPastDoses} - ) : totalPastDoses.length > 0 ? ( - - ) : null} +
+
0 ? 'has-missed' : ''}`} + onClick={() => setShowPastDays(!showPastDays)} + > + {showPastDays ? '▼' : '▶'} + + {showPastDays ? t('dashboard.schedules.hidePastDays') : t('dashboard.schedules.showPastDays')} + + ({t('dashboard.schedules.pastDaysCount', { count: pastDays.length })}) + {missedCount > 0 ? ( + ⚠️ {missedCount} + ) : totalPastDoses.length > 0 ? ( + + ) : null} +
+ {missedCount > 0 && ( + + )}
); })()} {/* Past days (when expanded) */} {showPastDays && pastDays.map((day) => { const allDoseIds = day.meds.flatMap((item) => item.doses.flatMap((d) => (d.takenBy || []).length > 0 ? d.takenBy.map((p) => `${d.id}-${p}`) : [d.id])); - const allDayTaken = allDoseIds.length > 0 && allDoseIds.every((id) => takenDoses.has(id)); - const takenCount = allDoseIds.filter((id) => takenDoses.has(id)).length; + const allDayTaken = allDoseIds.length > 0 && allDoseIds.every((id) => takenDoses.has(id) || dismissedDoses.has(id)); + const takenCount = allDoseIds.filter((id) => takenDoses.has(id) || dismissedDoses.has(id)).length; const isAutoCollapsed = true; // Past days are always auto-collapsed const isManuallyExpanded = manuallyExpandedDays.has(day.dateStr); const isCollapsed = !isManuallyExpanded; @@ -1679,6 +1751,35 @@ function AppContent() {
+ + {/* Clear Missed Doses Confirmation Modal */} + {showClearMissedConfirm && ( +
setShowClearMissedConfirm(false)}> +
e.stopPropagation()} style={{maxWidth: "450px"}}> + +

{t('dashboard.schedules.clearMissedConfirmTitle')}

+

{t('dashboard.schedules.clearMissedConfirmMessage', { count: missedPastDoseIds.length })}

+
+ + +
+
+
+ )} } /> diff --git a/frontend/src/i18n/de.json b/frontend/src/i18n/de.json index fca9bfa..4510ecd 100644 --- a/frontend/src/i18n/de.json +++ b/frontend/src/i18n/de.json @@ -38,7 +38,14 @@ "pastDaysCount": "{{count}} Tag", "pastDaysCount_other": "{{count}} Tage", "missedDoses": "{{count}} verpasste Dosis", - "missedDoses_other": "{{count}} verpasste Dosen" + "missedDoses_other": "{{count}} verpasste Dosen", + "clearMissed": "Verpasste löschen", + "clearMissedConfirmTitle": "Verpasste Dosen löschen?", + "clearMissedConfirmMessage": "{{count}} verpasste Dosis wird als bestätigt markiert, ohne vom Bestand abgezogen zu werden.", + "clearMissedConfirmMessage_other": "{{count}} verpasste Dosen werden als bestätigt markiert, ohne vom Bestand abgezogen zu werden.", + "clearMissedConfirm": "Ja, löschen", + "clearMissedCancel": "Abbrechen", + "clearMissedSuccess": "{{count}} verpasste Dosen gelöscht" }, "reminders": { "active": "Automatische Erinnerungen aktiv", diff --git a/frontend/src/i18n/en.json b/frontend/src/i18n/en.json index 660906e..c4935b5 100644 --- a/frontend/src/i18n/en.json +++ b/frontend/src/i18n/en.json @@ -40,7 +40,14 @@ "pastDaysCount": "{{count}} day", "pastDaysCount_other": "{{count}} days", "missedDoses": "{{count}} missed dose", - "missedDoses_other": "{{count}} missed doses" + "missedDoses_other": "{{count}} missed doses", + "clearMissed": "Clear missed", + "clearMissedConfirmTitle": "Clear Missed Doses?", + "clearMissedConfirmMessage": "This will mark {{count}} missed dose as acknowledged without deducting from your stock.", + "clearMissedConfirmMessage_other": "This will mark {{count}} missed doses as acknowledged without deducting from your stock.", + "clearMissedConfirm": "Yes, Clear", + "clearMissedCancel": "Cancel", + "clearMissedSuccess": "Cleared {{count}} missed doses" }, "reminders": { "active": "Automatic reminders active", diff --git a/frontend/src/styles.css b/frontend/src/styles.css index f9700ae..1d4de9a 100644 --- a/frontend/src/styles.css +++ b/frontend/src/styles.css @@ -690,6 +690,35 @@ textarea.auto-resize { background: rgba(234, 179, 8, 0.08); } +/* Past days header container - toggle + clear button */ +.past-days-header { + display: flex; + align-items: center; + gap: 0.75rem; +} +.past-days-header .past-days-toggle { + flex: 1; +} +.clear-missed-btn { + padding: 0.5rem 0.75rem; + font-size: 0.8rem; + font-weight: 500; + background: rgba(234, 179, 8, 0.15); + color: var(--warning); + border: 1px solid var(--warning); + border-radius: var(--btn-radius); + cursor: pointer; + white-space: nowrap; + transition: background 150ms ease, transform 100ms ease; +} +.clear-missed-btn:hover { + background: rgba(234, 179, 8, 0.25); + transform: translateY(-1px); +} +.clear-missed-btn:active { + transform: translateY(0); +} + /* Past day blocks styling */ .day-block.past { opacity: 0.7;