feat: add reminder skip frontend flow
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
})}
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user