feat: add reminder skip frontend flow

This commit is contained in:
Daniel Volz
2026-05-10 23:24:18 +02:00
committed by GitHub
parent 4019716b9b
commit 5060d135ba
12 changed files with 602 additions and 139 deletions
+11 -1
View File
@@ -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<string>;
setTakenDoses: React.Dispatch<React.SetStateAction<Set<string>>>;
skippedDoses: Set<string>;
dismissedDoses: Set<string>;
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<void>;
markDoseSkipped: (doseId: string) => Promise<void>;
undoDoseTaken: (doseId: string) => Promise<void>;
undoDoseSkipped: (doseId: string) => Promise<void>;
// From useCollapsedDays
manuallyCollapsedDays: Set<string>;
@@ -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,
+178 -6
View File
@@ -10,13 +10,16 @@ export interface UseDosesReturn {
setTakenDoses: React.Dispatch<React.SetStateAction<Set<string>>>;
takenDoseTimestamps: Map<string, number>;
takenDoseSources: Map<string, "manual" | "automatic">;
skippedDoses: Set<string>;
dismissedDoses: Set<string>;
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<void>;
markDoseSkipped: (doseId: string) => Promise<void>;
undoDoseTaken: (doseId: string) => Promise<void>;
undoDoseSkipped: (doseId: string) => Promise<void>;
loadTakenDoses: () => Promise<void>;
}
@@ -56,7 +59,7 @@ export function useDoses(): UseDosesReturn {
const sources = new Map<string, "manual" | "automatic">();
const dismissed = new Set<string>();
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,
};
}
@@ -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,
+23 -19
View File
@@ -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",
+6 -2
View File
@@ -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",
+84 -61
View File
@@ -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 ? (
<button className="dose-btn undo take" onClick={() => undoDoseTaken(options.doseId)} title={t("common.undo")}>
{options.isAutomaticallyTaken && (
<span className="info-tooltip" data-tooltip={t("tooltips.automaticTaken")}>
🤖
</span>
)}
<span className="dose-btn-label">{t("common.undo")}</span>
<span aria-hidden="true"></span>
</button>
) : (
<button
className={`dose-btn take${options.isEmpty ? " out-of-stock" : ""}`}
onClick={() => markDoseTaken(options.doseId)}
disabled={options.isEmpty || options.isSkipped}
title={options.isEmpty ? t("common.outOfStockTakeBlocked") : t("dose.markAsTaken")}
>
<span className="dose-btn-label">{t("dose.take")}</span>
<span aria-hidden="true">{options.isEmpty ? "⊘" : "✓"}</span>
</button>
);
const skipButton = options.isSkipped ? (
<button className="dose-btn undo skip" onClick={() => undoDoseSkipped(options.doseId)} title={t("common.undo")}>
<span className="dose-btn-label">{t("common.undo")}</span>
<span aria-hidden="true"></span>
</button>
) : (
<button
className="dose-btn skip"
onClick={() => markDoseSkipped(options.doseId)}
title={t("dose.markAsSkipped")}
disabled={options.isTaken}
>
<span className="dose-btn-label">{t("dose.skip")}</span>
</button>
);
return (
<>
{takeButton}
{skipButton}
</>
);
};
return (
<section className="grid">
<article className="card schedule-full">
@@ -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 (
<div key={doseId} className={`dose-person ${isTaken ? "taken" : ""}`}>
<div key={doseId} className={personClasses.join(" ")}>
{person && (
<span
className="person-name clickable"
@@ -335,35 +395,13 @@ export function SchedulePage() {
{person}
</span>
)}
{isTaken ? (
<button
className="dose-btn undo"
onClick={() => undoDoseTaken(doseId)}
title={t("common.undo")}
>
{isAutomaticallyTaken && (
<span
className="info-tooltip"
data-tooltip={t("tooltips.automaticTaken")}
>
🤖
</span>
)}
</button>
) : (
<button
className={`dose-btn take${isEmpty ? " out-of-stock" : ""}`}
onClick={() => markDoseTaken(doseId)}
disabled={isEmpty}
title={
isEmpty ? t("common.outOfStockTakeBlocked") : t("dose.markAsTaken")
}
>
<span className="dose-btn-label">{t("dose.take")}</span>
<span aria-hidden="true">{isEmpty ? "⊘" : "✓"}</span>
</button>
)}
{renderDoseActionButtons({
doseId,
isTaken,
isSkipped,
isAutomaticallyTaken,
isEmpty,
})}
</div>
);
})}
@@ -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 (
<div
key={doseId}
className={`dose-person ${isTaken ? "taken" : ""} ${isOverdue ? "overdue" : ""}`}
>
<div key={doseId} className={personClasses.join(" ")}>
{person && (
<span
className="person-name clickable"
@@ -568,30 +608,13 @@ export function SchedulePage() {
{person}
</span>
)}
{isTaken ? (
<button
className="dose-btn undo"
onClick={() => undoDoseTaken(doseId)}
title={t("common.undo")}
>
{isAutomaticallyTaken && (
<span className="info-tooltip" data-tooltip={t("tooltips.automaticTaken")}>
🤖
</span>
)}
</button>
) : (
<button
className={`dose-btn take${isEmpty ? " out-of-stock" : ""}`}
onClick={() => markDoseTaken(doseId)}
disabled={isEmpty}
title={isEmpty ? t("common.outOfStockTakeBlocked") : t("dose.markAsTaken")}
>
<span className="dose-btn-label">{t("dose.take")}</span>
<span aria-hidden="true">{isEmpty ? "⊘" : "✓"}</span>
</button>
)}
{renderDoseActionButtons({
doseId,
isTaken,
isSkipped,
isAutomaticallyTaken,
isEmpty,
})}
</div>
);
})}
+41 -29
View File
@@ -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() {
<div className="schedule-row">
<span className="schedule-label">{t("settings.schedule.nextCheck")}</span>
<span className="schedule-value">
{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",
})
)}
</span>
</div>
)}
@@ -751,13 +754,16 @@ export function SettingsPage() {
<div className="schedule-row">
<span className="schedule-label">{t("settings.schedule.lastStockSent")}</span>
<span className="schedule-value">
{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",
})
)}
</span>
</div>
)}
@@ -765,13 +771,16 @@ export function SettingsPage() {
<div className="schedule-row">
<span className="schedule-label">{t("settings.schedule.lastIntakeSent")}</span>
<span className="schedule-value">
{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",
})
)}
</span>
</div>
)}
@@ -779,13 +788,16 @@ export function SettingsPage() {
<div className="schedule-row">
<span className="schedule-label">{t("settings.schedule.lastPrescriptionSent")}</span>
<span className="schedule-value">
{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",
})
)}
</span>
</div>
)}
+19 -12
View File
@@ -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,
+63 -3
View File
@@ -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;
@@ -37,6 +37,7 @@ vi.mock("../../hooks", () => ({
vi.mock("../../utils/formatters", () => ({
getSystemLocale: () => "en-US",
setDefaultFormattingTimezone: vi.fn(),
}));
vi.mock("../../utils/schedule", async () => {
@@ -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(
<MemoryRouter>
<SchedulePage />
</MemoryRouter>
);
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(
<MemoryRouter>
<SchedulePage />
</MemoryRouter>
);
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(
<MemoryRouter>
<SchedulePage />
</MemoryRouter>
);
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(
<MemoryRouter>
<SchedulePage />
</MemoryRouter>
);
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(
<MemoryRouter>
<SchedulePage />
</MemoryRouter>
);
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();
+32 -6
View File
@@ -59,17 +59,43 @@ const TIMEZONE_TO_REGION: Record<string, string> = {
"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;
}
/**