From 5060d135ba87956fd9317ba8eeedc9477166efd7 Mon Sep 17 00:00:00 2001 From: Daniel Volz Date: Sun, 10 May 2026 23:24:18 +0200 Subject: [PATCH] feat: add reminder skip frontend flow --- frontend/src/context/AppContext.tsx | 12 +- frontend/src/hooks/useDoses.ts | 184 +++++++++++++++++- frontend/src/hooks/useScheduleController.ts | 3 + frontend/src/i18n/de.json | 42 ++-- frontend/src/i18n/en.json | 8 +- frontend/src/pages/SchedulePage.tsx | 145 ++++++++------ frontend/src/pages/SettingsPage.tsx | 70 ++++--- frontend/src/pages/dashboard-helpers.ts | 31 +-- frontend/src/styles/app-surfaces.css | 66 ++++++- frontend/src/test/context/AppContext.test.tsx | 1 + frontend/src/test/pages/SchedulePage.test.tsx | 141 ++++++++++++++ frontend/src/utils/formatters.ts | 38 +++- 12 files changed, 602 insertions(+), 139 deletions(-) diff --git a/frontend/src/context/AppContext.tsx b/frontend/src/context/AppContext.tsx index 80e664a..3b4763f 100644 --- a/frontend/src/context/AppContext.tsx +++ b/frontend/src/context/AppContext.tsx @@ -11,7 +11,7 @@ import { type ScheduleEvent, type StockThresholds, } from "../types"; -import { getSystemLocale } from "../utils/formatters"; +import { getSystemLocale, setDefaultFormattingTimezone } from "../utils/formatters"; import { log } from "../utils/logger"; import { buildSchedulePreview, calculateCoverage, computeMissedPastDoseIds, getStockStatus } from "../utils/schedule"; import { ShareContextProvider } from "./ShareContext"; @@ -77,12 +77,15 @@ export interface AppContextValue { // From useDoses takenDoses: Set; setTakenDoses: React.Dispatch>>; + skippedDoses: Set; dismissedDoses: Set; getDoseId: (baseDoseId: string, person: string | null) => string; isDoseTakenAutomatically: (doseId: string) => boolean; countTakenDoses: (doses: Array<{ id: string; takenBy: string[] }>) => { total: number; taken: number }; markDoseTaken: (doseId: string) => Promise; + markDoseSkipped: (doseId: string) => Promise; undoDoseTaken: (doseId: string) => Promise; + undoDoseSkipped: (doseId: string) => Promise; // From useCollapsedDays manuallyCollapsedDays: Set; @@ -299,6 +302,10 @@ export function AppProvider({ children }: { children: React.ReactNode }) { shares: number; } | null>(null); + useEffect(() => { + setDefaultFormattingTimezone(settingsHook.settings.timezone || settingsHook.settings.serverTimezone || null); + }, [settingsHook.settings.timezone, settingsHook.settings.serverTimezone]); + // Load user-specific scheduleDays when user changes useEffect(() => { if (typeof window !== "undefined" && user?.id) { @@ -848,12 +855,15 @@ export function AppProvider({ children }: { children: React.ReactNode }) { // From useDoses takenDoses: doses.takenDoses, setTakenDoses: doses.setTakenDoses, + skippedDoses: doses.skippedDoses, dismissedDoses: doses.dismissedDoses, getDoseId: doses.getDoseId, isDoseTakenAutomatically: doses.isDoseTakenAutomatically, countTakenDoses: doses.countTakenDoses, markDoseTaken: doses.markDoseTaken, + markDoseSkipped: doses.markDoseSkipped, undoDoseTaken: doses.undoDoseTaken, + undoDoseSkipped: doses.undoDoseSkipped, // From useCollapsedDays manuallyCollapsedDays: collapsed.manuallyCollapsedDays, diff --git a/frontend/src/hooks/useDoses.ts b/frontend/src/hooks/useDoses.ts index 4af8f2e..a0ca67a 100644 --- a/frontend/src/hooks/useDoses.ts +++ b/frontend/src/hooks/useDoses.ts @@ -10,13 +10,16 @@ export interface UseDosesReturn { setTakenDoses: React.Dispatch>>; takenDoseTimestamps: Map; takenDoseSources: Map; + skippedDoses: Set; dismissedDoses: Set; clearDosesState: () => void; getDoseId: (baseDoseId: string, person: string | null) => string; isDoseTakenAutomatically: (doseId: string) => boolean; countTakenDoses: (doses: Array<{ id: string; takenBy: string[] }>) => { total: number; taken: number }; markDoseTaken: (doseId: string) => Promise; + markDoseSkipped: (doseId: string) => Promise; undoDoseTaken: (doseId: string) => Promise; + undoDoseSkipped: (doseId: string) => Promise; loadTakenDoses: () => Promise; } @@ -56,7 +59,7 @@ export function useDoses(): UseDosesReturn { const sources = new Map(); const dismissed = new Set(); for (const d of data.doses) { - if (d.dismissed) { + if (d.skipped === true || d.dismissed === true) { dismissed.add(d.doseId); } else { taken.add(d.doseId); @@ -127,6 +130,15 @@ export function useDoses(): UseDosesReturn { const markDoseTaken = useCallback( async (doseId: string) => { + if (dismissedDoses.has(doseId)) { + return; + } + + const wasTaken = takenDoses.has(doseId); + const wasSkipped = dismissedDoses.has(doseId); + const previousTimestamp = takenDoseTimestamps.get(doseId); + const previousSource = takenDoseSources.get(doseId); + // Optimistic update mutationInFlightRef.current++; setTakenDoses((prev) => { @@ -134,6 +146,11 @@ export function useDoses(): UseDosesReturn { next.add(doseId); return next; }); + setDismissedDoses((prev) => { + const next = new Set(prev); + next.delete(doseId); + return next; + }); setTakenDoseTimestamps((prev) => { const next = new Map(prev); next.set(doseId, Date.now()); @@ -163,17 +180,38 @@ export function useDoses(): UseDosesReturn { // Revert on error setTakenDoses((prev) => { const next = new Set(prev); - next.delete(doseId); + if (wasTaken) { + next.add(doseId); + } else { + next.delete(doseId); + } + return next; + }); + setDismissedDoses((prev) => { + const next = new Set(prev); + if (wasSkipped) { + next.add(doseId); + } else { + next.delete(doseId); + } return next; }); setTakenDoseTimestamps((prev) => { const next = new Map(prev); - next.delete(doseId); + if (wasTaken && typeof previousTimestamp === "number") { + next.set(doseId, previousTimestamp); + } else { + next.delete(doseId); + } return next; }); setTakenDoseSources((prev) => { const next = new Map(prev); - next.delete(doseId); + if (wasTaken && previousSource) { + next.set(doseId, previousSource); + } else { + next.delete(doseId); + } return next; }); } finally { @@ -182,11 +220,96 @@ export function useDoses(): UseDosesReturn { loadTakenDoses(); } }, - [getErrorCode, loadTakenDoses, t] + [dismissedDoses, getErrorCode, loadTakenDoses, t, takenDoseSources, takenDoseTimestamps, takenDoses] + ); + + const markDoseSkipped = useCallback( + async (doseId: string) => { + if (takenDoses.has(doseId)) { + return; + } + + const wasTaken = takenDoses.has(doseId); + const wasSkipped = dismissedDoses.has(doseId); + const previousTimestamp = takenDoseTimestamps.get(doseId); + const previousSource = takenDoseSources.get(doseId); + + mutationInFlightRef.current++; + setDismissedDoses((prev) => { + const next = new Set(prev); + next.add(doseId); + return next; + }); + setTakenDoses((prev) => { + const next = new Set(prev); + next.delete(doseId); + return next; + }); + setTakenDoseTimestamps((prev) => { + const next = new Map(prev); + next.delete(doseId); + return next; + }); + setTakenDoseSources((prev) => { + const next = new Map(prev); + next.delete(doseId); + return next; + }); + + try { + const response = await fetch("/api/doses/skip", { + method: "POST", + headers: { "Content-Type": "application/json" }, + credentials: "include", + body: JSON.stringify({ doseId }), + }); + if (!response.ok) { + throw new Error("Failed to mark dose as skipped"); + } + } catch { + setDismissedDoses((prev) => { + const next = new Set(prev); + if (wasSkipped) { + next.add(doseId); + } else { + next.delete(doseId); + } + return next; + }); + setTakenDoses((prev) => { + const next = new Set(prev); + if (wasTaken) { + next.add(doseId); + } + return next; + }); + setTakenDoseTimestamps((prev) => { + const next = new Map(prev); + if (wasTaken && typeof previousTimestamp === "number") { + next.set(doseId, previousTimestamp); + } + return next; + }); + setTakenDoseSources((prev) => { + const next = new Map(prev); + if (wasTaken && previousSource) { + next.set(doseId, previousSource); + } + return next; + }); + } finally { + mutationInFlightRef.current--; + loadTakenDoses(); + } + }, + [dismissedDoses, loadTakenDoses, takenDoseSources, takenDoseTimestamps, takenDoses] ); const undoDoseTaken = useCallback( async (doseId: string) => { + const previousTimestamp = takenDoseTimestamps.get(doseId); + const previousSource = takenDoseSources.get(doseId); + // Optimistic update mutationInFlightRef.current++; setTakenDoses((prev) => { @@ -218,13 +341,59 @@ export function useDoses(): UseDosesReturn { next.add(doseId); return next; }); + setTakenDoseTimestamps((prev) => { + const next = new Map(prev); + if (typeof previousTimestamp === "number") { + next.set(doseId, previousTimestamp); + } + return next; + }); + setTakenDoseSources((prev) => { + const next = new Map(prev); + if (previousSource) { + next.set(doseId, previousSource); + } + return next; + }); } finally { mutationInFlightRef.current--; // Re-sync with server after mutation completes loadTakenDoses(); } }, - [loadTakenDoses] + [loadTakenDoses, takenDoseSources, takenDoseTimestamps] + ); + + const undoDoseSkipped = useCallback( + async (doseId: string) => { + const wasSkipped = dismissedDoses.has(doseId); + + mutationInFlightRef.current++; + setDismissedDoses((prev) => { + const next = new Set(prev); + next.delete(doseId); + return next; + }); + + try { + await fetch(`/api/doses/skip/${encodeURIComponent(doseId)}`, { + method: "DELETE", + credentials: "include", + }); + } catch { + setDismissedDoses((prev) => { + const next = new Set(prev); + if (wasSkipped) { + next.add(doseId); + } + return next; + }); + } finally { + mutationInFlightRef.current--; + loadTakenDoses(); + } + }, + [dismissedDoses, loadTakenDoses] ); return { @@ -232,13 +401,16 @@ export function useDoses(): UseDosesReturn { setTakenDoses, takenDoseTimestamps, takenDoseSources, + skippedDoses: dismissedDoses, dismissedDoses, clearDosesState, getDoseId, isDoseTakenAutomatically, countTakenDoses, markDoseTaken, + markDoseSkipped, undoDoseTaken, + undoDoseSkipped, loadTakenDoses, }; } diff --git a/frontend/src/hooks/useScheduleController.ts b/frontend/src/hooks/useScheduleController.ts index c1a4b32..88e048a 100644 --- a/frontend/src/hooks/useScheduleController.ts +++ b/frontend/src/hooks/useScheduleController.ts @@ -23,8 +23,11 @@ export function useScheduleController() { futureDays: ctx.futureDays, takenDoses: ctx.takenDoses, dismissedDoses: ctx.dismissedDoses, + skippedDoses: ctx.skippedDoses, markDoseTaken: ctx.markDoseTaken, + markDoseSkipped: ctx.markDoseSkipped, undoDoseTaken: ctx.undoDoseTaken, + undoDoseSkipped: ctx.undoDoseSkipped, manuallyCollapsedDays: ctx.manuallyCollapsedDays, manuallyExpandedDays: ctx.manuallyExpandedDays, toggleDayCollapse: ctx.toggleDayCollapse, diff --git a/frontend/src/i18n/de.json b/frontend/src/i18n/de.json index a559e78..ac0c92f 100644 --- a/frontend/src/i18n/de.json +++ b/frontend/src/i18n/de.json @@ -110,7 +110,7 @@ "fullBlisters": "Volle Blister", "openBlister": "Offener Blister", "stock": "Bestand", - "dailyConsumption": "Taeglicher Verbrauch", + "dailyConsumption": "Täglicher Verbrauch", "stockDetails": "Details", "daysLeft": "Tage übrig", "status": "Status", @@ -133,7 +133,7 @@ "obsoleteTitle": "Obsolet ({{count}})", "obsoleteSince": "Beendet", "started": "Gestartet", - "emptyState": "Noch keine Medikamente. Fuege dein erstes Medikament hinzu." + "emptyState": "Noch keine Medikamente. Füge dein erstes Medikament hinzu." }, "details": { "packs": "Packungen", @@ -142,7 +142,7 @@ "loose": "Lose", "total": "Gesamt", "stock": "Bestand", - "capacityPerPackage": "Kapazitaet pro Packung", + "capacityPerPackage": "Kapazität pro Packung", "totalCapacity": "Kapazität", "type": "Typ" }, @@ -174,17 +174,17 @@ "medicationForm": "Medikationsform", "medicationFormCapsule": "Kapsel", "medicationFormTablet": "Tablette", - "medicationFormLiquid": "Fluessigkeit", + "medicationFormLiquid": "Flüssigkeit", "medicationFormTopical": "Topisch", "pillForm": "Pillenform", "lifecycleCategory": "Lebenszyklus", - "lifecycleRefillWhenEmpty": "Nachfuellen wenn leer", + "lifecycleRefillWhenEmpty": "Nachfüllen wenn leer", "lifecycleTreatmentPeriod": "Behandlungszeitraum", "packageType": "Verpackungsart", "packageTypeBlister": "Blisterpackung", "packageTypeBottle": "Pillendose", "packageTypeTube": "Tube", - "packageTypeLiquidContainer": "Fluessigbehaeltnis", + "packageTypeLiquidContainer": "Flüssigbehältnis", "packs": "Packungen", "bottles": "Flaschen", "tubes": "Tuben", @@ -317,17 +317,17 @@ "usageApplication": "Dosis (Anwendungen)", "intakeUnit": "Einnahmeeinheit", "intakeUnitMl": "Milliliter (ml)", - "intakeUnitTsp": "Teeloeffel (5 ml)", - "intakeUnitTbsp": "Essloeffel (15 ml)", + "intakeUnitTsp": "Teelöffel (5 ml)", + "intakeUnitTbsp": "Esslöffel (15 ml)", "intakes": "Einnahmen", "intakes_one": "Einnahme", "intakes_other": "Einnahmen", - "teaspoons": "Teeloeffel", - "teaspoons_one": "Teeloeffel", - "teaspoons_other": "Teeloeffel", - "tablespoons": "Essloeffel", - "tablespoons_one": "Essloeffel", - "tablespoons_other": "Essloeffel", + "teaspoons": "Teelöffel", + "teaspoons_one": "Teelöffel", + "teaspoons_other": "Teelöffel", + "tablespoons": "Esslöffel", + "tablespoons_one": "Esslöffel", + "tablespoons_other": "Esslöffel", "applications": "Anwendungen", "applications_one": "Anwendung", "applications_other": "Anwendungen", @@ -337,7 +337,7 @@ "everyDays": "Alle (Tage)", "every": "alle", "weekdays": "Wochentage", - "weekdaysRequired": "Waehle mindestens einen Wochentag aus", + "weekdaysRequired": "Wähle mindestens einen Wochentag aus", "weekdaysShort": { "mon": "Mo", "tue": "Di", @@ -548,7 +548,11 @@ "dose": { "takenBy": "eingenommen von", "markAsTaken": "Als eingenommen markieren", - "take": "Nehmen" + "take": "Nehmen", + "skip": "Überspringen", + "markAsSkipped": "Als übersprungen markieren", + "undoTake": "Nehmen rückgängig", + "undoSkip": "Überspringen rückgängig" }, "auth": { "login": "Anmelden", @@ -784,11 +788,11 @@ "loosePills": "Lose Tabletten", "pillsPerBlister": "(je {{count}} Tabletten)", "packageSize": "Packungsgröße: {{count}} Tabletten", - "packageSizeAmount": "Packungsgroesse: {{count}} {{unit}}", + "packageSizeAmount": "Packungsgröße: {{count}} {{unit}}", "packageSizeBreakdown": "{{packCount}} x {{sizePerPack}} Tabletten Packung = {{total}} Tabletten", "currentComposition": "Aktueller Bestand: {{fullBlisters}} volle Blister + {{partialPills}} angebrochen + {{loosePills}} lose = {{total}} Tabletten", "maxExceeded": "Die maximale Packungsgröße beträgt {{count}} Tabletten. Werte wurden begrenzt.", - "maxExceededAmount": "Die maximale Packungsgroesse betraegt {{count}} {{unit}}. Werte wurden begrenzt.", + "maxExceededAmount": "Die maximale Packungsgröße beträgt {{count}} {{unit}}. Werte wurden begrenzt.", "decreaseValue": "Wert verringern", "increaseValue": "Wert erhöhen", "currentTotal": "Aktueller Bestand", @@ -870,7 +874,7 @@ "docIntakeHistory": "Einnahme-Verlauf", "docDosesTaken": "Eingenommene Dosen", "docDosesTakenAutomatic": "Automatisch eingenommen", - "docDosesDismissed": "Verworfene Dosen", + "docDosesSkipped": "Übersprungene Dosen", "docFirstDose": "Erste Dosis", "docLastDose": "Letzte Dosis", "docRefillHistory": "Nachfüll-Verlauf", diff --git a/frontend/src/i18n/en.json b/frontend/src/i18n/en.json index f526db3..07fe140 100644 --- a/frontend/src/i18n/en.json +++ b/frontend/src/i18n/en.json @@ -548,7 +548,11 @@ "dose": { "takenBy": "taken by", "markAsTaken": "Mark as taken", - "take": "Take" + "take": "Take", + "skip": "Skip", + "markAsSkipped": "Mark as skipped", + "undoTake": "Undo take", + "undoSkip": "Undo skip" }, "auth": { "login": "Login", @@ -870,7 +874,7 @@ "docIntakeHistory": "Intake History", "docDosesTaken": "Doses taken", "docDosesTakenAutomatic": "Automatically taken", - "docDosesDismissed": "Doses dismissed", + "docDosesSkipped": "Doses skipped", "docFirstDose": "First dose", "docLastDose": "Last dose", "docRefillHistory": "Refill History", diff --git a/frontend/src/pages/SchedulePage.tsx b/frontend/src/pages/SchedulePage.tsx index 245e904..cac77a0 100644 --- a/frontend/src/pages/SchedulePage.tsx +++ b/frontend/src/pages/SchedulePage.tsx @@ -24,8 +24,8 @@ function getStockStatus( packageType?: string ) { if (isTubePackageType(packageType)) return { className: "success", label: "status.noSchedule" }; - // Out of stock or completely depleted = danger (red) - if (medsLeft <= 0 || daysLeft === 0) return { className: "danger", label: "status.outOfStock" }; + // Only a real zero-or-below stock count is out of stock. + if (medsLeft <= 0) return { className: "danger", label: "status.outOfStock" }; // No schedule, but has stock = normal if (daysLeft === null) return { className: "success", label: "status.noSchedule" }; if (isLiquidContainerPackageType(packageType)) { @@ -85,7 +85,10 @@ export function SchedulePage() { isDoseTakenAutomatically, dismissedDoses, markDoseTaken, + skippedDoses, + markDoseSkipped, undoDoseTaken, + undoDoseSkipped, coverageByMed, depletionByMed, manuallyExpandedDays, @@ -172,6 +175,59 @@ export function SchedulePage() { doses?: Array<{ usage: number; intakeUnit?: IntakeUnit | null }> ) => formatScheduleTotalUsageLabel(med, total, t, doses); + const renderDoseActionButtons = (options: { + doseId: string; + isTaken: boolean; + isSkipped: boolean; + isAutomaticallyTaken: boolean; + isEmpty: boolean; + }) => { + const takeButton = options.isTaken ? ( + + ) : ( + + ); + + const skipButton = options.isSkipped ? ( + + ) : ( + + ); + + return ( + <> + {takeButton} + {skipButton} + + ); + }; + return (
@@ -320,10 +376,14 @@ export function SchedulePage() { {people.map((person) => { const doseId = getDoseId(dose.id, person); const isTaken = isDoseTakenForDisplay(doseId); + const isSkipped = skippedDoses.has(doseId); const isAutomaticallyTaken = isTaken && isDoseTakenAutomatically(doseId) && dose.when <= Date.now(); + const personClasses = ["dose-person"]; + if (isTaken) personClasses.push("taken"); + if (isSkipped) personClasses.push("skipped"); return ( -
+
{person && ( )} - {isTaken ? ( - - ) : ( - - )} + {renderDoseActionButtons({ + doseId, + isTaken, + isSkipped, + isAutomaticallyTaken, + isEmpty, + })}
); })} @@ -549,14 +587,16 @@ export function SchedulePage() { {people.map((person) => { const doseId = getDoseId(dose.id, person); const isTaken = isDoseTakenForDisplay(doseId); + const isSkipped = skippedDoses.has(doseId); const isAutomaticallyTaken = isTaken && isDoseTakenAutomatically(doseId) && dose.when <= now; - const isOverdue = !isTaken && dose.when < now && !isPastDay; + const isOverdue = !isTaken && !isSkipped && !isEmpty && dose.when < now && !isPastDay; + const personClasses = ["dose-person"]; + if (isTaken) personClasses.push("taken"); + if (isSkipped) personClasses.push("skipped"); + if (isOverdue) personClasses.push("overdue"); return ( -
+
{person && ( )} - {isTaken ? ( - - ) : ( - - )} + {renderDoseActionButtons({ + doseId, + isTaken, + isSkipped, + isAutomaticallyTaken, + isEmpty, + })}
); })} diff --git a/frontend/src/pages/SettingsPage.tsx b/frontend/src/pages/SettingsPage.tsx index 4626a47..e9f61bd 100644 --- a/frontend/src/pages/SettingsPage.tsx +++ b/frontend/src/pages/SettingsPage.tsx @@ -3,7 +3,7 @@ import { useEffect, useState } from "react"; import { useTranslation } from "react-i18next"; import { ConfirmModal, ExportModal } from "../components"; import { useAppContext } from "../context"; -import { getSystemLocale } from "../utils/formatters"; +import { getSystemLocale, withFormattingTimezone } from "../utils/formatters"; export function SettingsPage() { const { t, i18n } = useTranslation(); @@ -737,13 +737,16 @@ export function SettingsPage() {
{t("settings.schedule.nextCheck")} - {new Date(settings.nextScheduledCheck).toLocaleString(getSystemLocale(i18n.language), { - day: "2-digit", - month: "2-digit", - year: "numeric", - hour: "2-digit", - minute: "2-digit", - })} + {new Date(settings.nextScheduledCheck).toLocaleString( + getSystemLocale(i18n.language), + withFormattingTimezone({ + day: "2-digit", + month: "2-digit", + year: "numeric", + hour: "2-digit", + minute: "2-digit", + }) + )}
)} @@ -751,13 +754,16 @@ export function SettingsPage() {
{t("settings.schedule.lastStockSent")} - {new Date(settings.lastStockReminderSent).toLocaleString(getSystemLocale(i18n.language), { - day: "2-digit", - month: "2-digit", - year: "numeric", - hour: "2-digit", - minute: "2-digit", - })} + {new Date(settings.lastStockReminderSent).toLocaleString( + getSystemLocale(i18n.language), + withFormattingTimezone({ + day: "2-digit", + month: "2-digit", + year: "numeric", + hour: "2-digit", + minute: "2-digit", + }) + )}
)} @@ -765,13 +771,16 @@ export function SettingsPage() {
{t("settings.schedule.lastIntakeSent")} - {new Date(settings.lastAutoEmailSent).toLocaleString(getSystemLocale(i18n.language), { - day: "2-digit", - month: "2-digit", - year: "numeric", - hour: "2-digit", - minute: "2-digit", - })} + {new Date(settings.lastAutoEmailSent).toLocaleString( + getSystemLocale(i18n.language), + withFormattingTimezone({ + day: "2-digit", + month: "2-digit", + year: "numeric", + hour: "2-digit", + minute: "2-digit", + }) + )}
)} @@ -779,13 +788,16 @@ export function SettingsPage() {
{t("settings.schedule.lastPrescriptionSent")} - {new Date(settings.lastPrescriptionReminderSent).toLocaleString(getSystemLocale(i18n.language), { - day: "2-digit", - month: "2-digit", - year: "numeric", - hour: "2-digit", - minute: "2-digit", - })} + {new Date(settings.lastPrescriptionReminderSent).toLocaleString( + getSystemLocale(i18n.language), + withFormattingTimezone({ + day: "2-digit", + month: "2-digit", + year: "numeric", + hour: "2-digit", + minute: "2-digit", + }) + )}
)} diff --git a/frontend/src/pages/dashboard-helpers.ts b/frontend/src/pages/dashboard-helpers.ts index 4b943ae..6b33dc5 100644 --- a/frontend/src/pages/dashboard-helpers.ts +++ b/frontend/src/pages/dashboard-helpers.ts @@ -1,5 +1,6 @@ import type { Coverage, Medication, PackageType } from "../types"; import { getMedTotal as getMedTotalFromTypes, isLiquidContainerPackageType, isTubePackageType } from "../types"; +import { withFormattingTimezone } from "../utils/formatters"; import { splitCurrentBlisterStock } from "../utils/stock"; export function userStorageKey(userId: number | undefined, key: string): string { @@ -132,12 +133,15 @@ export function getReminderStatusData( let lastStockSent: { date: string; medNames: string | null } | null = null; if (lastStockReminderSent) { const sentDate = new Date(lastStockReminderSent); - const formattedDate = sentDate.toLocaleDateString(locale, { - day: "2-digit", - month: "short", - hour: "2-digit", - minute: "2-digit", - }); + const formattedDate = sentDate.toLocaleDateString( + locale, + withFormattingTimezone({ + day: "2-digit", + month: "short", + hour: "2-digit", + minute: "2-digit", + }) + ); lastStockSent = { date: formattedDate, medNames: lastStockReminderMedNames, @@ -147,12 +151,15 @@ export function getReminderStatusData( let lastIntakeSent: { date: string; medName: string | null; takenBy: string | null } | null = null; if (lastAutoEmailSent) { const sentDate = new Date(lastAutoEmailSent); - const formattedDate = sentDate.toLocaleDateString(locale, { - day: "2-digit", - month: "short", - hour: "2-digit", - minute: "2-digit", - }); + const formattedDate = sentDate.toLocaleDateString( + locale, + withFormattingTimezone({ + day: "2-digit", + month: "short", + hour: "2-digit", + minute: "2-digit", + }) + ); lastIntakeSent = { date: formattedDate, medName: lastReminderMedName, diff --git a/frontend/src/styles/app-surfaces.css b/frontend/src/styles/app-surfaces.css index f31e16a..a89bb5a 100644 --- a/frontend/src/styles/app-surfaces.css +++ b/frontend/src/styles/app-surfaces.css @@ -2112,6 +2112,20 @@ button.has-validation-error { border-color: color-mix(in srgb, var(--danger) 42%, transparent); color: color-mix(in srgb, var(--danger) 82%, white 18%); } + +.time-row.notification-focus-target-row { + scroll-margin-top: 0.75rem; +} + +.time-row.notification-focus-target-row .med-name-text { + color: color-mix(in srgb, var(--primary) 88%, white 12%); +} + +.time-row.notification-focus-target-row .tag.subtle { + background: color-mix(in srgb, var(--primary) 12%, transparent); + border-color: color-mix(in srgb, var(--primary) 28%, transparent); +} + .time-main { display: flex; flex-direction: column; @@ -2209,12 +2223,12 @@ button.has-validation-error { color: var(--warning); } -.dose-item.overdue .dose-btn.take { +.dose-item.overdue .dose-btn.take:not(.undo):not(:disabled) { box-shadow: 0 0 0 2px var(--warning); animation: overduePulse 1.5s ease-in-out infinite; } -.dose-item.overdue .dose-btn.take:hover { +.dose-item.overdue .dose-btn.take:not(.undo):hover { filter: brightness(0.87); } @@ -2332,7 +2346,7 @@ button.has-validation-error { } .dose-btn .dose-btn-label { - display: none; + display: inline; } .dose-btn.take { @@ -2379,6 +2393,16 @@ button.has-validation-error { filter: none; } +.dose-btn.skip { + background: color-mix(in srgb, var(--warning) 18%, var(--bg-tertiary)); + border: 1px solid color-mix(in srgb, var(--warning) 52%, var(--border-primary)); + color: var(--text-primary); +} + +.dose-btn.skip:hover { + filter: brightness(0.94); +} + .dose-btn.take.out-of-stock, .dose-btn.take.out-of-stock:disabled, .dashboard-schedules-section .dose-btn.take.out-of-stock, @@ -2409,6 +2433,18 @@ button.has-validation-error { filter: brightness(0.9); } +.dose-btn.undo.take { + background: color-mix(in srgb, var(--success) 82%, var(--accent-bg)); + border-color: color-mix(in srgb, var(--success) 88%, white 12%); + color: #eafff6; +} + +.dose-btn.undo.skip { + background: color-mix(in srgb, var(--warning) 50%, var(--bg-tertiary)); + border-color: color-mix(in srgb, var(--warning) 68%, var(--border-primary)); + color: var(--text-primary); +} + /* Per-person dose tracking */ .dose-checks { display: flex; @@ -2426,10 +2462,20 @@ button.has-validation-error { background: rgba(255, 255, 255, 0.03); } +.dose-person.notification-focus-target { + background: color-mix(in srgb, var(--primary) 16%, var(--accent-bg)); + box-shadow: 0 0 0 2px color-mix(in srgb, var(--primary) 46%, transparent); + animation: notification-focus-pulse 1.5s ease 2; +} + .dose-person.taken { background: var(--success-bg); } +.dose-person.skipped { + background: color-mix(in srgb, var(--warning) 20%, var(--accent-bg)); +} + .dose-person.overdue { background: var(--warning-bg); } @@ -2457,6 +2503,10 @@ button.has-validation-error { color: var(--success); } +.dose-person.skipped .person-name { + color: color-mix(in srgb, var(--warning) 82%, var(--text-primary)); +} + .dose-person .dose-btn { margin-left: 0; height: 24px; @@ -2465,6 +2515,16 @@ button.has-validation-error { font-size: 0.75rem; } +@keyframes notification-focus-pulse { + 0%, + 100% { + box-shadow: 0 0 0 2px color-mix(in srgb, var(--primary) 46%, transparent); + } + 50% { + box-shadow: 0 0 0 5px color-mix(in srgb, var(--primary) 14%, transparent); + } +} + @media (min-width: 769px) { .time-row { grid-template-columns: minmax(170px, 230px) 1fr; diff --git a/frontend/src/test/context/AppContext.test.tsx b/frontend/src/test/context/AppContext.test.tsx index fc7594d..d008e74 100644 --- a/frontend/src/test/context/AppContext.test.tsx +++ b/frontend/src/test/context/AppContext.test.tsx @@ -37,6 +37,7 @@ vi.mock("../../hooks", () => ({ vi.mock("../../utils/formatters", () => ({ getSystemLocale: () => "en-US", + setDefaultFormattingTimezone: vi.fn(), })); vi.mock("../../utils/schedule", async () => { diff --git a/frontend/src/test/pages/SchedulePage.test.tsx b/frontend/src/test/pages/SchedulePage.test.tsx index b600a0c..6cde891 100644 --- a/frontend/src/test/pages/SchedulePage.test.tsx +++ b/frontend/src/test/pages/SchedulePage.test.tsx @@ -103,9 +103,12 @@ const createMockContext = (overrides = {}) => ({ pastDays: [], futureDays: [], takenDoses: new Set(), + skippedDoses: new Set(), dismissedDoses: new Set(), markDoseTaken: vi.fn(), + markDoseSkipped: vi.fn(), undoDoseTaken: vi.fn(), + undoDoseSkipped: vi.fn(), coverageByMed: {}, depletionByMed: {}, manuallyExpandedDays: new Set(), @@ -674,6 +677,144 @@ describe("SchedulePage with taken doses", () => { }); }); +describe("SchedulePage skip behavior", () => { + beforeEach(() => { + vi.clearAllMocks(); + localStorage.clear(); + mockContextValue = createMockContext({ + meds: mockMeds, + futureDays: mockFutureDays, + coverageByMed: mockCoverageByMed, + }); + }); + + it("shows a skip action alongside take for neutral doses", () => { + render( + + + + ); + + expect(document.querySelector(".dose-btn.take")).toBeInTheDocument(); + expect(document.querySelector(".dose-btn.skip")).toBeInTheDocument(); + }); + + it("calls markDoseSkipped when clicking skip", () => { + const markDoseSkipped = vi.fn(); + mockContextValue = createMockContext({ + meds: mockMeds, + futureDays: mockFutureDays, + coverageByMed: mockCoverageByMed, + markDoseSkipped, + }); + + render( + + + + ); + + const skipButton = document.querySelector(".dose-btn.skip"); + expect(skipButton).toBeInTheDocument(); + + if (skipButton) { + fireEvent.click(skipButton); + } + + expect(markDoseSkipped).toHaveBeenCalledWith(`1-0-${FIXED_TIMESTAMP}-John`); + }); + + it("renders undo skip state for skipped doses", () => { + const skippedDoseId = `1-0-${FIXED_TIMESTAMP}-John`; + mockContextValue = createMockContext({ + meds: mockMeds, + futureDays: mockFutureDays, + coverageByMed: mockCoverageByMed, + skippedDoses: new Set([skippedDoseId]), + }); + + render( + + + + ); + + expect(document.querySelector(".dose-btn.undo.skip")).toBeInTheDocument(); + expect(screen.getByText("John").closest(".dose-person")).toHaveClass("skipped"); + }); + + it("calls undoDoseSkipped when clicking undo skip", () => { + const skippedDoseId = `1-0-${FIXED_TIMESTAMP}-John`; + const undoDoseSkipped = vi.fn(); + mockContextValue = createMockContext({ + meds: mockMeds, + futureDays: mockFutureDays, + coverageByMed: mockCoverageByMed, + skippedDoses: new Set([skippedDoseId]), + undoDoseSkipped, + }); + + render( + + + + ); + + const undoSkipButton = document.querySelector(".dose-btn.undo.skip"); + expect(undoSkipButton).toBeInTheDocument(); + + if (undoSkipButton) { + fireEvent.click(undoSkipButton); + } + + expect(undoDoseSkipped).toHaveBeenCalledWith(skippedDoseId); + }); + + it("does not mark skipped due doses as overdue", () => { + vi.useFakeTimers(); + const now = new Date("2026-01-22T12:00:00.000Z"); + vi.setSystemTime(now); + + const when = new Date("2026-01-22T09:00:00.000Z").getTime(); + const baseDoseId = `1-0-${when}`; + const skippedDoseId = `${baseDoseId}-John`; + const dueDay = [ + { + dateStr: "Wed, Jan 22", + date: new Date(now), + isPast: false, + meds: [ + { + medName: "Aspirin", + total: 1, + doses: [{ id: baseDoseId, timeStr: "09:00", when, usage: 1, takenBy: ["John"] }], + lastWhen: when, + }, + ], + }, + ]; + + mockContextValue = createMockContext({ + meds: mockMeds, + futureDays: dueDay, + coverageByMed: mockCoverageByMed, + skippedDoses: new Set([skippedDoseId]), + }); + + render( + + + + ); + + const personRow = screen.getByText("John").closest(".dose-person"); + expect(personRow).toHaveClass("skipped"); + expect(personRow).not.toHaveClass("overdue"); + + vi.useRealTimers(); + }); +}); + describe("SchedulePage with low stock", () => { beforeEach(() => { vi.clearAllMocks(); diff --git a/frontend/src/utils/formatters.ts b/frontend/src/utils/formatters.ts index aa26f8f..7e476e5 100644 --- a/frontend/src/utils/formatters.ts +++ b/frontend/src/utils/formatters.ts @@ -59,17 +59,43 @@ const TIMEZONE_TO_REGION: Record = { "Pacific/Auckland": "NZ", }; +let defaultFormattingTimezone: string | null = null; + +export function setDefaultFormattingTimezone(timezone: string | null | undefined): void { + defaultFormattingTimezone = timezone?.trim() || null; +} + +export function getFormattingTimezone(): string | undefined { + if (defaultFormattingTimezone) { + return defaultFormattingTimezone; + } + + try { + return Intl.DateTimeFormat().resolvedOptions().timeZone; + } catch { + return undefined; + } +} + +export function withFormattingTimezone(options: Intl.DateTimeFormatOptions): Intl.DateTimeFormatOptions { + const timezone = getFormattingTimezone(); + if (!timezone) { + return options; + } + + return { + ...options, + timeZone: timezone, + }; +} + /** * Get region code from timezone. * Returns undefined if timezone is not mapped. */ export function getRegionFromTimezone(): string | undefined { - try { - const timezone = Intl.DateTimeFormat().resolvedOptions().timeZone; - return TIMEZONE_TO_REGION[timezone]; - } catch { - return undefined; - } + const timezone = getFormattingTimezone(); + return timezone ? TIMEZONE_TO_REGION[timezone] : undefined; } /**