feat: add shared overview and harden frontend session state (#407)
This commit is contained in:
@@ -13,7 +13,7 @@ import { AppHeader } from "./components/AppHeader";
|
||||
import { AuthPage, AuthProvider, useAuth } from "./components/Auth";
|
||||
import { AppProvider, UnsavedChangesProvider, useAppContext } from "./context";
|
||||
import { useScrollLock } from "./hooks/useScrollLock";
|
||||
import { DashboardPage, MedicationsPage, PlannerPage, SchedulePage, SettingsPage } from "./pages";
|
||||
import { DashboardPage, MedicationsPage, PlannerPage, SchedulePage, SettingsPage, SharedOverviewPage } from "./pages";
|
||||
|
||||
// Vite injects this at build time from package.json
|
||||
declare const __APP_VERSION__: string;
|
||||
@@ -29,6 +29,7 @@ export default function App() {
|
||||
<AuthProvider>
|
||||
<Routes>
|
||||
{/* Public share route - accessible without auth */}
|
||||
<Route path="/share/:token/overview" element={<SharedOverviewPage />} />
|
||||
<Route path="/share/:token" element={<SharedSchedule />} />
|
||||
{/* All other routes go through AppRouter */}
|
||||
<Route path="*" element={<AppRouter />} />
|
||||
|
||||
@@ -16,6 +16,7 @@ import { Lightbox, MedicationAvatar } from "../components";
|
||||
import { useEscapeKey } from "../hooks";
|
||||
import type { Coverage, Medication, RefillEntry, StockThresholds } from "../types";
|
||||
import {
|
||||
allowsPillFormSelection,
|
||||
getMedDisplayName,
|
||||
getMedTotal,
|
||||
getPackageSize,
|
||||
@@ -245,6 +246,14 @@ export function MedDetailModal({
|
||||
const closeLabel = t("common.close");
|
||||
const decrementLabel = t("editStock.decreaseValue");
|
||||
const incrementLabel = t("editStock.increaseValue");
|
||||
const showPillWeightDetails = allowsPillFormSelection(selectedMed.packageType) && !!selectedMed.pillWeightMg;
|
||||
const pillWeightMg = showPillWeightDetails ? (selectedMed.pillWeightMg ?? 0) : 0;
|
||||
const isTubeRefillPackage = isTubePackageType(selectedMed.packageType);
|
||||
const isLiquidRefillPackage =
|
||||
isLiquidContainerPackageType(selectedMed.packageType) || selectedMed.medicationForm === "liquid";
|
||||
const isCountBasedAmountRefillPackage = isLiquidRefillPackage || isTubeRefillPackage;
|
||||
const liquidRefillAmountPerBottle = Math.max(1, Math.round(Number.isFinite(amountPerPackage) ? amountPerPackage : 1));
|
||||
const amountRefillPackageCount = Math.max(0, Math.round(refillLoose / liquidRefillAmountPerBottle));
|
||||
const getScheduleUsageLabel = (usage: number, intakeUnit?: "ml" | "tsp" | "tbsp" | null) => {
|
||||
if (isLiquidContainerPackageType(selectedMed.packageType)) {
|
||||
if (intakeUnit === "tsp") {
|
||||
@@ -934,7 +943,7 @@ export function MedDetailModal({
|
||||
<span className="med-detail-value">{(selectedMed.totalPills ?? packageSize) || "—"}</span>
|
||||
</div>
|
||||
)}
|
||||
{selectedMed.pillWeightMg && (
|
||||
{showPillWeightDetails && (
|
||||
<div className="med-detail-item">
|
||||
<span className="med-detail-label">{t("modal.pillWeight")}</span>
|
||||
<span className="med-detail-value">
|
||||
@@ -984,8 +993,7 @@ export function MedDetailModal({
|
||||
>
|
||||
<span className="med-schedule-usage">
|
||||
{getScheduleUsageLabel(totalUsage, intake.intakeUnit)}
|
||||
{selectedMed.pillWeightMg &&
|
||||
` (${totalUsage * selectedMed.pillWeightMg} ${selectedMed.doseUnit ?? "mg"})`}
|
||||
{showPillWeightDetails && ` (${totalUsage * pillWeightMg} ${selectedMed.doseUnit ?? "mg"})`}
|
||||
</span>
|
||||
<span className="med-schedule-freq">
|
||||
{intake.every === 1 ? t("common.daily") : t("common.everyNDays", { count: intake.every })}
|
||||
@@ -1236,6 +1244,23 @@ export function MedDetailModal({
|
||||
})}
|
||||
</label>
|
||||
</>
|
||||
) : isCountBasedAmountRefillPackage ? (
|
||||
<label>
|
||||
{isTubeRefillPackage ? t("form.tubes") : t("form.bottles")}
|
||||
{renderRefillStepperInput({
|
||||
value: amountRefillPackageCount,
|
||||
min: 0,
|
||||
max: Number.MAX_SAFE_INTEGER,
|
||||
onChange: (nextPackages) => {
|
||||
onRefillPacksChange(nextPackages);
|
||||
onRefillLooseChange(nextPackages * liquidRefillAmountPerBottle);
|
||||
},
|
||||
})}
|
||||
<p className="edit-stock-cap-info" style={{ marginTop: "0.35rem" }}>
|
||||
{isTubeRefillPackage ? t("form.packageAmountPerTube") : t("form.packageAmountPerBottle")}:{" "}
|
||||
{formatNumber(liquidRefillAmountPerBottle)} {amountUnitLabel}
|
||||
</p>
|
||||
</label>
|
||||
) : (
|
||||
<label>
|
||||
{t("refill.pillsToAdd")}
|
||||
@@ -1286,7 +1311,9 @@ export function MedDetailModal({
|
||||
onClick={() => onSubmitRefill(selectedMed.id, usePrescriptionRefill)}
|
||||
disabled={
|
||||
(isAmountBasedPackageType(selectedMed.packageType)
|
||||
? refillLoose < 1
|
||||
? isCountBasedAmountRefillPackage
|
||||
? amountRefillPackageCount < 1
|
||||
: refillLoose < 1
|
||||
: cappedRefillPacks < 1 && refillLoose < 1) ||
|
||||
exceedsPrescriptionPackLimit ||
|
||||
refillSaving
|
||||
@@ -1297,7 +1324,9 @@ export function MedDetailModal({
|
||||
{(() => {
|
||||
const totalRefill = !isAmountBasedPackageType(selectedMed.packageType)
|
||||
? cappedRefillPacks * selectedMed.blistersPerPack * selectedMed.pillsPerBlister + refillLoose
|
||||
: refillLoose;
|
||||
: isCountBasedAmountRefillPackage
|
||||
? amountRefillPackageCount * liquidRefillAmountPerBottle
|
||||
: refillLoose;
|
||||
return totalRefill > 0 ? (
|
||||
<span className="refill-preview">
|
||||
+{totalRefill}
|
||||
|
||||
@@ -435,7 +435,7 @@ export function MobileEditModal({
|
||||
<label className="full">
|
||||
{t("form.packageType")}
|
||||
<select
|
||||
className="package-type-select"
|
||||
className="select-field package-type-select"
|
||||
value={form.packageType}
|
||||
onChange={(e) => onHandleValueChange("packageType", e.target.value as FormState["packageType"])}
|
||||
>
|
||||
@@ -458,6 +458,7 @@ export function MobileEditModal({
|
||||
<label className="full">
|
||||
{t("form.pillForm")}
|
||||
<select
|
||||
className="select-field"
|
||||
value={form.pillForm}
|
||||
onChange={(e) => onHandleValueChange("pillForm", e.target.value as FormState["pillForm"])}
|
||||
>
|
||||
@@ -469,7 +470,11 @@ export function MobileEditModal({
|
||||
{isTubePackageType(form.packageType) && (
|
||||
<label className="full">
|
||||
{t("form.medicationForm")}
|
||||
<select value={"topical"} onChange={() => onHandleValueChange("medicationForm", "topical")}>
|
||||
<select
|
||||
className="select-field"
|
||||
value={"topical"}
|
||||
onChange={() => onHandleValueChange("medicationForm", "topical")}
|
||||
>
|
||||
<option value="topical">{t("form.medicationFormTopical")}</option>
|
||||
</select>
|
||||
</label>
|
||||
@@ -477,7 +482,11 @@ export function MobileEditModal({
|
||||
{isLiquidContainerPackageType(form.packageType) && (
|
||||
<label className="full">
|
||||
{t("form.medicationForm")}
|
||||
<select value={"liquid"} onChange={() => onHandleValueChange("medicationForm", "liquid")}>
|
||||
<select
|
||||
className="select-field"
|
||||
value={"liquid"}
|
||||
onChange={() => onHandleValueChange("medicationForm", "liquid")}
|
||||
>
|
||||
<option value="liquid">{t("form.medicationFormLiquid")}</option>
|
||||
</select>
|
||||
</label>
|
||||
@@ -630,7 +639,7 @@ export function MobileEditModal({
|
||||
<select
|
||||
value="g"
|
||||
disabled
|
||||
className="dose-unit-select"
|
||||
className="select-field dose-unit-select"
|
||||
aria-label={t("form.packageAmountUnitG")}
|
||||
>
|
||||
<option value="g">{t("form.packageAmountUnitG")}</option>
|
||||
@@ -675,7 +684,7 @@ export function MobileEditModal({
|
||||
<select
|
||||
value="ml"
|
||||
disabled
|
||||
className="dose-unit-select"
|
||||
className="select-field dose-unit-select"
|
||||
aria-label={t("form.packageAmountUnitMl")}
|
||||
>
|
||||
<option value="ml">{t("form.packageAmountUnitMl")}</option>
|
||||
@@ -743,7 +752,7 @@ export function MobileEditModal({
|
||||
<select
|
||||
value={form.doseUnit}
|
||||
onChange={(e) => onFormChange({ ...form, doseUnit: e.target.value as DoseUnit })}
|
||||
className="dose-unit-select"
|
||||
className="select-field dose-unit-select"
|
||||
>
|
||||
{DOSE_UNITS.map((unit) => (
|
||||
<option key={unit.value} value={unit.value}>
|
||||
@@ -849,6 +858,7 @@ export function MobileEditModal({
|
||||
<label className="compact full-row">
|
||||
<span>{t("form.blisters.intakeUnit")}</span>
|
||||
<select
|
||||
className="select-field"
|
||||
value={intake.intakeUnit}
|
||||
onChange={(e) =>
|
||||
onSetIntakeValue(idx, "intakeUnit", e.target.value as "ml" | "tsp" | "tbsp")
|
||||
@@ -864,6 +874,7 @@ export function MobileEditModal({
|
||||
<label className="compact full-row taken-by-field">
|
||||
<span>{t("form.blisters.takenByIntake")}</span>
|
||||
<select
|
||||
className="select-field"
|
||||
value={intake.takenBy}
|
||||
onChange={(e) => onSetIntakeValue(idx, "takenBy", e.target.value)}
|
||||
>
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
*/
|
||||
|
||||
import { Check, Copy, Link2, X } from "lucide-react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
export interface ShareDialogProps {
|
||||
@@ -40,8 +41,49 @@ export function ShareDialog({
|
||||
onCopyShareLink,
|
||||
}: ShareDialogProps) {
|
||||
const { t } = useTranslation();
|
||||
const [overviewCopied, setOverviewCopied] = useState(false);
|
||||
const closeLabel = t("common.close");
|
||||
const copyLabel = shareCopied ? t("share.copied") : t("share.copyLink");
|
||||
const overviewCopyLabel = overviewCopied ? t("share.copied") : t("share.copyOverviewLink");
|
||||
const overviewLink = shareLink ? `${shareLink}/overview` : null;
|
||||
|
||||
useEffect(() => {
|
||||
if (!shareLink) {
|
||||
setOverviewCopied(false);
|
||||
}
|
||||
}, [shareLink]);
|
||||
|
||||
const copyOverviewLink = async () => {
|
||||
if (!overviewLink) return;
|
||||
|
||||
const markCopied = () => {
|
||||
setOverviewCopied(true);
|
||||
setTimeout(() => setOverviewCopied(false), 2000);
|
||||
};
|
||||
|
||||
if (navigator.clipboard?.writeText) {
|
||||
try {
|
||||
await navigator.clipboard.writeText(overviewLink);
|
||||
markCopied();
|
||||
return;
|
||||
} catch {
|
||||
// Fall back to textarea-based copy.
|
||||
}
|
||||
}
|
||||
|
||||
const textarea = document.createElement("textarea");
|
||||
textarea.value = overviewLink;
|
||||
textarea.style.position = "fixed";
|
||||
textarea.style.opacity = "0";
|
||||
document.body.appendChild(textarea);
|
||||
textarea.select();
|
||||
try {
|
||||
document.execCommand("copy");
|
||||
markCopied();
|
||||
} finally {
|
||||
document.body.removeChild(textarea);
|
||||
}
|
||||
};
|
||||
|
||||
// ESC is handled by the global handler in App.tsx to avoid double history.back()
|
||||
|
||||
@@ -91,6 +133,7 @@ export function ShareDialog({
|
||||
return (
|
||||
<div className="share-dialog-result">
|
||||
<p className="share-success">{t("share.linkGenerated")}</p>
|
||||
<p className="share-link-label">{t("share.scheduleLink")}</p>
|
||||
<div className="share-link-box">
|
||||
<input
|
||||
type="text"
|
||||
@@ -109,13 +152,34 @@ export function ShareDialog({
|
||||
{shareCopied ? <Check size={18} aria-hidden="true" /> : <Copy size={18} aria-hidden="true" />}
|
||||
</button>
|
||||
</div>
|
||||
<p className="share-link-label">{t("share.overviewLink")}</p>
|
||||
<div className="share-link-box">
|
||||
<input
|
||||
type="text"
|
||||
value={overviewLink ?? ""}
|
||||
readOnly
|
||||
className="share-link-input"
|
||||
onClick={(e) => (e.target as HTMLInputElement).select()}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className="btn-copy icon-only tooltip-trigger"
|
||||
onClick={copyOverviewLink}
|
||||
aria-label={overviewCopyLabel}
|
||||
data-tooltip={overviewCopyLabel}
|
||||
>
|
||||
{overviewCopied ? <Check size={18} aria-hidden="true" /> : <Copy size={18} aria-hidden="true" />}
|
||||
</button>
|
||||
</div>
|
||||
{shareCopied && <span className="share-copied-hint">{t("share.copied")}</span>}
|
||||
{overviewCopied && <span className="share-copied-hint">{t("share.copied")}</span>}
|
||||
<div className="share-dialog-footer">
|
||||
<button
|
||||
className="ghost"
|
||||
onClick={() => {
|
||||
onShareLinkChange(null);
|
||||
onShareCopiedChange(false);
|
||||
setOverviewCopied(false);
|
||||
}}
|
||||
>
|
||||
{t("share.generateAnother")}
|
||||
@@ -131,6 +195,7 @@ export function ShareDialog({
|
||||
<label htmlFor="share-person-select">{t("share.selectPerson")}</label>
|
||||
<select
|
||||
id="share-person-select"
|
||||
className="select-field"
|
||||
value={shareSelectedPerson}
|
||||
onChange={(e) => onShareSelectedPersonChange(e.target.value)}
|
||||
>
|
||||
@@ -146,6 +211,7 @@ export function ShareDialog({
|
||||
<label htmlFor="share-period-select">{t("share.selectPeriod")}</label>
|
||||
<select
|
||||
id="share-period-select"
|
||||
className="select-field"
|
||||
value={shareSelectedDays}
|
||||
onChange={(e) => onShareSelectedDaysChange(Number(e.target.value))}
|
||||
>
|
||||
|
||||
@@ -736,9 +736,14 @@ export function SharedSchedule() {
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="shared-schedule-page">
|
||||
<div className="shared-schedule-loading">
|
||||
<div className="shared-schedule-loading shared-schedule-loading-skeleton" aria-busy="true">
|
||||
<h1>💊 MedAssist-ng</h1>
|
||||
<p>{t("common.loading")}</p>
|
||||
<span className="screen-reader-only">{t("common.loading")}</span>
|
||||
<div className="skeleton-card">
|
||||
<span className="skeleton-line skeleton-line-long" />
|
||||
<span className="skeleton-line skeleton-line-medium" />
|
||||
<span className="skeleton-line skeleton-line-short" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -7,6 +7,7 @@ import { MedicationAvatar } from "../components";
|
||||
import { useEscapeKey } from "../hooks/useEscapeKey";
|
||||
import type { Coverage, Medication, StockThresholds } from "../types";
|
||||
import { getMedDisplayName, getMedTotal, getPackageSize } from "../types";
|
||||
import { allowsPillFormSelection, isLiquidContainerPackageType, isTubePackageType } from "../types/package-profiles";
|
||||
import { formatNumber } from "../utils";
|
||||
import { getSystemLocale } from "../utils/formatters";
|
||||
import { getStockStatus } from "../utils/schedule";
|
||||
@@ -32,6 +33,43 @@ export function UserFilterModal({
|
||||
}: UserFilterModalProps) {
|
||||
const { t, i18n } = useTranslation();
|
||||
|
||||
const isLiquidMedication = (med: Medication): boolean => {
|
||||
const rawPackageType = med.packageType as unknown as string | null | undefined;
|
||||
return (
|
||||
isLiquidContainerPackageType(med.packageType) || rawPackageType === "liquid" || med.medicationForm === "liquid"
|
||||
);
|
||||
};
|
||||
|
||||
const getLiquidCountUnitLabel = (unit: "ml" | "tsp" | "tbsp" | null | undefined, usage: number): string => {
|
||||
if (unit === "tsp") return t("form.blisters.teaspoons", { count: Math.abs(usage) });
|
||||
if (unit === "tbsp") return t("form.blisters.tablespoons", { count: Math.abs(usage) });
|
||||
return t("form.packageAmountUnitMl");
|
||||
};
|
||||
|
||||
const formatIntakeUsageLabel = (
|
||||
med: Medication,
|
||||
usage: number,
|
||||
intakeUnit?: "ml" | "tsp" | "tbsp" | null
|
||||
): string => {
|
||||
if (isLiquidMedication(med)) {
|
||||
return `${formatNumber(usage)} ${getLiquidCountUnitLabel(intakeUnit, usage)}`;
|
||||
}
|
||||
if (isTubePackageType(med.packageType)) {
|
||||
return `${formatNumber(usage)} ${t("form.blisters.applications", { count: usage })}`;
|
||||
}
|
||||
return `${formatNumber(usage)} ${usage !== 1 ? t("common.pills") : t("common.pill")}`;
|
||||
};
|
||||
|
||||
const formatStockSummaryLabel = (med: Medication, currentStock: number, packageSize: number): string => {
|
||||
if (isLiquidMedication(med)) {
|
||||
return `${formatNumber(currentStock)}/${formatNumber(packageSize)} ${t("form.packageAmountUnitMl")}`;
|
||||
}
|
||||
if (isTubePackageType(med.packageType)) {
|
||||
return `${formatNumber(currentStock)}/${formatNumber(packageSize)} ${t("form.packageAmountUnitG")}`;
|
||||
}
|
||||
return `${formatNumber(currentStock)}/${formatNumber(packageSize)} ${packageSize === 1 ? t("common.pill") : t("common.pills")}`;
|
||||
};
|
||||
|
||||
useEscapeKey(!!selectedUser, onClose);
|
||||
|
||||
if (!selectedUser) return null;
|
||||
@@ -70,7 +108,7 @@ export function UserFilterModal({
|
||||
? getStockStatus(medCoverage.daysLeft, medCoverage.medsLeft, settings, med.packageType)
|
||||
: getStockStatus(null, getMedTotal(med), settings, med.packageType);
|
||||
const packageSize = getPackageSize(med);
|
||||
const currentStock = medCoverage ? formatNumber(medCoverage.medsLeft) : formatNumber(getMedTotal(med));
|
||||
const currentStock = medCoverage ? medCoverage.medsLeft : getMedTotal(med);
|
||||
|
||||
// Get intakes relevant to this person
|
||||
const personIntakes = (
|
||||
@@ -109,10 +147,12 @@ export function UserFilterModal({
|
||||
minute: "2-digit",
|
||||
});
|
||||
const intakeKey = `${intake.start}-${intake.usage}-${intake.every}-${intake.takenBy ?? ""}`;
|
||||
const intakeUnit = "intakeUnit" in intake ? intake.intakeUnit : undefined;
|
||||
return (
|
||||
<span key={intakeKey} className="user-med-intake-item">
|
||||
{intake.usage} {intake.usage !== 1 ? t("common.pills") : t("common.pill")}
|
||||
{med.pillWeightMg != null &&
|
||||
{formatIntakeUsageLabel(med, intake.usage, intakeUnit)}
|
||||
{allowsPillFormSelection(med.packageType) &&
|
||||
med.pillWeightMg != null &&
|
||||
` (${intake.usage * med.pillWeightMg} ${med.doseUnit ?? "mg"})`}{" "}
|
||||
{intake.every === 1 ? t("common.daily") : t("common.everyNDays", { count: intake.every })}{" "}
|
||||
{t("modal.at")} {timeStr}
|
||||
@@ -123,10 +163,7 @@ export function UserFilterModal({
|
||||
)}
|
||||
</div>
|
||||
<div className="user-med-stats">
|
||||
<span className="user-med-pills">
|
||||
{currentStock}/{formatNumber(packageSize)}{" "}
|
||||
{packageSize === 1 ? t("common.pill") : t("common.pills")}
|
||||
</span>
|
||||
<span className="user-med-pills">{formatStockSummaryLabel(med, currentStock, packageSize)}</span>
|
||||
{status && <span className={`status-chip ${status.className}`}>{t(status.label)}</span>}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -54,6 +54,7 @@ export interface AppContextValue {
|
||||
setSettings: ReturnType<typeof useSettings>["setSettings"];
|
||||
savedSettings: ReturnType<typeof useSettings>["savedSettings"];
|
||||
settingsLoading: boolean;
|
||||
settingsLoadError: ReturnType<typeof useSettings>["settingsLoadError"];
|
||||
settingsSaving: boolean;
|
||||
settingsSaved: boolean;
|
||||
testingEmail: boolean;
|
||||
@@ -299,14 +300,49 @@ export function AppProvider({ children }: { children: React.ReactNode }) {
|
||||
if (typeof window !== "undefined" && user?.id) {
|
||||
const storedDays = localStorage.getItem(userStorageKey(user.id, "scheduleDays"));
|
||||
setScheduleDays(storedDays ? Number(storedDays) : 30);
|
||||
} else {
|
||||
setScheduleDays(30);
|
||||
}
|
||||
}, [user?.id]);
|
||||
|
||||
// Load medications and settings when user changes
|
||||
// Security boundary: clear user-scoped UI state immediately on user/session switches,
|
||||
// then load fresh data for the active identity.
|
||||
useEffect(() => {
|
||||
if (!user?.id) {
|
||||
setScheduleDays(30);
|
||||
}
|
||||
|
||||
medications.clearMedicationsState();
|
||||
settingsHook.resetSettingsState();
|
||||
doses.clearDosesState();
|
||||
refill.clearRefillState();
|
||||
share.resetShareDialogState();
|
||||
|
||||
setSelectedMed(null);
|
||||
setShowImageLightbox(false);
|
||||
setScheduleLightboxImage(null);
|
||||
setSelectedUser(null);
|
||||
setShowPastDays(false);
|
||||
setShowFutureDays(false);
|
||||
setShowExportModal(false);
|
||||
setShowImportConfirm(false);
|
||||
setPendingImportData(null);
|
||||
setImportResult(null);
|
||||
|
||||
medications.loadMeds();
|
||||
settingsHook.loadSettings();
|
||||
}, [medications.loadMeds, settingsHook.loadSettings]);
|
||||
doses.loadTakenDoses();
|
||||
}, [
|
||||
user?.id,
|
||||
medications.clearMedicationsState,
|
||||
medications.loadMeds,
|
||||
settingsHook.resetSettingsState,
|
||||
settingsHook.loadSettings,
|
||||
doses.clearDosesState,
|
||||
doses.loadTakenDoses,
|
||||
refill.clearRefillState,
|
||||
share.resetShareDialogState,
|
||||
]);
|
||||
|
||||
// Update selectedMed when meds change (e.g., after refill)
|
||||
useEffect(() => {
|
||||
@@ -801,6 +837,7 @@ export function AppProvider({ children }: { children: React.ReactNode }) {
|
||||
setSettings: settingsHook.setSettings,
|
||||
savedSettings: settingsHook.savedSettings,
|
||||
settingsLoading: settingsHook.settingsLoading,
|
||||
settingsLoadError: settingsHook.settingsLoadError,
|
||||
settingsSaving: settingsHook.settingsSaving,
|
||||
settingsSaved: settingsHook.settingsSaved,
|
||||
testingEmail: settingsHook.testingEmail,
|
||||
|
||||
@@ -24,6 +24,9 @@ export function useCollapsedDays(userId: number | undefined): UseCollapsedDaysRe
|
||||
);
|
||||
setManuallyCollapsedDays(collapsed);
|
||||
setManuallyExpandedDays(expanded);
|
||||
} else {
|
||||
setManuallyCollapsedDays(new Set());
|
||||
setManuallyExpandedDays(new Set());
|
||||
}
|
||||
}, [userId]);
|
||||
|
||||
|
||||
@@ -12,6 +12,7 @@ export interface UseDosesReturn {
|
||||
dismissedDoses: Set<string>;
|
||||
showClearMissedConfirm: boolean;
|
||||
setShowClearMissedConfirm: (show: boolean) => void;
|
||||
clearDosesState: () => void;
|
||||
getDoseId: (baseDoseId: string, person: string | null) => string;
|
||||
isDoseTakenAutomatically: (doseId: string) => boolean;
|
||||
countTakenDoses: (doses: Array<{ id: string; takenBy: string[] }>) => { total: number; taken: number };
|
||||
@@ -30,6 +31,15 @@ export function useDoses(): UseDosesReturn {
|
||||
// Track in-flight mutations to prevent polling from overwriting optimistic updates
|
||||
const mutationInFlightRef = useRef(0);
|
||||
|
||||
const clearDosesState = useCallback(() => {
|
||||
setTakenDoses(new Set());
|
||||
setTakenDoseTimestamps(new Map());
|
||||
setTakenDoseSources(new Map());
|
||||
setDismissedDoses(new Set());
|
||||
setShowClearMissedConfirm(false);
|
||||
mutationInFlightRef.current = 0;
|
||||
}, []);
|
||||
|
||||
// Load taken doses from server
|
||||
const loadTakenDoses = useCallback(async () => {
|
||||
// Skip polling while mutations are in-flight to prevent race conditions
|
||||
@@ -60,12 +70,15 @@ export function useDoses(): UseDosesReturn {
|
||||
setTakenDoseTimestamps(timestamps);
|
||||
setTakenDoseSources(sources);
|
||||
setDismissedDoses(dismissed);
|
||||
} else if (res.status === 401 || res.status === 403) {
|
||||
// Prevent showing previous user's dose state after auth/session changes.
|
||||
clearDosesState();
|
||||
}
|
||||
// Don't reset on error - keep current state
|
||||
} catch {
|
||||
// Don't reset on error - keep current state
|
||||
}
|
||||
}, []);
|
||||
}, [clearDosesState]);
|
||||
|
||||
// Poll for taken doses from server (works with or without auth)
|
||||
useEffect(() => {
|
||||
@@ -209,6 +222,7 @@ export function useDoses(): UseDosesReturn {
|
||||
dismissedDoses,
|
||||
showClearMissedConfirm,
|
||||
setShowClearMissedConfirm,
|
||||
clearDosesState,
|
||||
getDoseId,
|
||||
isDoseTakenAutomatically,
|
||||
countTakenDoses,
|
||||
|
||||
@@ -350,14 +350,15 @@ export function useMedicationForm(): UseMedicationFormReturn {
|
||||
const next = { ...prev, [key]: value } as FormState;
|
||||
|
||||
if (key === "packageType") {
|
||||
if (isTubePackageType(value)) {
|
||||
const nextPackageType = value as FormState["packageType"];
|
||||
if (isTubePackageType(nextPackageType)) {
|
||||
next.packCount = "1";
|
||||
next.packageAmountValue = String(Math.max(1, Number(next.packageAmountValue) || 0));
|
||||
next.medicationForm = "topical";
|
||||
next.lifecycleCategory = "treatment_period";
|
||||
next.doseUnit = "units";
|
||||
next.packageAmountUnit = "g";
|
||||
} else if (isLiquidContainerPackageType(value)) {
|
||||
} else if (isLiquidContainerPackageType(nextPackageType)) {
|
||||
next.packCount = String(Math.max(1, Number(next.packCount) || 1));
|
||||
next.packageAmountValue = String(Math.max(1, Number(next.packageAmountValue) || 0));
|
||||
next.medicationForm = "liquid";
|
||||
|
||||
@@ -8,6 +8,7 @@ export interface UseMedicationsReturn {
|
||||
saving: boolean;
|
||||
setSaving: React.Dispatch<React.SetStateAction<boolean>>;
|
||||
uploadingImage: boolean;
|
||||
clearMedicationsState: () => void;
|
||||
loadMeds: () => void;
|
||||
deleteMed: (id: number, editingId: number | null, resetForm: () => void) => Promise<void>;
|
||||
uploadMedImage: (medId: number, file: File) => Promise<void>;
|
||||
@@ -20,6 +21,13 @@ export function useMedications(): UseMedicationsReturn {
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [uploadingImage, setUploadingImage] = useState(false);
|
||||
|
||||
const clearMedicationsState = useCallback(() => {
|
||||
setMeds([]);
|
||||
setLoading(false);
|
||||
setSaving(false);
|
||||
setUploadingImage(false);
|
||||
}, []);
|
||||
|
||||
const loadMeds = useCallback(() => {
|
||||
setLoading(true);
|
||||
fetch("/api/medications?includeObsolete=true", { credentials: "include" })
|
||||
@@ -96,6 +104,7 @@ export function useMedications(): UseMedicationsReturn {
|
||||
saving,
|
||||
setSaving,
|
||||
uploadingImage,
|
||||
clearMedicationsState,
|
||||
loadMeds,
|
||||
deleteMed,
|
||||
uploadMedImage,
|
||||
|
||||
@@ -36,6 +36,7 @@ export interface UseRefillReturn {
|
||||
editStockMedication: Medication | null;
|
||||
|
||||
// Actions
|
||||
clearRefillState: () => void;
|
||||
loadRefillHistory: (medId: number) => Promise<void>;
|
||||
submitRefill: (
|
||||
medId: number,
|
||||
@@ -69,6 +70,22 @@ export function useRefill(): UseRefillReturn {
|
||||
const [editStockSaving, setEditStockSaving] = useState(false);
|
||||
const [editStockMedication, setEditStockMedication] = useState<Medication | null>(null);
|
||||
|
||||
const clearRefillState = useCallback(() => {
|
||||
setShowRefillModal(false);
|
||||
setRefillPacks(1);
|
||||
setRefillLoose(0);
|
||||
setUsePrescriptionRefill(false);
|
||||
setRefillSaving(false);
|
||||
setRefillHistory([]);
|
||||
setRefillHistoryExpanded(false);
|
||||
setShowEditStockModal(false);
|
||||
setEditStockFullBlisters(0);
|
||||
setEditStockPartialBlisterPills(0);
|
||||
setEditStockLoosePills(0);
|
||||
setEditStockSaving(false);
|
||||
setEditStockMedication(null);
|
||||
}, []);
|
||||
|
||||
// Load refill history for a medication
|
||||
const loadRefillHistory = useCallback(async (medId: number) => {
|
||||
try {
|
||||
@@ -327,6 +344,7 @@ export function useRefill(): UseRefillReturn {
|
||||
}, [showEditStockModal]);
|
||||
|
||||
return {
|
||||
clearRefillState,
|
||||
showRefillModal,
|
||||
setShowRefillModal,
|
||||
refillPacks,
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { log } from "../utils/logger";
|
||||
|
||||
export interface Settings {
|
||||
emailEnabled: boolean;
|
||||
@@ -54,6 +55,8 @@ export interface Settings {
|
||||
expiryWarningDays: number;
|
||||
}
|
||||
|
||||
export type SettingsLoadError = "auth" | "forbidden" | "request" | null;
|
||||
|
||||
const defaultSettings: Settings = {
|
||||
emailEnabled: false,
|
||||
notificationEmail: "",
|
||||
@@ -108,6 +111,7 @@ export interface UseSettingsReturn {
|
||||
setSettings: React.Dispatch<React.SetStateAction<Settings>>;
|
||||
savedSettings: Settings;
|
||||
settingsLoading: boolean;
|
||||
settingsLoadError: SettingsLoadError;
|
||||
settingsSaving: boolean;
|
||||
settingsSaved: boolean;
|
||||
testingEmail: boolean;
|
||||
@@ -121,6 +125,7 @@ export interface UseSettingsReturn {
|
||||
testEmail: () => Promise<void>;
|
||||
testShoutrrr: () => Promise<void>;
|
||||
hasUnsavedChanges: boolean;
|
||||
resetSettingsState: () => void;
|
||||
}
|
||||
|
||||
export function useSettings(): UseSettingsReturn {
|
||||
@@ -128,6 +133,7 @@ export function useSettings(): UseSettingsReturn {
|
||||
const [settings, setSettings] = useState<Settings>(defaultSettings);
|
||||
const [savedSettings, setSavedSettings] = useState<Settings>(defaultSettings);
|
||||
const [settingsLoading, setSettingsLoading] = useState(false);
|
||||
const [settingsLoadError, setSettingsLoadError] = useState<SettingsLoadError>(null);
|
||||
const [settingsSaving, setSettingsSaving] = useState(false);
|
||||
const [settingsSaved, setSettingsSaved] = useState(false);
|
||||
const [testingEmail, setTestingEmail] = useState(false);
|
||||
@@ -135,20 +141,123 @@ export function useSettings(): UseSettingsReturn {
|
||||
const [testingShoutrrr, setTestingShoutrrr] = useState(false);
|
||||
const [testShoutrrrResult, setTestShoutrrrResult] = useState<{ success: boolean; message: string } | null>(null);
|
||||
|
||||
// Generation counter: incremented on every resetSettingsState call.
|
||||
// loadSettings captures the current generation; if it changes before
|
||||
// the fetch completes, the stale response is silently discarded.
|
||||
const loadGenerationRef = useRef(0);
|
||||
|
||||
const resetSettingsState = useCallback(() => {
|
||||
loadGenerationRef.current += 1; // Invalidate any in-flight loadSettings
|
||||
setSettings(defaultSettings);
|
||||
setSavedSettings(defaultSettings);
|
||||
setSettingsLoading(false);
|
||||
setSettingsLoadError(null);
|
||||
setSettingsSaving(false);
|
||||
setSettingsSaved(false);
|
||||
setTestingEmail(false);
|
||||
setTestEmailResult(null);
|
||||
setTestingShoutrrr(false);
|
||||
setTestShoutrrrResult(null);
|
||||
}, []);
|
||||
|
||||
const clearReminderMetadata = useCallback(() => {
|
||||
setSettings((prev) => ({
|
||||
...prev,
|
||||
lastAutoEmailSent: null,
|
||||
lastNotificationType: null,
|
||||
lastNotificationChannel: null,
|
||||
lastReminderMedName: null,
|
||||
lastReminderTakenBy: null,
|
||||
lastStockReminderSent: null,
|
||||
lastStockReminderChannel: null,
|
||||
lastStockReminderMedNames: null,
|
||||
lastPrescriptionReminderSent: null,
|
||||
lastPrescriptionReminderChannel: null,
|
||||
lastPrescriptionReminderMedNames: null,
|
||||
}));
|
||||
setSavedSettings((prev) => ({
|
||||
...prev,
|
||||
lastAutoEmailSent: null,
|
||||
lastNotificationType: null,
|
||||
lastNotificationChannel: null,
|
||||
lastReminderMedName: null,
|
||||
lastReminderTakenBy: null,
|
||||
lastStockReminderSent: null,
|
||||
lastStockReminderChannel: null,
|
||||
lastStockReminderMedNames: null,
|
||||
lastPrescriptionReminderSent: null,
|
||||
lastPrescriptionReminderChannel: null,
|
||||
lastPrescriptionReminderMedNames: null,
|
||||
}));
|
||||
}, []);
|
||||
|
||||
const fetchWithRefresh = useCallback(async (input: RequestInfo | URL, init?: RequestInit): Promise<Response> => {
|
||||
const requestInit: RequestInit = {
|
||||
credentials: "include",
|
||||
...init,
|
||||
};
|
||||
|
||||
let response = await fetch(input, requestInit);
|
||||
if (response.status !== 401) {
|
||||
return response;
|
||||
}
|
||||
|
||||
const refreshResponse = await fetch("/api/auth/refresh", {
|
||||
method: "POST",
|
||||
credentials: "include",
|
||||
});
|
||||
if (!refreshResponse.ok) {
|
||||
return response;
|
||||
}
|
||||
|
||||
response = await fetch(input, requestInit);
|
||||
return response;
|
||||
}, []);
|
||||
|
||||
// Load settings function - exposed for manual refresh (e.g., after auth)
|
||||
const loadSettings = useCallback(() => {
|
||||
setSettingsLoading(true);
|
||||
fetch("/api/settings", { credentials: "include" })
|
||||
.then((res) => (res.ok ? res.json() : Promise.reject()))
|
||||
const generation = loadGenerationRef.current;
|
||||
fetchWithRefresh("/api/settings")
|
||||
.then((res) => {
|
||||
// Discard result if a newer loadSettings call (or resetSettingsState) has fired
|
||||
if (loadGenerationRef.current !== generation) return Promise.reject("stale");
|
||||
if (!res.ok) {
|
||||
log.warn("[useSettings] loadSettings failed", { status: res.status });
|
||||
if (res.status === 401 || res.status === 403) {
|
||||
resetSettingsState();
|
||||
}
|
||||
return Promise.reject({ status: res.status });
|
||||
}
|
||||
return res.json();
|
||||
})
|
||||
.then((data) => {
|
||||
if (!data || loadGenerationRef.current !== generation) return;
|
||||
log.debug("[useSettings] settings loaded", { smtpConfigured: !!data.smtpHost });
|
||||
const newSettings = { ...defaultSettings, ...data, smtpPass: "" };
|
||||
setSettings(newSettings);
|
||||
setSavedSettings(newSettings);
|
||||
setSettingsLoadError(null);
|
||||
setSettingsSaved(false);
|
||||
})
|
||||
.catch(() => {})
|
||||
.finally(() => setSettingsLoading(false));
|
||||
}, []);
|
||||
.catch((error: unknown) => {
|
||||
if (error === "stale") return;
|
||||
const status =
|
||||
typeof error === "object" && error !== null && "status" in error ? (error.status as number) : undefined;
|
||||
if (status === 401) {
|
||||
setSettingsLoadError("auth");
|
||||
return;
|
||||
}
|
||||
if (status === 403) {
|
||||
setSettingsLoadError("forbidden");
|
||||
return;
|
||||
}
|
||||
setSettingsLoadError("request");
|
||||
})
|
||||
.finally(() => {
|
||||
if (loadGenerationRef.current === generation) setSettingsLoading(false);
|
||||
});
|
||||
}, [fetchWithRefresh, resetSettingsState]);
|
||||
|
||||
// Load settings on mount
|
||||
useEffect(() => {
|
||||
@@ -158,41 +267,59 @@ export function useSettings(): UseSettingsReturn {
|
||||
// Auto-refresh reminder status (last sent timestamp) every 30 seconds
|
||||
useEffect(() => {
|
||||
const refreshReminderStatus = () => {
|
||||
fetch("/api/settings", { credentials: "include" })
|
||||
.then((res) => (res.ok ? res.json() : Promise.reject()))
|
||||
fetchWithRefresh("/api/settings")
|
||||
.then((res) => {
|
||||
if (!res.ok) {
|
||||
if (res.status === 401 || res.status === 403) {
|
||||
clearReminderMetadata();
|
||||
}
|
||||
return Promise.reject();
|
||||
}
|
||||
return res.json();
|
||||
})
|
||||
.then((data) => {
|
||||
const pick = <T>(key: string, fallback: T): T => (Object.hasOwn(data, key) ? (data[key] as T) : fallback);
|
||||
|
||||
// Only update the reminder-related fields without triggering unsaved changes
|
||||
setSettings((prev) => ({
|
||||
...prev,
|
||||
lastAutoEmailSent: data.lastAutoEmailSent ?? prev.lastAutoEmailSent,
|
||||
lastNotificationType: data.lastNotificationType ?? prev.lastNotificationType,
|
||||
lastNotificationChannel: data.lastNotificationChannel ?? prev.lastNotificationChannel,
|
||||
lastReminderMedName: data.lastReminderMedName ?? prev.lastReminderMedName,
|
||||
lastReminderTakenBy: data.lastReminderTakenBy ?? prev.lastReminderTakenBy,
|
||||
lastStockReminderSent: data.lastStockReminderSent ?? prev.lastStockReminderSent,
|
||||
lastStockReminderChannel: data.lastStockReminderChannel ?? prev.lastStockReminderChannel,
|
||||
lastStockReminderMedNames: data.lastStockReminderMedNames ?? prev.lastStockReminderMedNames,
|
||||
lastPrescriptionReminderSent: data.lastPrescriptionReminderSent ?? prev.lastPrescriptionReminderSent,
|
||||
lastPrescriptionReminderChannel:
|
||||
data.lastPrescriptionReminderChannel ?? prev.lastPrescriptionReminderChannel,
|
||||
lastPrescriptionReminderMedNames:
|
||||
data.lastPrescriptionReminderMedNames ?? prev.lastPrescriptionReminderMedNames,
|
||||
lastAutoEmailSent: pick("lastAutoEmailSent", prev.lastAutoEmailSent),
|
||||
lastNotificationType: pick("lastNotificationType", prev.lastNotificationType),
|
||||
lastNotificationChannel: pick("lastNotificationChannel", prev.lastNotificationChannel),
|
||||
lastReminderMedName: pick("lastReminderMedName", prev.lastReminderMedName),
|
||||
lastReminderTakenBy: pick("lastReminderTakenBy", prev.lastReminderTakenBy),
|
||||
lastStockReminderSent: pick("lastStockReminderSent", prev.lastStockReminderSent),
|
||||
lastStockReminderChannel: pick("lastStockReminderChannel", prev.lastStockReminderChannel),
|
||||
lastStockReminderMedNames: pick("lastStockReminderMedNames", prev.lastStockReminderMedNames),
|
||||
lastPrescriptionReminderSent: pick("lastPrescriptionReminderSent", prev.lastPrescriptionReminderSent),
|
||||
lastPrescriptionReminderChannel: pick(
|
||||
"lastPrescriptionReminderChannel",
|
||||
prev.lastPrescriptionReminderChannel
|
||||
),
|
||||
lastPrescriptionReminderMedNames: pick(
|
||||
"lastPrescriptionReminderMedNames",
|
||||
prev.lastPrescriptionReminderMedNames
|
||||
),
|
||||
}));
|
||||
setSavedSettings((prev) => ({
|
||||
...prev,
|
||||
lastAutoEmailSent: data.lastAutoEmailSent ?? prev.lastAutoEmailSent,
|
||||
lastNotificationType: data.lastNotificationType ?? prev.lastNotificationType,
|
||||
lastNotificationChannel: data.lastNotificationChannel ?? prev.lastNotificationChannel,
|
||||
lastReminderMedName: data.lastReminderMedName ?? prev.lastReminderMedName,
|
||||
lastReminderTakenBy: data.lastReminderTakenBy ?? prev.lastReminderTakenBy,
|
||||
lastStockReminderSent: data.lastStockReminderSent ?? prev.lastStockReminderSent,
|
||||
lastStockReminderChannel: data.lastStockReminderChannel ?? prev.lastStockReminderChannel,
|
||||
lastStockReminderMedNames: data.lastStockReminderMedNames ?? prev.lastStockReminderMedNames,
|
||||
lastPrescriptionReminderSent: data.lastPrescriptionReminderSent ?? prev.lastPrescriptionReminderSent,
|
||||
lastPrescriptionReminderChannel:
|
||||
data.lastPrescriptionReminderChannel ?? prev.lastPrescriptionReminderChannel,
|
||||
lastPrescriptionReminderMedNames:
|
||||
data.lastPrescriptionReminderMedNames ?? prev.lastPrescriptionReminderMedNames,
|
||||
lastAutoEmailSent: pick("lastAutoEmailSent", prev.lastAutoEmailSent),
|
||||
lastNotificationType: pick("lastNotificationType", prev.lastNotificationType),
|
||||
lastNotificationChannel: pick("lastNotificationChannel", prev.lastNotificationChannel),
|
||||
lastReminderMedName: pick("lastReminderMedName", prev.lastReminderMedName),
|
||||
lastReminderTakenBy: pick("lastReminderTakenBy", prev.lastReminderTakenBy),
|
||||
lastStockReminderSent: pick("lastStockReminderSent", prev.lastStockReminderSent),
|
||||
lastStockReminderChannel: pick("lastStockReminderChannel", prev.lastStockReminderChannel),
|
||||
lastStockReminderMedNames: pick("lastStockReminderMedNames", prev.lastStockReminderMedNames),
|
||||
lastPrescriptionReminderSent: pick("lastPrescriptionReminderSent", prev.lastPrescriptionReminderSent),
|
||||
lastPrescriptionReminderChannel: pick(
|
||||
"lastPrescriptionReminderChannel",
|
||||
prev.lastPrescriptionReminderChannel
|
||||
),
|
||||
lastPrescriptionReminderMedNames: pick(
|
||||
"lastPrescriptionReminderMedNames",
|
||||
prev.lastPrescriptionReminderMedNames
|
||||
),
|
||||
}));
|
||||
})
|
||||
.catch(() => {});
|
||||
@@ -200,7 +327,7 @@ export function useSettings(): UseSettingsReturn {
|
||||
|
||||
const interval = setInterval(refreshReminderStatus, 30000);
|
||||
return () => clearInterval(interval);
|
||||
}, []);
|
||||
}, [clearReminderMetadata, fetchWithRefresh]);
|
||||
|
||||
// Internal save function (no event needed)
|
||||
const performSave = useCallback(
|
||||
@@ -246,20 +373,30 @@ export function useSettings(): UseSettingsReturn {
|
||||
smtpSecure: settingsToSave.smtpSecure,
|
||||
};
|
||||
|
||||
await fetch("/api/settings", {
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
credentials: "include",
|
||||
body: JSON.stringify(payload),
|
||||
}).catch(() => null);
|
||||
try {
|
||||
const response = await fetchWithRefresh("/api/settings", {
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
|
||||
const updatedSettings = { ...settingsToSave };
|
||||
setSettings(updatedSettings);
|
||||
setSettingsSaving(false);
|
||||
setSavedSettings(updatedSettings);
|
||||
setSettingsSaved(true);
|
||||
if (!response.ok) {
|
||||
throw new Error(`SETTINGS_SAVE_FAILED_${response.status}`);
|
||||
}
|
||||
|
||||
const updatedSettings = { ...settingsToSave };
|
||||
setSettings(updatedSettings);
|
||||
setSavedSettings(updatedSettings);
|
||||
setSettingsSaved(true);
|
||||
} catch {
|
||||
setSettingsSaved(false);
|
||||
// Keep UI aligned with backend truth if save failed (auth/session/network/server error).
|
||||
loadSettings();
|
||||
} finally {
|
||||
setSettingsSaving(false);
|
||||
}
|
||||
},
|
||||
[i18n.language]
|
||||
[fetchWithRefresh, i18n.language, loadSettings]
|
||||
);
|
||||
|
||||
// Debounced auto-save: fires whenever settings change
|
||||
@@ -321,10 +458,9 @@ export function useSettings(): UseSettingsReturn {
|
||||
setTestingEmail(true);
|
||||
setTestEmailResult(null);
|
||||
try {
|
||||
const res = await fetch("/api/settings/test-email", {
|
||||
const res = await fetchWithRefresh("/api/settings/test-email", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
credentials: "include",
|
||||
body: JSON.stringify({ email: settings.notificationEmail }),
|
||||
});
|
||||
const data = await res.json();
|
||||
@@ -337,16 +473,15 @@ export function useSettings(): UseSettingsReturn {
|
||||
} finally {
|
||||
setTestingEmail(false);
|
||||
}
|
||||
}, [settings.notificationEmail]);
|
||||
}, [fetchWithRefresh, settings.notificationEmail]);
|
||||
|
||||
const testShoutrrr = useCallback(async () => {
|
||||
setTestingShoutrrr(true);
|
||||
setTestShoutrrrResult(null);
|
||||
try {
|
||||
const res = await fetch("/api/settings/test-shoutrrr", {
|
||||
const res = await fetchWithRefresh("/api/settings/test-shoutrrr", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
credentials: "include",
|
||||
body: JSON.stringify({ url: settings.shoutrrrUrl }),
|
||||
});
|
||||
const data = await res.json();
|
||||
@@ -359,7 +494,7 @@ export function useSettings(): UseSettingsReturn {
|
||||
} finally {
|
||||
setTestingShoutrrr(false);
|
||||
}
|
||||
}, [settings.shoutrrrUrl]);
|
||||
}, [fetchWithRefresh, settings.shoutrrrUrl]);
|
||||
|
||||
// Check for unsaved changes
|
||||
const hasUnsavedChanges = JSON.stringify(settings) !== JSON.stringify(savedSettings);
|
||||
@@ -369,6 +504,7 @@ export function useSettings(): UseSettingsReturn {
|
||||
setSettings,
|
||||
savedSettings,
|
||||
settingsLoading,
|
||||
settingsLoadError,
|
||||
settingsSaving,
|
||||
settingsSaved,
|
||||
testingEmail,
|
||||
@@ -382,5 +518,6 @@ export function useSettings(): UseSettingsReturn {
|
||||
testEmail,
|
||||
testShoutrrr,
|
||||
hasUnsavedChanges,
|
||||
resetSettingsState,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -292,6 +292,18 @@
|
||||
"title": "Sprache",
|
||||
"select": "Sprache auswählen"
|
||||
},
|
||||
"apiKey": {
|
||||
"title": "API-Zugriff",
|
||||
"generateTitle": "API-Key erzeugen",
|
||||
"generateDesc": "Erstellt einen neuen API-Key mit Schreibrechten. Der vorherige Key wird automatisch ungültig.",
|
||||
"generateButton": "Key erzeugen",
|
||||
"generating": "Wird erzeugt...",
|
||||
"currentToken": "Neuer API-Key",
|
||||
"copyButton": "Kopieren",
|
||||
"copied": "Kopiert",
|
||||
"copyHint": "Diesen Key jetzt kopieren. Er wird nur einmal angezeigt und kann später nicht erneut abgerufen werden.",
|
||||
"generateError": "API-Key konnte nicht erzeugt werden"
|
||||
},
|
||||
"notifications": {
|
||||
"title": "Benachrichtigungen",
|
||||
"channels": "Kanäle",
|
||||
@@ -312,7 +324,11 @@
|
||||
},
|
||||
"email": {
|
||||
"recipient": "Empfänger",
|
||||
"notConfigured": "Nicht konfiguriert"
|
||||
"notConfigured": "Nicht konfiguriert",
|
||||
"serverNotConfigured": "E-Mail-Benachrichtigungen bleiben deaktiviert, bis SMTP im Backend konfiguriert ist.",
|
||||
"loadErrorAuth": "Die E-Mail-Einstellungen konnten nicht geladen werden, weil deine Sitzung nicht mehr gültig ist. Bitte melde dich erneut an.",
|
||||
"loadErrorForbidden": "Die E-Mail-Einstellungen konnten nicht geladen werden, weil diese Sitzung sie nicht lesen darf.",
|
||||
"loadErrorGeneric": "Die Verfügbarkeit von E-Mail konnte nicht geprüft werden, weil das Laden der Einstellungen fehlgeschlagen ist."
|
||||
},
|
||||
"push": {
|
||||
"url": "URL",
|
||||
@@ -541,7 +557,10 @@
|
||||
"generating": "Wird generiert...",
|
||||
"generateAnother": "Weiteren Link generieren",
|
||||
"linkGenerated": "Teilen-Link erstellt!",
|
||||
"scheduleLink": "Zeitplan-Link",
|
||||
"overviewLink": "Übersichts-Link",
|
||||
"copyLink": "Link kopieren",
|
||||
"copyOverviewLink": "Übersichts-Link kopieren",
|
||||
"copied": "In Zwischenablage kopiert!",
|
||||
"noPeople": "Keine Medikamente mit 'Eingenommen von' zugewiesen. Füge zuerst eine Person zu einem Medikament hinzu.",
|
||||
"scheduleFor": "Zeitplan für",
|
||||
@@ -557,6 +576,34 @@
|
||||
"expiredOn": "Abgelaufen am: {{date}}"
|
||||
}
|
||||
},
|
||||
"sharedOverview": {
|
||||
"title": "Medikamentenübersicht für {{person}}",
|
||||
"sharedBy": "Geteilt von {{user}}",
|
||||
"expiredOn": "Abgelaufen am: {{date}}",
|
||||
"noMedications": "Für diesen Teilen-Link sind keine Medikamente verfügbar.",
|
||||
"columns": {
|
||||
"name": "Name",
|
||||
"package": "Packung",
|
||||
"stock": "Bestand",
|
||||
"daysLeft": "Tage übrig",
|
||||
"nextIntake": "Nächste Einnahme",
|
||||
"depletion": "Aufgebraucht",
|
||||
"priority": "Priorität"
|
||||
},
|
||||
"priority": {
|
||||
"normal": "Normal",
|
||||
"high": "Hoch"
|
||||
},
|
||||
"stock": {
|
||||
"of": "{{current}} von {{capacity}}"
|
||||
},
|
||||
"error": {
|
||||
"notFound": "Teilen-Link nicht gefunden",
|
||||
"expired": "Dieser geteilte Übersichts-Link ist abgelaufen",
|
||||
"rateLimit": "Zu viele Anfragen. Bitte versuche es in einem Moment erneut.",
|
||||
"generic": "Die Medikamentenübersicht konnte nicht geladen werden"
|
||||
}
|
||||
},
|
||||
"exportImport": {
|
||||
"title": "Datenexport / -import",
|
||||
"description": "Sichere deine Daten oder übertrage sie auf ein anderes Gerät.",
|
||||
|
||||
@@ -292,6 +292,18 @@
|
||||
"title": "Language",
|
||||
"select": "Select language"
|
||||
},
|
||||
"apiKey": {
|
||||
"title": "API Access",
|
||||
"generateTitle": "Generate API key",
|
||||
"generateDesc": "Creates a new write-capable API key. The previous key becomes invalid automatically.",
|
||||
"generateButton": "Generate key",
|
||||
"generating": "Generating...",
|
||||
"currentToken": "New API key",
|
||||
"copyButton": "Copy",
|
||||
"copied": "Copied",
|
||||
"copyHint": "Copy this key now. It is shown only once and cannot be retrieved later.",
|
||||
"generateError": "Failed to generate API key"
|
||||
},
|
||||
"notifications": {
|
||||
"title": "Notifications",
|
||||
"channels": "Channels",
|
||||
@@ -312,7 +324,11 @@
|
||||
},
|
||||
"email": {
|
||||
"recipient": "Recipient",
|
||||
"notConfigured": "Not configured"
|
||||
"notConfigured": "Not configured",
|
||||
"serverNotConfigured": "Email notifications stay unavailable until SMTP is configured on the backend.",
|
||||
"loadErrorAuth": "Email settings could not be loaded because your session is no longer valid. Please sign in again.",
|
||||
"loadErrorForbidden": "Email settings could not be loaded because this session is not allowed to read them.",
|
||||
"loadErrorGeneric": "Email availability could not be verified because loading settings failed."
|
||||
},
|
||||
"push": {
|
||||
"url": "URL",
|
||||
@@ -541,7 +557,10 @@
|
||||
"generating": "Generating...",
|
||||
"generateAnother": "Generate another link",
|
||||
"linkGenerated": "Share link generated!",
|
||||
"scheduleLink": "Schedule link",
|
||||
"overviewLink": "Overview link",
|
||||
"copyLink": "Copy Link",
|
||||
"copyOverviewLink": "Copy Overview Link",
|
||||
"copied": "Copied to clipboard!",
|
||||
"noPeople": "No medications with 'Taken by' assigned. Add a person to a medication first.",
|
||||
"scheduleFor": "Schedule for",
|
||||
@@ -557,6 +576,34 @@
|
||||
"expiredOn": "Expired on: {{date}}"
|
||||
}
|
||||
},
|
||||
"sharedOverview": {
|
||||
"title": "Medication Overview for {{person}}",
|
||||
"sharedBy": "Shared by {{user}}",
|
||||
"expiredOn": "Expired on: {{date}}",
|
||||
"noMedications": "No medications available for this share link.",
|
||||
"columns": {
|
||||
"name": "Name",
|
||||
"package": "Package",
|
||||
"stock": "Stock",
|
||||
"daysLeft": "Days left",
|
||||
"nextIntake": "Next intake",
|
||||
"depletion": "Depletion",
|
||||
"priority": "Priority"
|
||||
},
|
||||
"priority": {
|
||||
"normal": "Normal",
|
||||
"high": "High"
|
||||
},
|
||||
"stock": {
|
||||
"of": "{{current}} of {{capacity}}"
|
||||
},
|
||||
"error": {
|
||||
"notFound": "Share link not found",
|
||||
"expired": "This shared overview link has expired",
|
||||
"rateLimit": "Too many requests. Please try again in a moment.",
|
||||
"generic": "Failed to load medication overview"
|
||||
}
|
||||
},
|
||||
"exportImport": {
|
||||
"title": "Data Export / Import",
|
||||
"description": "Backup your data or transfer it to another device.",
|
||||
|
||||
+1086
-1022
File diff suppressed because it is too large
Load Diff
@@ -1261,7 +1261,7 @@ export function MedicationsPage() {
|
||||
<label>
|
||||
{t("form.packageType")}
|
||||
<select
|
||||
className="package-type-select"
|
||||
className="select-field package-type-select"
|
||||
value={form.packageType}
|
||||
onChange={(e) => handleValueChange("packageType", e.target.value as PackageType)}
|
||||
>
|
||||
@@ -1284,6 +1284,7 @@ export function MedicationsPage() {
|
||||
<label>
|
||||
{t("form.pillForm")}
|
||||
<select
|
||||
className="select-field"
|
||||
value={form.pillForm}
|
||||
onChange={(e) => handleValueChange("pillForm", e.target.value as FormState["pillForm"])}
|
||||
>
|
||||
@@ -1295,7 +1296,11 @@ export function MedicationsPage() {
|
||||
{isTubePackageType(form.packageType) && (
|
||||
<label>
|
||||
{t("form.medicationForm")}
|
||||
<select value={"topical"} onChange={() => handleValueChange("medicationForm", "topical")}>
|
||||
<select
|
||||
className="select-field"
|
||||
value={"topical"}
|
||||
onChange={() => handleValueChange("medicationForm", "topical")}
|
||||
>
|
||||
<option value="topical">{t("form.medicationFormTopical")}</option>
|
||||
</select>
|
||||
</label>
|
||||
@@ -1303,7 +1308,11 @@ export function MedicationsPage() {
|
||||
{isLiquidContainerPackageType(form.packageType) && (
|
||||
<label>
|
||||
{t("form.medicationForm")}
|
||||
<select value={"liquid"} onChange={() => handleValueChange("medicationForm", "liquid")}>
|
||||
<select
|
||||
className="select-field"
|
||||
value={"liquid"}
|
||||
onChange={() => handleValueChange("medicationForm", "liquid")}
|
||||
>
|
||||
<option value="liquid">{t("form.medicationFormLiquid")}</option>
|
||||
</select>
|
||||
</label>
|
||||
@@ -1503,7 +1512,7 @@ export function MedicationsPage() {
|
||||
<select
|
||||
value="g"
|
||||
disabled
|
||||
className="dose-unit-select"
|
||||
className="select-field dose-unit-select"
|
||||
aria-label={t("form.packageAmountUnitG")}
|
||||
>
|
||||
<option value="g">{t("form.packageAmountUnitG")}</option>
|
||||
@@ -1563,7 +1572,7 @@ export function MedicationsPage() {
|
||||
<select
|
||||
value={form.doseUnit}
|
||||
onChange={(e) => handleValueChange("doseUnit", e.target.value as DoseUnit)}
|
||||
className="dose-unit-select"
|
||||
className="select-field dose-unit-select"
|
||||
>
|
||||
{DOSE_UNITS.map((unit) => (
|
||||
<option key={unit.value} value={unit.value}>
|
||||
@@ -1597,7 +1606,7 @@ export function MedicationsPage() {
|
||||
<select
|
||||
value="ml"
|
||||
disabled
|
||||
className="dose-unit-select"
|
||||
className="select-field dose-unit-select"
|
||||
aria-label={t("form.packageAmountUnitMl")}
|
||||
>
|
||||
<option value="ml">{t("form.packageAmountUnitMl")}</option>
|
||||
@@ -1760,6 +1769,7 @@ export function MedicationsPage() {
|
||||
<label>
|
||||
{t("form.blisters.intakeUnit")}
|
||||
<select
|
||||
className="select-field"
|
||||
value={intake.intakeUnit}
|
||||
onChange={(e) =>
|
||||
setIntakeValue(idx, "intakeUnit", e.target.value as "ml" | "tsp" | "tbsp")
|
||||
@@ -1775,6 +1785,7 @@ export function MedicationsPage() {
|
||||
<label className="taken-by-field" title={t("form.blisters.takenByTooltip")}>
|
||||
{t("form.blisters.takenByIntake")}
|
||||
<select
|
||||
className="select-field"
|
||||
value={intake.takenBy}
|
||||
onChange={(e) => setIntakeValue(idx, "takenBy", e.target.value)}
|
||||
>
|
||||
|
||||
@@ -128,7 +128,7 @@ export function PlannerPage() {
|
||||
return t("form.ml");
|
||||
}
|
||||
if (isTubePackageType(med?.packageType)) {
|
||||
return med.medicationForm === "liquid" ? t("form.ml") : t("blisters.applications");
|
||||
return med?.medicationForm === "liquid" ? t("form.ml") : t("blisters.applications");
|
||||
}
|
||||
return count === 1 ? t("common.pill") : t("common.pills");
|
||||
};
|
||||
@@ -140,7 +140,7 @@ export function PlannerPage() {
|
||||
return `${roundedLoose} ${t("form.ml")}`;
|
||||
}
|
||||
if (isTubePackageType(med?.packageType)) {
|
||||
const unit = med.medicationForm === "liquid" ? t("form.ml") : t("blisters.applications");
|
||||
const unit = med?.medicationForm === "liquid" ? t("form.ml") : t("blisters.applications");
|
||||
return `${roundedLoose} ${unit}`;
|
||||
}
|
||||
return `${roundedLoose} ${roundedLoose === 1 ? t("common.pill") : t("common.pills")}`;
|
||||
|
||||
@@ -179,7 +179,7 @@ export function SchedulePage() {
|
||||
<div className="card-head">
|
||||
<h2>{t("dashboard.schedules.title")}</h2>
|
||||
<select
|
||||
className="schedule-days-select"
|
||||
className="select-field schedule-days-select"
|
||||
value={scheduleDays}
|
||||
onChange={(e) => {
|
||||
const val = Number(e.target.value);
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
/* biome-ignore-all lint/a11y/noLabelWithoutControl: settings rows use label-styled text with adjacent custom toggle controls */
|
||||
import { useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { ConfirmModal, ExportModal } from "../components";
|
||||
import { useAppContext } from "../context";
|
||||
@@ -6,10 +7,15 @@ import { getSystemLocale } from "../utils/formatters";
|
||||
|
||||
export function SettingsPage() {
|
||||
const { t, i18n } = useTranslation();
|
||||
const [apiKeyToken, setApiKeyToken] = useState("");
|
||||
const [apiKeyGenerating, setApiKeyGenerating] = useState(false);
|
||||
const [apiKeyCopied, setApiKeyCopied] = useState(false);
|
||||
const [apiKeyError, setApiKeyError] = useState<string | null>(null);
|
||||
const {
|
||||
settings,
|
||||
setSettings,
|
||||
settingsLoading,
|
||||
settingsLoadError,
|
||||
// Email testing
|
||||
testEmail,
|
||||
testingEmail,
|
||||
@@ -35,10 +41,95 @@ export function SettingsPage() {
|
||||
} = useAppContext();
|
||||
|
||||
const hasExistingData = meds.length > 0;
|
||||
let emailUnavailableReason: string | null = null;
|
||||
if (settingsLoadError === "auth") {
|
||||
emailUnavailableReason = t("settings.email.loadErrorAuth");
|
||||
} else if (settingsLoadError === "forbidden") {
|
||||
emailUnavailableReason = t("settings.email.loadErrorForbidden");
|
||||
} else if (settingsLoadError === "request") {
|
||||
emailUnavailableReason = t("settings.email.loadErrorGeneric");
|
||||
} else if (!settings.smtpHost) {
|
||||
emailUnavailableReason = t("settings.email.serverNotConfigured");
|
||||
}
|
||||
|
||||
const generateApiKey = async () => {
|
||||
setApiKeyGenerating(true);
|
||||
setApiKeyError(null);
|
||||
setApiKeyCopied(false);
|
||||
|
||||
try {
|
||||
const response = await fetch("/api/auth/api-keys", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
credentials: "include",
|
||||
body: JSON.stringify({
|
||||
name: "Default API Key",
|
||||
scope: "write",
|
||||
}),
|
||||
});
|
||||
|
||||
const data = await response.json().catch(() => ({}));
|
||||
if (!response.ok || typeof data?.token !== "string" || !data.token) {
|
||||
setApiKeyError(t("settings.apiKey.generateError"));
|
||||
return;
|
||||
}
|
||||
|
||||
setApiKeyToken(data.token);
|
||||
} catch {
|
||||
setApiKeyError(t("settings.apiKey.generateError"));
|
||||
} finally {
|
||||
setApiKeyGenerating(false);
|
||||
}
|
||||
};
|
||||
|
||||
const copyApiKeyToken = async () => {
|
||||
if (!apiKeyToken) return;
|
||||
|
||||
const markCopied = () => {
|
||||
setApiKeyCopied(true);
|
||||
setTimeout(() => setApiKeyCopied(false), 2000);
|
||||
};
|
||||
|
||||
if (navigator.clipboard?.writeText) {
|
||||
try {
|
||||
await navigator.clipboard.writeText(apiKeyToken);
|
||||
markCopied();
|
||||
return;
|
||||
} catch {
|
||||
// Fall back to textarea-based copy.
|
||||
}
|
||||
}
|
||||
|
||||
const textarea = document.createElement("textarea");
|
||||
textarea.value = apiKeyToken;
|
||||
textarea.style.position = "fixed";
|
||||
textarea.style.opacity = "0";
|
||||
document.body.appendChild(textarea);
|
||||
textarea.select();
|
||||
try {
|
||||
document.execCommand("copy");
|
||||
markCopied();
|
||||
} finally {
|
||||
document.body.removeChild(textarea);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<section className="grid">
|
||||
{settingsLoading ? (
|
||||
<p>{t("settings.loading")}</p>
|
||||
<div className="page-loading-skeleton" aria-busy="true">
|
||||
<span className="screen-reader-only">{t("settings.loading")}</span>
|
||||
<article className="card skeleton-card">
|
||||
<span className="skeleton-line skeleton-line-short" />
|
||||
<span className="skeleton-line skeleton-line-medium" />
|
||||
</article>
|
||||
<article className="card skeleton-card">
|
||||
<span className="skeleton-line skeleton-line-short" />
|
||||
<span className="skeleton-line skeleton-line-long" />
|
||||
<span className="skeleton-line skeleton-line-medium" />
|
||||
<span className="skeleton-line skeleton-line-long" />
|
||||
</article>
|
||||
</div>
|
||||
) : (
|
||||
<div className="settings-form">
|
||||
{/* Language */}
|
||||
@@ -60,7 +151,7 @@ export function SettingsPage() {
|
||||
body: JSON.stringify({ language: lang }),
|
||||
});
|
||||
}}
|
||||
className="language-select"
|
||||
className="select-field language-select"
|
||||
>
|
||||
<option value="en">🇬🇧 English</option>
|
||||
<option value="de">🇩🇪 Deutsch</option>
|
||||
@@ -68,6 +159,46 @@ export function SettingsPage() {
|
||||
</label>
|
||||
</article>
|
||||
|
||||
<article className="card">
|
||||
<div className="card-head">
|
||||
<h2>{t("settings.apiKey.title")}</h2>
|
||||
</div>
|
||||
<div className="setting-section">
|
||||
<div className="setting-group" style={{ gridTemplateColumns: "1fr" }}>
|
||||
<div className="action-card">
|
||||
<div className="action-card-content">
|
||||
<span className="action-card-title">{t("settings.apiKey.generateTitle")}</span>
|
||||
<span className="action-card-desc">{t("settings.apiKey.generateDesc")}</span>
|
||||
</div>
|
||||
<button type="button" className="secondary" onClick={generateApiKey} disabled={apiKeyGenerating}>
|
||||
{apiKeyGenerating ? t("settings.apiKey.generating") : t("settings.apiKey.generateButton")}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{apiKeyToken ? (
|
||||
<div>
|
||||
<span className="field-label">{t("settings.apiKey.currentToken")}</span>
|
||||
<div className="setting-actions api-key-actions">
|
||||
<input
|
||||
type="text"
|
||||
className="api-key-token-input"
|
||||
value={apiKeyToken}
|
||||
readOnly
|
||||
onClick={(e) => (e.target as HTMLInputElement).select()}
|
||||
/>
|
||||
<button type="button" className="ghost" onClick={copyApiKeyToken}>
|
||||
{apiKeyCopied ? t("settings.apiKey.copied") : t("settings.apiKey.copyButton")}
|
||||
</button>
|
||||
</div>
|
||||
<p className="hint-text">{t("settings.apiKey.copyHint")}</p>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{apiKeyError ? <p className="danger-text">{apiKeyError}</p> : null}
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
|
||||
{/* Notifications */}
|
||||
<article className="card">
|
||||
<div className="card-head">
|
||||
@@ -361,6 +492,11 @@ export function SettingsPage() {
|
||||
<span className="toggle-slider"></span>
|
||||
</label>
|
||||
</div>
|
||||
{emailUnavailableReason && (
|
||||
<div className="setting-actions">
|
||||
<span className={settingsLoadError ? "danger-text" : "info-text"}>{emailUnavailableReason}</span>
|
||||
</div>
|
||||
)}
|
||||
{settings.emailEnabled && (
|
||||
<>
|
||||
<div className="setting-group">
|
||||
@@ -375,12 +511,19 @@ export function SettingsPage() {
|
||||
</span>
|
||||
</span>
|
||||
<input
|
||||
type="email"
|
||||
type="text"
|
||||
value={settings.notificationEmail}
|
||||
onChange={(e) => setSettings({ ...settings, notificationEmail: e.target.value })}
|
||||
placeholder="your@email.com"
|
||||
placeholder="recipient address"
|
||||
pattern="[a-z0-9._%+\-]+@[a-z0-9.\-]+\.[a-z]{2,}$"
|
||||
autoComplete="email"
|
||||
inputMode="email"
|
||||
autoComplete="off"
|
||||
autoCapitalize="none"
|
||||
autoCorrect="off"
|
||||
spellCheck={false}
|
||||
data-bwignore="true"
|
||||
data-lpignore="true"
|
||||
data-1p-ignore="true"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,342 @@
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useParams } from "react-router-dom";
|
||||
import { MedicationAvatar } from "../components";
|
||||
import type { SharedMedicationOverviewItem, SharedMedicationOverviewResponse } from "../types";
|
||||
import { getSystemLocale } from "../utils/formatters";
|
||||
|
||||
type ThemePreference = "light" | "dark" | "system";
|
||||
|
||||
function getSystemTheme(): "light" | "dark" {
|
||||
if (typeof window !== "undefined" && window.matchMedia?.("(prefers-color-scheme: light)").matches) {
|
||||
return "light";
|
||||
}
|
||||
return "dark";
|
||||
}
|
||||
|
||||
function formatPackageInfo(medication: SharedMedicationOverviewItem): string {
|
||||
if (medication.packageType === "blister") {
|
||||
return `${medication.packCount} x ${medication.blistersPerPack} x ${medication.pillsPerBlister}`;
|
||||
}
|
||||
|
||||
if (medication.totalPills !== null) {
|
||||
return `${medication.packCount} x ${medication.totalPills}`;
|
||||
}
|
||||
|
||||
return `${medication.packCount}`;
|
||||
}
|
||||
|
||||
function formatDate(dateValue: string | null, locale: string): string {
|
||||
if (!dateValue) return "-";
|
||||
const parsed = new Date(`${dateValue}T00:00:00`);
|
||||
if (Number.isNaN(parsed.getTime())) return dateValue;
|
||||
return parsed.toLocaleDateString(locale);
|
||||
}
|
||||
|
||||
export function SharedOverviewPage() {
|
||||
const { token } = useParams<{ token: string }>();
|
||||
const { t, i18n } = useTranslation();
|
||||
|
||||
const [data, setData] = useState<SharedMedicationOverviewResponse | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [expiredAt, setExpiredAt] = useState<string | null>(null);
|
||||
|
||||
const [themePreference, setThemePreference] = useState<ThemePreference>(() => {
|
||||
if (typeof window !== "undefined") {
|
||||
const stored = localStorage.getItem("theme") as ThemePreference | null;
|
||||
if (stored === "light" || stored === "dark" || stored === "system") return stored;
|
||||
}
|
||||
return "dark";
|
||||
});
|
||||
const [themeMenuOpen, setThemeMenuOpen] = useState(false);
|
||||
const themeMenuRef = useRef<HTMLDivElement>(null);
|
||||
const resolvedTheme = themePreference === "system" ? getSystemTheme() : themePreference;
|
||||
|
||||
useEffect(() => {
|
||||
document.documentElement.setAttribute("data-theme", resolvedTheme);
|
||||
localStorage.setItem("theme", themePreference);
|
||||
}, [themePreference, resolvedTheme]);
|
||||
|
||||
useEffect(() => {
|
||||
if (themePreference !== "system") return;
|
||||
const mq = window.matchMedia?.("(prefers-color-scheme: light)");
|
||||
if (!mq) return;
|
||||
const handler = () => {
|
||||
const resolved = mq.matches ? "light" : "dark";
|
||||
document.documentElement.setAttribute("data-theme", resolved);
|
||||
};
|
||||
mq.addEventListener("change", handler);
|
||||
return () => mq.removeEventListener("change", handler);
|
||||
}, [themePreference]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!themeMenuOpen) return;
|
||||
const handleClickOutside = (event: MouseEvent) => {
|
||||
if (themeMenuRef.current && !themeMenuRef.current.contains(event.target as Node)) {
|
||||
setThemeMenuOpen(false);
|
||||
}
|
||||
};
|
||||
document.addEventListener("click", handleClickOutside);
|
||||
return () => document.removeEventListener("click", handleClickOutside);
|
||||
}, [themeMenuOpen]);
|
||||
|
||||
useEffect(() => {
|
||||
let isCancelled = false;
|
||||
|
||||
async function loadOverview() {
|
||||
if (!token) {
|
||||
if (!isCancelled) {
|
||||
setLoading(false);
|
||||
setError(t("sharedOverview.error.notFound"));
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
setExpiredAt(null);
|
||||
|
||||
try {
|
||||
const response = await fetch(`/api/share/${token}/overview`);
|
||||
const responseData = await response.json().catch(() => ({}));
|
||||
|
||||
if (!response.ok) {
|
||||
if (response.status === 404) {
|
||||
throw new Error("not_found");
|
||||
}
|
||||
if (response.status === 410) {
|
||||
setExpiredAt(responseData.expiredAt ?? null);
|
||||
throw new Error("expired");
|
||||
}
|
||||
if (response.status === 429) {
|
||||
throw new Error("rate_limited");
|
||||
}
|
||||
throw new Error("load_failed");
|
||||
}
|
||||
|
||||
if (!isCancelled) {
|
||||
setData(responseData as SharedMedicationOverviewResponse);
|
||||
}
|
||||
} catch (loadError) {
|
||||
if (isCancelled) return;
|
||||
|
||||
const message = loadError instanceof Error ? loadError.message : "load_failed";
|
||||
if (message === "not_found") {
|
||||
setError(t("sharedOverview.error.notFound"));
|
||||
return;
|
||||
}
|
||||
if (message === "expired") {
|
||||
setError(t("sharedOverview.error.expired"));
|
||||
return;
|
||||
}
|
||||
if (message === "rate_limited") {
|
||||
setError(t("sharedOverview.error.rateLimit"));
|
||||
return;
|
||||
}
|
||||
|
||||
setError(t("sharedOverview.error.generic"));
|
||||
} finally {
|
||||
if (!isCancelled) {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void loadOverview();
|
||||
|
||||
return () => {
|
||||
isCancelled = true;
|
||||
};
|
||||
}, [token, t]);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="shared-schedule-page">
|
||||
<div className="shared-schedule-loading shared-schedule-loading-skeleton" aria-busy="true">
|
||||
<h1>💊 MedAssist-ng</h1>
|
||||
<span className="screen-reader-only">{t("common.loading")}</span>
|
||||
<div className="skeleton-card">
|
||||
<span className="skeleton-line skeleton-line-long" />
|
||||
<span className="skeleton-line skeleton-line-medium" />
|
||||
<span className="skeleton-line skeleton-line-short" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error || !data) {
|
||||
return (
|
||||
<div className="shared-schedule-page">
|
||||
<div className={`shared-schedule-error${expiredAt ? " expired" : ""}`}>
|
||||
<h1>💊 MedAssist-ng</h1>
|
||||
{expiredAt ? <div className="expired-icon">⏰</div> : null}
|
||||
<h2>{t("sharedOverview.title")}</h2>
|
||||
<p className="error-message">{error ?? t("sharedOverview.error.generic")}</p>
|
||||
{expiredAt ? (
|
||||
<p className="expired-date">
|
||||
{t("sharedOverview.expiredOn", {
|
||||
date: new Date(expiredAt).toLocaleDateString(getSystemLocale(i18n.language)),
|
||||
})}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const locale = getSystemLocale(i18n.language);
|
||||
|
||||
return (
|
||||
<div className="shared-schedule-page">
|
||||
<div className="shared-schedule-container shared-overview-container">
|
||||
<header className="shared-schedule-header">
|
||||
<h1>{t("sharedOverview.title", { person: data.takenBy })}</h1>
|
||||
<p className="shared-schedule-period">{t("sharedOverview.sharedBy", { user: data.sharedBy ?? "-" })}</p>
|
||||
<div className="shared-schedule-header-actions">
|
||||
<div className={`theme-menu ${themeMenuOpen ? "open" : ""}`} ref={themeMenuRef}>
|
||||
<button className="icon-btn" onClick={() => setThemeMenuOpen(!themeMenuOpen)} title={t("theme.title")}>
|
||||
{resolvedTheme === "dark" ? "🌙" : "☀️"}
|
||||
</button>
|
||||
<div className="theme-dropdown">
|
||||
<button
|
||||
className={`theme-dropdown-item${themePreference === "light" ? " active" : ""}`}
|
||||
onClick={() => {
|
||||
setThemePreference("light");
|
||||
setThemeMenuOpen(false);
|
||||
}}
|
||||
>
|
||||
{t("theme.light")}
|
||||
</button>
|
||||
<button
|
||||
className={`theme-dropdown-item${themePreference === "dark" ? " active" : ""}`}
|
||||
onClick={() => {
|
||||
setThemePreference("dark");
|
||||
setThemeMenuOpen(false);
|
||||
}}
|
||||
>
|
||||
{t("theme.dark")}
|
||||
</button>
|
||||
<button
|
||||
className={`theme-dropdown-item${themePreference === "system" ? " active" : ""}`}
|
||||
onClick={() => {
|
||||
setThemePreference("system");
|
||||
setThemeMenuOpen(false);
|
||||
}}
|
||||
>
|
||||
{t("theme.system")}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{data.medications.length === 0 ? (
|
||||
<p className="shared-schedule-empty">{t("sharedOverview.noMedications")}</p>
|
||||
) : (
|
||||
<>
|
||||
<div className="shared-overview-table-wrap">
|
||||
<table className="shared-overview-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>{t("sharedOverview.columns.name")}</th>
|
||||
<th>{t("sharedOverview.columns.package")}</th>
|
||||
<th>{t("sharedOverview.columns.stock")}</th>
|
||||
<th>{t("sharedOverview.columns.daysLeft")}</th>
|
||||
<th>{t("sharedOverview.columns.nextIntake")}</th>
|
||||
<th>{t("sharedOverview.columns.depletion")}</th>
|
||||
<th>{t("sharedOverview.columns.priority")}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{data.medications.map((medication) => {
|
||||
const priorityKey =
|
||||
medication.priority === "high"
|
||||
? "sharedOverview.priority.high"
|
||||
: "sharedOverview.priority.normal";
|
||||
return (
|
||||
<tr key={`${medication.name}-${medication.medicationStartDate ?? "no-start"}`}>
|
||||
<td>
|
||||
<div className="shared-overview-medication-cell">
|
||||
<MedicationAvatar name={medication.name} imageUrl={medication.imageUrl} size="sm" />
|
||||
<div className="shared-overview-medication-text">
|
||||
<strong>{medication.name}</strong>
|
||||
{medication.genericName ? <span>{medication.genericName}</span> : null}
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td>{formatPackageInfo(medication)}</td>
|
||||
<td>
|
||||
{medication.currentStock === null || medication.capacity === null
|
||||
? "-"
|
||||
: t("sharedOverview.stock.of", {
|
||||
current: medication.currentStock,
|
||||
capacity: medication.capacity,
|
||||
})}
|
||||
</td>
|
||||
<td>{medication.daysLeft === null ? "-" : medication.daysLeft}</td>
|
||||
<td>{formatDate(medication.nextIntakeDate, locale)}</td>
|
||||
<td>{formatDate(medication.depletionDate, locale)}</td>
|
||||
<td>
|
||||
{medication.priority === null ? (
|
||||
"-"
|
||||
) : (
|
||||
<span className={`shared-overview-priority ${medication.priority}`}>{t(priorityKey)}</span>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div className="shared-overview-cards">
|
||||
{data.medications.map((medication) => {
|
||||
const priorityKey =
|
||||
medication.priority === "high" ? "sharedOverview.priority.high" : "sharedOverview.priority.normal";
|
||||
return (
|
||||
<article
|
||||
className="shared-overview-card"
|
||||
key={`${medication.name}-${medication.medicationStartDate ?? "no-start"}`}
|
||||
>
|
||||
<div className="shared-overview-card-title">
|
||||
<MedicationAvatar name={medication.name} imageUrl={medication.imageUrl} size="sm" />
|
||||
<div>
|
||||
<strong>{medication.name}</strong>
|
||||
{medication.genericName ? <p>{medication.genericName}</p> : null}
|
||||
</div>
|
||||
</div>
|
||||
<div className="shared-overview-card-grid">
|
||||
<span>{t("sharedOverview.columns.package")}</span>
|
||||
<strong>{formatPackageInfo(medication)}</strong>
|
||||
<span>{t("sharedOverview.columns.stock")}</span>
|
||||
<strong>
|
||||
{medication.currentStock === null || medication.capacity === null
|
||||
? "-"
|
||||
: t("sharedOverview.stock.of", {
|
||||
current: medication.currentStock,
|
||||
capacity: medication.capacity,
|
||||
})}
|
||||
</strong>
|
||||
<span>{t("sharedOverview.columns.daysLeft")}</span>
|
||||
<strong>{medication.daysLeft === null ? "-" : medication.daysLeft}</strong>
|
||||
<span>{t("sharedOverview.columns.nextIntake")}</span>
|
||||
<strong>{formatDate(medication.nextIntakeDate, locale)}</strong>
|
||||
<span>{t("sharedOverview.columns.depletion")}</span>
|
||||
<strong>{formatDate(medication.depletionDate, locale)}</strong>
|
||||
</div>
|
||||
{medication.priority ? (
|
||||
<span className={`shared-overview-priority ${medication.priority}`}>{t(priorityKey)}</span>
|
||||
) : null}
|
||||
</article>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -102,11 +102,17 @@ export function getReminderStatusData(
|
||||
}
|
||||
|
||||
const lowStockMeds = Array.from(lowStockMap.values()).sort((a, b) => a.daysLeft - b.daysLeft);
|
||||
const criticalCount = lowStockMeds.filter((m) => m.isCritical).length;
|
||||
const emptyCount = lowStockMeds.filter((m) => m.daysLeft <= 0).length;
|
||||
const criticalCount = lowStockMeds.filter((m) => m.isCritical && m.daysLeft > 0).length;
|
||||
const lowCount = lowStockMeds.filter((m) => !m.isCritical).length;
|
||||
|
||||
let status: { text: string; className: string };
|
||||
if (criticalCount > 0) {
|
||||
if (emptyCount > 0) {
|
||||
status = {
|
||||
text: t("dashboard.reminders.emptyStock", { count: emptyCount }),
|
||||
className: "danger",
|
||||
};
|
||||
} else if (criticalCount > 0) {
|
||||
status = {
|
||||
text: t("dashboard.reminders.criticalMeds", { count: criticalCount }),
|
||||
className: "danger",
|
||||
|
||||
@@ -4,3 +4,4 @@ export { MedicationsPage } from "./MedicationsPage";
|
||||
export { PlannerPage } from "./PlannerPage";
|
||||
export { SchedulePage } from "./SchedulePage";
|
||||
export { SettingsPage } from "./SettingsPage";
|
||||
export { SharedOverviewPage } from "./SharedOverviewPage";
|
||||
|
||||
+135
-1
@@ -431,6 +431,106 @@ body.modal-open {
|
||||
color: var(--danger);
|
||||
}
|
||||
|
||||
.reminder-status-skeleton .reminder-status-title {
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.reminder-status-skeleton-lines {
|
||||
gap: 0.45rem;
|
||||
}
|
||||
|
||||
.skeleton-line {
|
||||
display: block;
|
||||
height: 0.75rem;
|
||||
border-radius: 999px;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
background: color-mix(in srgb, var(--bg-tertiary) 78%, transparent);
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.skeleton-line::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
transform: translateX(-100%);
|
||||
background: linear-gradient(
|
||||
90deg,
|
||||
transparent 0%,
|
||||
color-mix(in srgb, var(--text-muted) 30%, transparent) 45%,
|
||||
transparent 100%
|
||||
);
|
||||
animation: reminderSkeletonSweep 1.1s linear infinite;
|
||||
}
|
||||
|
||||
.skeleton-line-long {
|
||||
width: min(540px, 96%);
|
||||
}
|
||||
|
||||
.skeleton-line-medium {
|
||||
width: min(420px, 82%);
|
||||
}
|
||||
|
||||
.skeleton-line-short {
|
||||
width: min(300px, 68%);
|
||||
}
|
||||
|
||||
.skeleton-pill {
|
||||
width: 110px;
|
||||
height: 2.25rem;
|
||||
border-radius: 10px;
|
||||
}
|
||||
|
||||
.dashboard-card-skeleton {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.55rem;
|
||||
padding: 0.35rem 0;
|
||||
}
|
||||
|
||||
.dashboard-actions-skeleton {
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.page-loading-skeleton {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.skeleton-card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.65rem;
|
||||
padding: 0.25rem 0;
|
||||
}
|
||||
|
||||
.shared-schedule-loading-skeleton .skeleton-card {
|
||||
max-width: 540px;
|
||||
margin: 0.75rem auto 0;
|
||||
}
|
||||
|
||||
.screen-reader-only {
|
||||
position: absolute;
|
||||
width: 1px;
|
||||
height: 1px;
|
||||
padding: 0;
|
||||
margin: -1px;
|
||||
overflow: hidden;
|
||||
clip: rect(0, 0, 0, 0);
|
||||
white-space: nowrap;
|
||||
border: 0;
|
||||
}
|
||||
|
||||
@keyframes reminderSkeletonSweep {
|
||||
0% {
|
||||
transform: translateX(-100%);
|
||||
}
|
||||
100% {
|
||||
transform: translateX(100%);
|
||||
}
|
||||
}
|
||||
|
||||
.med-link {
|
||||
font-weight: 600;
|
||||
text-decoration: underline;
|
||||
@@ -576,6 +676,7 @@ body.modal-open {
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.select-field.schedule-days-select,
|
||||
.schedule-days-select {
|
||||
background-color: var(--accent-bg);
|
||||
border: 1px solid var(--accent);
|
||||
@@ -591,9 +692,11 @@ body.modal-open {
|
||||
flex-shrink: 0;
|
||||
background-position: right 0.6rem center;
|
||||
}
|
||||
.select-field.schedule-days-select:hover,
|
||||
.schedule-days-select:hover {
|
||||
filter: brightness(1.15);
|
||||
}
|
||||
.select-field.schedule-days-select:focus,
|
||||
.schedule-days-select:focus {
|
||||
border-color: var(--accent-light);
|
||||
}
|
||||
@@ -1704,7 +1807,8 @@ textarea.auto-resize {
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
/* Package type selector - simple dropdown style */
|
||||
/* Generic select variant used by multiple form contexts */
|
||||
.select-field,
|
||||
.package-type-select {
|
||||
width: 100%;
|
||||
padding: 0.6rem 2rem 0.6rem 0.75rem;
|
||||
@@ -1717,6 +1821,12 @@ textarea.auto-resize {
|
||||
background-position: right 0.9rem center;
|
||||
}
|
||||
|
||||
.select-field:hover,
|
||||
.package-type-select:hover {
|
||||
border-color: var(--accent);
|
||||
}
|
||||
|
||||
.select-field:focus,
|
||||
.package-type-select:focus {
|
||||
outline: none;
|
||||
border-color: var(--accent);
|
||||
@@ -1819,6 +1929,7 @@ button.has-validation-error {
|
||||
min-width: 100px;
|
||||
}
|
||||
|
||||
.select-field.dose-unit-select,
|
||||
.dose-unit-select {
|
||||
width: auto;
|
||||
min-width: unset;
|
||||
@@ -1835,11 +1946,13 @@ button.has-validation-error {
|
||||
background-position: right 0.6rem center;
|
||||
}
|
||||
|
||||
.select-field.dose-unit-select:focus,
|
||||
.dose-unit-select:focus {
|
||||
outline: none;
|
||||
border-color: var(--accent);
|
||||
}
|
||||
|
||||
.select-field.dose-unit-select:hover,
|
||||
.dose-unit-select:hover {
|
||||
border-color: var(--accent);
|
||||
}
|
||||
@@ -3166,6 +3279,7 @@ button.has-validation-error {
|
||||
min-width: 100px;
|
||||
}
|
||||
|
||||
.select-field.language-select,
|
||||
.language-select {
|
||||
flex: 1 1 auto;
|
||||
min-width: 140px;
|
||||
@@ -3180,10 +3294,12 @@ button.has-validation-error {
|
||||
background-position: right 0.9rem center;
|
||||
}
|
||||
|
||||
.select-field.language-select:hover,
|
||||
.language-select:hover {
|
||||
border-color: var(--accent);
|
||||
}
|
||||
|
||||
.select-field.language-select:focus,
|
||||
.language-select:focus {
|
||||
outline: none;
|
||||
border-color: var(--accent);
|
||||
@@ -4124,6 +4240,24 @@ button.has-validation-error {
|
||||
padding-top: 0.5rem;
|
||||
}
|
||||
|
||||
.api-key-actions {
|
||||
padding-top: 0.35rem;
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
.api-key-token-input {
|
||||
flex: 1;
|
||||
font-family: "SF Mono", "Fira Code", monospace;
|
||||
font-size: 0.84rem;
|
||||
}
|
||||
|
||||
@media (max-width: 700px) {
|
||||
.api-key-actions {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
}
|
||||
}
|
||||
|
||||
.form-footer {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
|
||||
@@ -136,6 +136,138 @@
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
/* =============================================================================
|
||||
Shared Overview Page (Public)
|
||||
============================================================================= */
|
||||
.shared-overview-container {
|
||||
max-width: 1080px;
|
||||
}
|
||||
|
||||
.shared-overview-table-wrap {
|
||||
overflow-x: auto;
|
||||
border: 1px solid var(--border-primary);
|
||||
border-radius: 12px;
|
||||
background: var(--bg-secondary);
|
||||
}
|
||||
|
||||
.shared-overview-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
min-width: 860px;
|
||||
}
|
||||
|
||||
.shared-overview-table th,
|
||||
.shared-overview-table td {
|
||||
padding: 0.85rem 0.75rem;
|
||||
border-bottom: 1px solid var(--border-primary);
|
||||
text-align: left;
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
.shared-overview-table th {
|
||||
color: var(--text-secondary);
|
||||
font-size: 0.85rem;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.02em;
|
||||
background: var(--bg-tertiary);
|
||||
}
|
||||
|
||||
.shared-overview-table tr:last-child td {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.shared-overview-medication-cell {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.6rem;
|
||||
min-width: 220px;
|
||||
}
|
||||
|
||||
.shared-overview-medication-text {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.15rem;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.shared-overview-medication-text strong {
|
||||
font-size: 0.95rem;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.shared-overview-medication-text span {
|
||||
font-size: 0.8rem;
|
||||
color: var(--text-secondary);
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.shared-overview-priority {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding: 0.2rem 0.5rem;
|
||||
border-radius: 999px;
|
||||
font-size: 0.8rem;
|
||||
font-weight: 600;
|
||||
border: 1px solid transparent;
|
||||
}
|
||||
|
||||
.shared-overview-priority.normal {
|
||||
color: var(--success);
|
||||
background: var(--success-bg);
|
||||
border-color: rgba(16, 185, 129, 0.25);
|
||||
}
|
||||
|
||||
.shared-overview-priority.high {
|
||||
color: var(--danger);
|
||||
background: var(--danger-bg);
|
||||
border-color: rgba(239, 68, 68, 0.25);
|
||||
}
|
||||
|
||||
.shared-overview-cards {
|
||||
display: none;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.shared-overview-card {
|
||||
background: var(--bg-secondary);
|
||||
border: 1px solid var(--border-primary);
|
||||
border-radius: 12px;
|
||||
padding: 0.9rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.shared-overview-card-title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.6rem;
|
||||
}
|
||||
|
||||
.shared-overview-card-title p {
|
||||
margin: 0.1rem 0 0;
|
||||
font-size: 0.8rem;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.shared-overview-card-grid {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(120px, 1fr) minmax(0, 1fr);
|
||||
gap: 0.35rem 0.75rem;
|
||||
}
|
||||
|
||||
.shared-overview-card-grid span {
|
||||
color: var(--text-secondary);
|
||||
font-size: 0.82rem;
|
||||
}
|
||||
|
||||
.shared-overview-card-grid strong {
|
||||
font-size: 0.88rem;
|
||||
font-weight: 600;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
@media (max-width: 600px) {
|
||||
.shared-schedule-page {
|
||||
padding: 1rem;
|
||||
@@ -148,6 +280,14 @@
|
||||
.shared-timeline {
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.shared-overview-table-wrap {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.shared-overview-cards {
|
||||
display: grid;
|
||||
}
|
||||
}
|
||||
|
||||
/* ── Desktop Edit Panel (two-column layout) ── */
|
||||
|
||||
@@ -48,14 +48,21 @@
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.share-dialog-form select {
|
||||
.share-dialog-form .select-field {
|
||||
width: 100%;
|
||||
padding: 0.75rem;
|
||||
padding: 0.6rem 2rem 0.6rem 0.75rem;
|
||||
border: 1px solid var(--border-primary);
|
||||
border-radius: 8px;
|
||||
background-color: var(--bg-input);
|
||||
background-color: var(--bg-secondary);
|
||||
color: var(--text-primary);
|
||||
font-size: 1rem;
|
||||
font-size: 0.9rem;
|
||||
cursor: pointer;
|
||||
background-position: right 0.9rem center;
|
||||
}
|
||||
|
||||
.share-dialog-form .select-field:focus {
|
||||
outline: none;
|
||||
border-color: var(--accent);
|
||||
}
|
||||
|
||||
.share-dialog-footer {
|
||||
@@ -86,6 +93,14 @@
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.share-link-label {
|
||||
margin: 0.75rem 0 0.4rem;
|
||||
text-align: left;
|
||||
font-size: 0.8rem;
|
||||
color: var(--text-secondary);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.share-link-box {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
|
||||
@@ -57,6 +57,7 @@ vi.mock("../pages", () => ({
|
||||
PlannerPage: () => <div>planner-page</div>,
|
||||
SchedulePage: () => <div>schedule-page</div>,
|
||||
SettingsPage: () => <div>settings-page</div>,
|
||||
SharedOverviewPage: () => <div>shared-overview-page</div>,
|
||||
}));
|
||||
|
||||
describe("App", () => {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { fireEvent, render, screen } from "@testing-library/react";
|
||||
import { fireEvent, render, screen, within } from "@testing-library/react";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { MedDetailModal } from "../../components/MedDetailModal";
|
||||
import type { Coverage, Medication, RefillEntry, StockThresholds } from "../../types";
|
||||
@@ -410,6 +410,112 @@ describe("MedDetailModal with refill modal", () => {
|
||||
|
||||
expect(screen.getByText("editStock.packageSize_150")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows bottles-based refill input for liquid container and preview in ml package amount", () => {
|
||||
const liquidMed: Medication = {
|
||||
...mockMedication,
|
||||
name: "Liquid Med",
|
||||
packageType: "liquid_container",
|
||||
packCount: 1,
|
||||
packageAmountValue: 150,
|
||||
packageAmountUnit: "ml",
|
||||
totalPills: 150,
|
||||
looseTablets: 150,
|
||||
};
|
||||
|
||||
render(<MedDetailModal {...defaultProps} selectedMed={liquidMed} showRefillModal={true} refillLoose={150} />);
|
||||
|
||||
const refillModal = document.querySelector(".refill-modal");
|
||||
expect(refillModal).not.toBeNull();
|
||||
expect(within(refillModal as HTMLElement).getByText(/form\.bottles/i)).toBeInTheDocument();
|
||||
expect(screen.queryByText(/refill\.pillsToAdd/i)).not.toBeInTheDocument();
|
||||
expect(screen.getByText(/\+150 form\.packageAmountUnitMl/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("maps liquid refill bottle input to package amount in ml", () => {
|
||||
const liquidMed: Medication = {
|
||||
...mockMedication,
|
||||
name: "Liquid Med",
|
||||
packageType: "liquid_container",
|
||||
packCount: 1,
|
||||
packageAmountValue: 150,
|
||||
packageAmountUnit: "ml",
|
||||
totalPills: 150,
|
||||
looseTablets: 150,
|
||||
};
|
||||
const onRefillLooseChange = vi.fn();
|
||||
const onRefillPacksChange = vi.fn();
|
||||
|
||||
render(
|
||||
<MedDetailModal
|
||||
{...defaultProps}
|
||||
selectedMed={liquidMed}
|
||||
showRefillModal={true}
|
||||
onRefillLooseChange={onRefillLooseChange}
|
||||
onRefillPacksChange={onRefillPacksChange}
|
||||
refillLoose={0}
|
||||
/>
|
||||
);
|
||||
|
||||
const input = document.querySelector(".refill-modal input[type='number']") as HTMLInputElement;
|
||||
fireEvent.change(input, { target: { value: "2" } });
|
||||
|
||||
expect(onRefillPacksChange).toHaveBeenCalledWith(2);
|
||||
expect(onRefillLooseChange).toHaveBeenCalledWith(300);
|
||||
});
|
||||
|
||||
it("shows tubes-based refill input for tube package and preview in g package amount", () => {
|
||||
const tubeMed: Medication = {
|
||||
...mockMedication,
|
||||
name: "Tube Med",
|
||||
packageType: "tube",
|
||||
packCount: 4,
|
||||
packageAmountValue: 150,
|
||||
packageAmountUnit: "g",
|
||||
totalPills: 600,
|
||||
looseTablets: 600,
|
||||
};
|
||||
|
||||
render(<MedDetailModal {...defaultProps} selectedMed={tubeMed} showRefillModal={true} refillLoose={150} />);
|
||||
|
||||
const refillModal = document.querySelector(".refill-modal");
|
||||
expect(refillModal).not.toBeNull();
|
||||
expect(within(refillModal as HTMLElement).getByText(/form\.tubes/i)).toBeInTheDocument();
|
||||
expect(screen.queryByText(/refill\.pillsToAdd/i)).not.toBeInTheDocument();
|
||||
expect(screen.getByText(/\+150 form\.packageAmountUnitG/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("maps tube refill count input to package amount in g", () => {
|
||||
const tubeMed: Medication = {
|
||||
...mockMedication,
|
||||
name: "Tube Med",
|
||||
packageType: "tube",
|
||||
packCount: 4,
|
||||
packageAmountValue: 150,
|
||||
packageAmountUnit: "g",
|
||||
totalPills: 600,
|
||||
looseTablets: 600,
|
||||
};
|
||||
const onRefillLooseChange = vi.fn();
|
||||
const onRefillPacksChange = vi.fn();
|
||||
|
||||
render(
|
||||
<MedDetailModal
|
||||
{...defaultProps}
|
||||
selectedMed={tubeMed}
|
||||
showRefillModal={true}
|
||||
onRefillLooseChange={onRefillLooseChange}
|
||||
onRefillPacksChange={onRefillPacksChange}
|
||||
refillLoose={0}
|
||||
/>
|
||||
);
|
||||
|
||||
const input = document.querySelector(".refill-modal input[type='number']") as HTMLInputElement;
|
||||
fireEvent.change(input, { target: { value: "2" } });
|
||||
|
||||
expect(onRefillPacksChange).toHaveBeenCalledWith(2);
|
||||
expect(onRefillLooseChange).toHaveBeenCalledWith(300);
|
||||
});
|
||||
});
|
||||
|
||||
describe("MedDetailModal actions", () => {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { fireEvent, render, screen } from "@testing-library/react";
|
||||
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { ShareDialog } from "../../components/ShareDialog";
|
||||
|
||||
@@ -68,8 +68,9 @@ describe("ShareDialog", () => {
|
||||
|
||||
it("shows generated link", () => {
|
||||
render(<ShareDialog {...defaultProps} shareLink="http://example.com/share/abc123" />);
|
||||
const input = screen.getByRole("textbox");
|
||||
expect(input).toHaveValue("http://example.com/share/abc123");
|
||||
const inputs = screen.getAllByRole("textbox") as HTMLInputElement[];
|
||||
expect(inputs[0]).toHaveValue("http://example.com/share/abc123");
|
||||
expect(inputs[1]).toHaveValue("http://example.com/share/abc123/overview");
|
||||
});
|
||||
|
||||
it("calls onCopyShareLink when copy button is clicked", () => {
|
||||
@@ -85,13 +86,23 @@ describe("ShareDialog", () => {
|
||||
|
||||
it("selects link text when input is clicked", () => {
|
||||
render(<ShareDialog {...defaultProps} shareLink="http://example.com/share/abc123" />);
|
||||
const input = screen.getByRole("textbox") as HTMLInputElement;
|
||||
const input = screen.getAllByRole("textbox")[0] as HTMLInputElement;
|
||||
const selectMock = vi.fn();
|
||||
input.select = selectMock;
|
||||
fireEvent.click(input);
|
||||
expect(selectMock).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("copies overview link when overview copy button is clicked", async () => {
|
||||
render(<ShareDialog {...defaultProps} shareLink="http://example.com/share/abc123" />);
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: /share\.copyOverviewLink/i }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(navigator.clipboard.writeText).toHaveBeenCalledWith("http://example.com/share/abc123/overview");
|
||||
});
|
||||
});
|
||||
|
||||
it("calls person and period change callbacks", () => {
|
||||
render(<ShareDialog {...defaultProps} />);
|
||||
|
||||
|
||||
@@ -294,4 +294,143 @@ describe("UserFilterModal", () => {
|
||||
expect(screen.queryByText("Med2")).not.toBeInTheDocument();
|
||||
expect(screen.getByText("Med3")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders tube intakes as applications and stock in g", () => {
|
||||
const onClose = vi.fn();
|
||||
const onOpenMedDetail = vi.fn();
|
||||
|
||||
const tubeMedication: Medication = {
|
||||
...mockMedication,
|
||||
id: 10,
|
||||
name: "Tube Med",
|
||||
genericName: "Tube Generic",
|
||||
packageType: "tube",
|
||||
totalPills: 600,
|
||||
looseTablets: 600,
|
||||
intakes: [
|
||||
{
|
||||
usage: 1,
|
||||
every: 1,
|
||||
start: "2024-01-01T21:04:00",
|
||||
takenBy: "John",
|
||||
intakeRemindersEnabled: true,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const tubeCoverage: Coverage = {
|
||||
name: "Tube Med",
|
||||
medsLeft: 600,
|
||||
daysLeft: null,
|
||||
depletionDate: null,
|
||||
depletionTime: null,
|
||||
nextDose: null,
|
||||
};
|
||||
|
||||
render(
|
||||
<UserFilterModal
|
||||
selectedUser="John"
|
||||
meds={[tubeMedication]}
|
||||
coverage={{ all: [tubeCoverage] }}
|
||||
settings={defaultSettings}
|
||||
onClose={onClose}
|
||||
onClearUser={vi.fn()}
|
||||
onOpenMedDetail={onOpenMedDetail}
|
||||
/>
|
||||
);
|
||||
|
||||
expect(screen.getByText(/form\.blisters\.applications_1/)).toBeInTheDocument();
|
||||
expect(screen.getByText("600/600 form.packageAmountUnitG")).toBeInTheDocument();
|
||||
expect(screen.queryByText(/600\/600 .*common\.pills/)).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders liquid container intakes and stock in ml", () => {
|
||||
const onClose = vi.fn();
|
||||
const onOpenMedDetail = vi.fn();
|
||||
|
||||
const liquidMedication: Medication = {
|
||||
...mockMedication,
|
||||
id: 11,
|
||||
name: "Liquid Container",
|
||||
genericName: "Liquid Generic",
|
||||
packageType: "liquid_container",
|
||||
totalPills: 150,
|
||||
looseTablets: 150,
|
||||
intakes: [
|
||||
{
|
||||
usage: 2,
|
||||
every: 1,
|
||||
start: "2024-01-01T09:32:00",
|
||||
intakeUnit: "ml",
|
||||
takenBy: "John",
|
||||
intakeRemindersEnabled: true,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const liquidCoverage: Coverage = {
|
||||
name: "Liquid Container",
|
||||
medsLeft: 0,
|
||||
daysLeft: 0,
|
||||
depletionDate: null,
|
||||
depletionTime: null,
|
||||
nextDose: null,
|
||||
};
|
||||
|
||||
render(
|
||||
<UserFilterModal
|
||||
selectedUser="John"
|
||||
meds={[liquidMedication]}
|
||||
coverage={{ all: [liquidCoverage] }}
|
||||
settings={defaultSettings}
|
||||
onClose={onClose}
|
||||
onClearUser={vi.fn()}
|
||||
onOpenMedDetail={onOpenMedDetail}
|
||||
/>
|
||||
);
|
||||
|
||||
expect(screen.getByText(/2 form\.packageAmountUnitMl common\.daily/)).toBeInTheDocument();
|
||||
expect(screen.getByText("0/150 form.packageAmountUnitMl")).toBeInTheDocument();
|
||||
expect(screen.queryByText(/0\/150 .*common\.pills/)).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders medicationForm liquid as ml in modal fallback", () => {
|
||||
const onClose = vi.fn();
|
||||
const onOpenMedDetail = vi.fn();
|
||||
|
||||
const legacyLiquidMedication: Medication = {
|
||||
...mockMedication,
|
||||
id: 12,
|
||||
name: "Legacy Liquid",
|
||||
medicationForm: "liquid",
|
||||
packageType: "bottle",
|
||||
totalPills: 100,
|
||||
looseTablets: 100,
|
||||
blisters: [{ usage: 1, every: 1, start: "2024-01-01T10:00:00" }],
|
||||
};
|
||||
|
||||
const legacyLiquidCoverage: Coverage = {
|
||||
name: "Legacy Liquid",
|
||||
medsLeft: 40,
|
||||
daysLeft: 10,
|
||||
depletionDate: null,
|
||||
depletionTime: null,
|
||||
nextDose: null,
|
||||
};
|
||||
|
||||
render(
|
||||
<UserFilterModal
|
||||
selectedUser="John"
|
||||
meds={[legacyLiquidMedication]}
|
||||
coverage={{ all: [legacyLiquidCoverage] }}
|
||||
settings={defaultSettings}
|
||||
onClose={onClose}
|
||||
onClearUser={vi.fn()}
|
||||
onOpenMedDetail={onOpenMedDetail}
|
||||
/>
|
||||
);
|
||||
|
||||
expect(screen.getByText(/1 form\.packageAmountUnitMl common\.daily/)).toBeInTheDocument();
|
||||
expect(screen.getByText("40/100 form.packageAmountUnitMl")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -88,6 +88,7 @@ describe("useAppContext", () => {
|
||||
saving: false,
|
||||
setSaving: vi.fn(),
|
||||
uploadingImage: false,
|
||||
clearMedicationsState: vi.fn(),
|
||||
loadMeds,
|
||||
deleteMed: vi.fn(),
|
||||
uploadMedImage: vi.fn(),
|
||||
@@ -173,6 +174,7 @@ describe("useAppContext", () => {
|
||||
expiryWarningDays: 30,
|
||||
},
|
||||
settingsLoading: false,
|
||||
settingsLoadError: null,
|
||||
settingsSaving: false,
|
||||
settingsSaved: false,
|
||||
testingEmail: false,
|
||||
@@ -186,6 +188,7 @@ describe("useAppContext", () => {
|
||||
testEmail: vi.fn(),
|
||||
testShoutrrr: vi.fn(),
|
||||
hasUnsavedChanges: false,
|
||||
resetSettingsState: vi.fn(),
|
||||
});
|
||||
|
||||
mockUseDoses.mockReturnValue({
|
||||
@@ -195,7 +198,9 @@ describe("useAppContext", () => {
|
||||
dismissedDoses: new Set<string>(),
|
||||
showClearMissedConfirm: true,
|
||||
setShowClearMissedConfirm: vi.fn(),
|
||||
clearDosesState: vi.fn(),
|
||||
getDoseId: vi.fn((base: string, person: string | null) => (person ? `${base}-${person}` : base)),
|
||||
isDoseTakenAutomatically: vi.fn(() => false),
|
||||
countTakenDoses: vi.fn(() => ({ total: 0, taken: 0 })),
|
||||
markDoseTaken: vi.fn(),
|
||||
undoDoseTaken: vi.fn(),
|
||||
@@ -234,6 +239,8 @@ describe("useAppContext", () => {
|
||||
setRefillPacks: vi.fn(),
|
||||
refillLoose: 0,
|
||||
setRefillLoose: vi.fn(),
|
||||
usePrescriptionRefill: false,
|
||||
setUsePrescriptionRefill: vi.fn(),
|
||||
refillSaving: false,
|
||||
refillHistory: [],
|
||||
refillHistoryExpanded: false,
|
||||
@@ -244,7 +251,11 @@ describe("useAppContext", () => {
|
||||
setEditStockFullBlisters: vi.fn(),
|
||||
editStockPartialBlisterPills: 0,
|
||||
setEditStockPartialBlisterPills: vi.fn(),
|
||||
editStockLoosePills: 0,
|
||||
setEditStockLoosePills: vi.fn(),
|
||||
editStockSaving: false,
|
||||
editStockMedication: null,
|
||||
clearRefillState: vi.fn(),
|
||||
loadRefillHistory: vi.fn(),
|
||||
submitRefill: vi.fn(),
|
||||
submitStockCorrection: vi.fn(),
|
||||
@@ -283,6 +294,55 @@ describe("useAppContext", () => {
|
||||
expect(result.current.settingsChanged).toBe(false);
|
||||
});
|
||||
|
||||
it("exposes the settings load error from useSettings", async () => {
|
||||
const settingsValue = mockUseSettings();
|
||||
mockUseSettings.mockReturnValue({
|
||||
...settingsValue,
|
||||
settingsLoadError: "forbidden",
|
||||
});
|
||||
|
||||
const { result } = renderHook(() => useAppContext(), { wrapper });
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.settingsLoadError).toBe("forbidden");
|
||||
});
|
||||
});
|
||||
|
||||
it("clears user-scoped state and reloads data when authenticated user changes", async () => {
|
||||
const { result, rerender } = renderHook(() => useAppContext(), { wrapper });
|
||||
|
||||
act(() => {
|
||||
result.current.openImageLightbox();
|
||||
result.current.openUserFilter("Max");
|
||||
result.current.setShowImportConfirm(true);
|
||||
result.current.setPendingImportData({ version: "1" });
|
||||
result.current.setImportResult({ medications: 1, doses: 1, refills: 0, shares: 0 });
|
||||
});
|
||||
|
||||
const clearMedicationsStateBefore = mockUseMedications().clearMedicationsState.mock.calls.length;
|
||||
const resetSettingsStateBefore = mockUseSettings().resetSettingsState.mock.calls.length;
|
||||
const clearDosesStateBefore = mockUseDoses().clearDosesState.mock.calls.length;
|
||||
const clearRefillStateBefore = mockUseRefill().clearRefillState.mock.calls.length;
|
||||
const resetShareDialogStateBefore = mockUseShare().resetShareDialogState.mock.calls.length;
|
||||
|
||||
mockUseAuth.mockReturnValue({ user: { id: 8, username: "other-user" } });
|
||||
rerender();
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockUseMedications().clearMedicationsState).toHaveBeenCalledTimes(clearMedicationsStateBefore + 1);
|
||||
expect(mockUseSettings().resetSettingsState).toHaveBeenCalledTimes(resetSettingsStateBefore + 1);
|
||||
expect(mockUseDoses().clearDosesState).toHaveBeenCalledTimes(clearDosesStateBefore + 1);
|
||||
expect(mockUseRefill().clearRefillState).toHaveBeenCalledTimes(clearRefillStateBefore + 1);
|
||||
expect(mockUseShare().resetShareDialogState).toHaveBeenCalledTimes(resetShareDialogStateBefore + 1);
|
||||
});
|
||||
|
||||
expect(result.current.selectedUser).toBeNull();
|
||||
expect(result.current.showImageLightbox).toBe(false);
|
||||
expect(result.current.showImportConfirm).toBe(false);
|
||||
expect(result.current.pendingImportData).toBeNull();
|
||||
expect(result.current.importResult).toBeNull();
|
||||
});
|
||||
|
||||
it("wraps share dialog opener with current medications", async () => {
|
||||
const { result } = renderHook(() => useAppContext(), { wrapper });
|
||||
|
||||
|
||||
@@ -304,4 +304,30 @@ describe("useDoses", () => {
|
||||
|
||||
expect(fetch).toHaveBeenCalledWith("/api/doses/taken/dose%201%2Fa", expect.objectContaining({ method: "DELETE" }));
|
||||
});
|
||||
|
||||
it("clears dose state when API returns 401", async () => {
|
||||
const mockDoses = {
|
||||
doses: [{ doseId: "dose-1", takenAt: Date.now(), dismissed: false }],
|
||||
};
|
||||
|
||||
(global.fetch as ReturnType<typeof vi.fn>)
|
||||
.mockResolvedValueOnce({ ok: true, json: () => Promise.resolve(mockDoses) })
|
||||
.mockResolvedValueOnce({ ok: false, status: 401, json: () => Promise.resolve({}) });
|
||||
|
||||
const { result } = renderHook(() => useDoses());
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.takenDoses.has("dose-1")).toBe(true);
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
await result.current.loadTakenDoses();
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.takenDoses.size).toBe(0);
|
||||
expect(result.current.dismissedDoses.size).toBe(0);
|
||||
expect(result.current.takenDoseTimestamps.size).toBe(0);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -14,6 +14,7 @@ describe("useSettings", () => {
|
||||
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it("initializes with default settings", () => {
|
||||
@@ -52,9 +53,61 @@ describe("useSettings", () => {
|
||||
|
||||
const { result } = renderHook(() => useSettings());
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.settingsLoadError).toBe("request");
|
||||
});
|
||||
});
|
||||
|
||||
it("maps a failed authenticated settings load to an auth error state", async () => {
|
||||
(global.fetch as ReturnType<typeof vi.fn>)
|
||||
.mockResolvedValueOnce({ ok: false, status: 401, json: () => Promise.resolve({}) })
|
||||
.mockResolvedValueOnce({ ok: false, status: 401, json: () => Promise.resolve({}) });
|
||||
|
||||
const { result } = renderHook(() => useSettings());
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.settingsLoadError).toBe("auth");
|
||||
});
|
||||
|
||||
expect(result.current.settings.emailEnabled).toBe(false);
|
||||
expect(result.current.settings.notificationEmail).toBe("");
|
||||
});
|
||||
|
||||
it("maps a forbidden settings load to a forbidden error state", async () => {
|
||||
(global.fetch as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
|
||||
ok: false,
|
||||
status: 403,
|
||||
json: () => Promise.resolve({}),
|
||||
});
|
||||
|
||||
const { result } = renderHook(() => useSettings());
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.settingsLoadError).toBe("forbidden");
|
||||
});
|
||||
});
|
||||
|
||||
it("retries loading settings after a successful refresh", async () => {
|
||||
(global.fetch as ReturnType<typeof vi.fn>)
|
||||
.mockResolvedValueOnce({ ok: false, status: 401, json: () => Promise.resolve({}) })
|
||||
.mockResolvedValueOnce({ ok: true, json: () => Promise.resolve({}) })
|
||||
.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: () => Promise.resolve({ emailEnabled: true, notificationEmail: "refreshed@example.com" }),
|
||||
});
|
||||
|
||||
const { result } = renderHook(() => useSettings());
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.settingsLoading).toBe(false);
|
||||
});
|
||||
|
||||
expect(result.current.settings.notificationEmail).toBe("refreshed@example.com");
|
||||
expect(global.fetch).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
"/api/auth/refresh",
|
||||
expect.objectContaining({ method: "POST", credentials: "include" })
|
||||
);
|
||||
});
|
||||
|
||||
it("saves settings to API", async () => {
|
||||
@@ -154,6 +207,28 @@ describe("useSettings", () => {
|
||||
expect(result.current.testEmailResult?.success).toBe(false);
|
||||
});
|
||||
|
||||
it("uses backend error messages for failed test email responses", async () => {
|
||||
(global.fetch as ReturnType<typeof vi.fn>)
|
||||
.mockResolvedValueOnce({ ok: true, json: () => Promise.resolve({}) })
|
||||
.mockResolvedValueOnce({
|
||||
ok: false,
|
||||
status: 400,
|
||||
json: () => Promise.resolve({ message: "Recipient rejected" }),
|
||||
});
|
||||
|
||||
const { result } = renderHook(() => useSettings());
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.settingsLoading).toBe(false);
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
await result.current.testEmail();
|
||||
});
|
||||
|
||||
expect(result.current.testEmailResult).toEqual({ success: false, message: "Recipient rejected" });
|
||||
});
|
||||
|
||||
it("tests shoutrrr notification", async () => {
|
||||
(global.fetch as ReturnType<typeof vi.fn>)
|
||||
.mockResolvedValueOnce({ ok: true, json: () => Promise.resolve({}) })
|
||||
@@ -176,6 +251,28 @@ describe("useSettings", () => {
|
||||
expect(result.current.testingShoutrrr).toBe(false);
|
||||
});
|
||||
|
||||
it("uses backend error messages for failed test notifications", async () => {
|
||||
(global.fetch as ReturnType<typeof vi.fn>)
|
||||
.mockResolvedValueOnce({ ok: true, json: () => Promise.resolve({}) })
|
||||
.mockResolvedValueOnce({
|
||||
ok: false,
|
||||
status: 500,
|
||||
json: () => Promise.resolve({ message: "Push target rejected" }),
|
||||
});
|
||||
|
||||
const { result } = renderHook(() => useSettings());
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.settingsLoading).toBe(false);
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
await result.current.testShoutrrr();
|
||||
});
|
||||
|
||||
expect(result.current.testShoutrrrResult).toEqual({ success: false, message: "Push target rejected" });
|
||||
});
|
||||
|
||||
it("tracks unsaved changes", async () => {
|
||||
(global.fetch as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
|
||||
ok: true,
|
||||
@@ -278,6 +375,68 @@ describe("useSettings", () => {
|
||||
expect(result.current.settings.shoutrrrEnabled).toBe(true);
|
||||
});
|
||||
|
||||
it("reloads backend state when saving settings fails", async () => {
|
||||
(global.fetch as ReturnType<typeof vi.fn>)
|
||||
.mockResolvedValueOnce({ ok: true, json: () => Promise.resolve({}) })
|
||||
.mockResolvedValueOnce({ ok: false, status: 500, json: () => Promise.resolve({}) })
|
||||
.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: () => Promise.resolve({ lowStockDays: 14, notificationEmail: "server@example.com" }),
|
||||
});
|
||||
|
||||
const { result } = renderHook(() => useSettings());
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.settingsLoading).toBe(false);
|
||||
});
|
||||
|
||||
act(() => {
|
||||
result.current.setSettings((current) => ({ ...current, lowStockDays: 99 }));
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
await result.current.saveSettings();
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.settings.lowStockDays).toBe(14);
|
||||
});
|
||||
|
||||
expect(result.current.settingsSaved).toBe(false);
|
||||
expect(result.current.settings.notificationEmail).toBe("server@example.com");
|
||||
});
|
||||
|
||||
it("resets all transient state back to defaults", async () => {
|
||||
(global.fetch as ReturnType<typeof vi.fn>)
|
||||
.mockResolvedValueOnce({ ok: true, json: () => Promise.resolve({ notificationEmail: "test@example.com" }) })
|
||||
.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: () => Promise.resolve({ message: "Email sent!" }),
|
||||
});
|
||||
|
||||
const { result } = renderHook(() => useSettings());
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.settingsLoading).toBe(false);
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
await result.current.testEmail();
|
||||
});
|
||||
|
||||
expect(result.current.testEmailResult).toEqual({ success: true, message: "Email sent!" });
|
||||
|
||||
act(() => {
|
||||
result.current.resetSettingsState();
|
||||
});
|
||||
|
||||
expect(result.current.settings.notificationEmail).toBe("");
|
||||
expect(result.current.savedSettings.notificationEmail).toBe("");
|
||||
expect(result.current.testEmailResult).toBeNull();
|
||||
expect(result.current.settingsSaved).toBe(false);
|
||||
expect(result.current.settingsLoadError).toBeNull();
|
||||
});
|
||||
|
||||
it("refreshes reminder status on interval", async () => {
|
||||
let refreshCallback: (() => void) | null = null;
|
||||
const nativeSetInterval = global.setInterval;
|
||||
@@ -324,4 +483,148 @@ describe("useSettings", () => {
|
||||
expect(result.current.settings.lastStockReminderChannel).toBe("both");
|
||||
});
|
||||
});
|
||||
|
||||
it("clears reminder metadata when refresh returns explicit null values", async () => {
|
||||
let refreshCallback: (() => void) | null = null;
|
||||
const nativeSetInterval = global.setInterval;
|
||||
vi.spyOn(global, "setInterval").mockImplementation(((handler: TimerHandler, timeout?: number) => {
|
||||
if (timeout === 30000) {
|
||||
refreshCallback = handler as () => void;
|
||||
return 1 as unknown as ReturnType<typeof setInterval>;
|
||||
}
|
||||
return nativeSetInterval(handler, timeout);
|
||||
}) as typeof setInterval);
|
||||
|
||||
(global.fetch as ReturnType<typeof vi.fn>)
|
||||
.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: () =>
|
||||
Promise.resolve({
|
||||
lastAutoEmailSent: "2026-01-01T10:00:00.000Z",
|
||||
lastNotificationType: "stock",
|
||||
lastNotificationChannel: "email",
|
||||
lastReminderMedName: "Aspirin",
|
||||
lastReminderTakenBy: "Max",
|
||||
lastStockReminderSent: "2026-01-01T09:00:00.000Z",
|
||||
lastStockReminderChannel: "both",
|
||||
lastStockReminderMedNames: "Aspirin",
|
||||
}),
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: () =>
|
||||
Promise.resolve({
|
||||
lastAutoEmailSent: null,
|
||||
lastNotificationType: null,
|
||||
lastNotificationChannel: null,
|
||||
lastReminderMedName: null,
|
||||
lastReminderTakenBy: null,
|
||||
lastStockReminderSent: null,
|
||||
lastStockReminderChannel: null,
|
||||
lastStockReminderMedNames: null,
|
||||
lastPrescriptionReminderSent: null,
|
||||
lastPrescriptionReminderChannel: null,
|
||||
lastPrescriptionReminderMedNames: null,
|
||||
}),
|
||||
});
|
||||
|
||||
const { result } = renderHook(() => useSettings());
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.settingsLoading).toBe(false);
|
||||
});
|
||||
|
||||
expect(result.current.settings.lastNotificationType).toBe("stock");
|
||||
expect(refreshCallback).not.toBeNull();
|
||||
|
||||
act(() => {
|
||||
refreshCallback?.();
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.settings.lastAutoEmailSent).toBeNull();
|
||||
expect(result.current.settings.lastNotificationType).toBeNull();
|
||||
expect(result.current.settings.lastNotificationChannel).toBeNull();
|
||||
expect(result.current.settings.lastReminderMedName).toBeNull();
|
||||
expect(result.current.settings.lastStockReminderSent).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
it("clears reminder metadata when refresh returns 401", async () => {
|
||||
let refreshCallback: (() => void) | null = null;
|
||||
const nativeSetInterval = global.setInterval;
|
||||
vi.spyOn(global, "setInterval").mockImplementation(((handler: TimerHandler, timeout?: number) => {
|
||||
if (timeout === 30000) {
|
||||
refreshCallback = handler as () => void;
|
||||
return 1 as unknown as ReturnType<typeof setInterval>;
|
||||
}
|
||||
return nativeSetInterval(handler, timeout);
|
||||
}) as typeof setInterval);
|
||||
|
||||
(global.fetch as ReturnType<typeof vi.fn>)
|
||||
.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: () =>
|
||||
Promise.resolve({
|
||||
lastAutoEmailSent: "2026-01-01T10:00:00.000Z",
|
||||
lastNotificationType: "stock",
|
||||
lastNotificationChannel: "email",
|
||||
lastReminderMedName: "Aspirin",
|
||||
lastReminderTakenBy: "Max",
|
||||
lastStockReminderSent: "2026-01-01T09:00:00.000Z",
|
||||
lastStockReminderChannel: "both",
|
||||
lastStockReminderMedNames: "Aspirin",
|
||||
}),
|
||||
})
|
||||
.mockResolvedValueOnce({ ok: false, status: 401, json: () => Promise.resolve({}) })
|
||||
.mockResolvedValueOnce({ ok: false, status: 401, json: () => Promise.resolve({}) });
|
||||
|
||||
const { result } = renderHook(() => useSettings());
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.settingsLoading).toBe(false);
|
||||
});
|
||||
|
||||
expect(result.current.settings.lastNotificationType).toBe("stock");
|
||||
expect(refreshCallback).not.toBeNull();
|
||||
|
||||
act(() => {
|
||||
refreshCallback?.();
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.settings.lastAutoEmailSent).toBeNull();
|
||||
expect(result.current.settings.lastNotificationType).toBeNull();
|
||||
expect(result.current.settings.lastNotificationChannel).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
it("resets to defaults when loadSettings gets 401", async () => {
|
||||
(global.fetch as ReturnType<typeof vi.fn>)
|
||||
.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: () => Promise.resolve({ emailEnabled: true, notificationEmail: "test@example.com" }),
|
||||
})
|
||||
.mockResolvedValueOnce({ ok: false, status: 401, json: () => Promise.resolve({}) })
|
||||
.mockResolvedValueOnce({ ok: false, status: 401, json: () => Promise.resolve({}) });
|
||||
|
||||
const { result } = renderHook(() => useSettings());
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.settingsLoading).toBe(false);
|
||||
});
|
||||
|
||||
expect(result.current.settings.emailEnabled).toBe(true);
|
||||
|
||||
act(() => {
|
||||
result.current.loadSettings();
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.settingsLoadError).toBe("auth");
|
||||
});
|
||||
|
||||
expect(result.current.settings.emailEnabled).toBe(false);
|
||||
expect(result.current.settings.notificationEmail).toBe("");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -64,6 +64,7 @@ const createMockContext = (overrides = {}) => ({
|
||||
},
|
||||
setSettings: vi.fn(),
|
||||
settingsLoading: false,
|
||||
settingsLoadError: null,
|
||||
settingsSaving: false,
|
||||
settingsSaved: false,
|
||||
saveSettings: vi.fn((e?: Event) => e?.preventDefault?.()),
|
||||
@@ -292,6 +293,41 @@ describe("SettingsPage", () => {
|
||||
expect(testEmail).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("shows the settings load failure reason in the email section", () => {
|
||||
mockContextValue = createMockContext({
|
||||
settingsLoadError: "forbidden",
|
||||
settings: {
|
||||
...createMockContext().settings,
|
||||
smtpHost: "smtp.example.com",
|
||||
},
|
||||
});
|
||||
|
||||
renderPage();
|
||||
|
||||
expect(screen.getByText("settings.email.loadErrorForbidden")).toBeInTheDocument();
|
||||
expect(screen.queryByText("settings.email.serverNotConfigured")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("keeps the email toggle enabled when SMTP host is present", () => {
|
||||
mockContextValue = createMockContext({
|
||||
settings: {
|
||||
...createMockContext().settings,
|
||||
smtpHost: "smtp.example.com",
|
||||
},
|
||||
});
|
||||
|
||||
renderPage();
|
||||
|
||||
expect(screen.queryByText("settings.email.serverNotConfigured")).not.toBeInTheDocument();
|
||||
const emailHeading = screen
|
||||
.getAllByText("settings.notifications.email")
|
||||
.find((element) => element.tagName === "H3");
|
||||
expect(emailHeading).toBeDefined();
|
||||
const emailToggle = emailHeading?.parentElement?.querySelector('input[type="checkbox"]') as HTMLInputElement | null;
|
||||
expect(emailToggle).not.toBeNull();
|
||||
expect(emailToggle).not.toBeDisabled();
|
||||
});
|
||||
|
||||
it("calls testShoutrrr when push test button is clicked", () => {
|
||||
const testShoutrrr = vi.fn();
|
||||
mockContextValue = createMockContext({
|
||||
|
||||
@@ -0,0 +1,110 @@
|
||||
import { render, screen, waitFor } from "@testing-library/react";
|
||||
import { MemoryRouter, Route, Routes } from "react-router-dom";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { SharedOverviewPage } from "../../pages/SharedOverviewPage";
|
||||
|
||||
function renderSharedOverview(path: string) {
|
||||
return render(
|
||||
<MemoryRouter initialEntries={[path]}>
|
||||
<Routes>
|
||||
<Route path="/share/:token/overview" element={<SharedOverviewPage />} />
|
||||
</Routes>
|
||||
</MemoryRouter>
|
||||
);
|
||||
}
|
||||
|
||||
describe("SharedOverviewPage", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
window.localStorage.clear();
|
||||
});
|
||||
|
||||
it("renders medication overview for valid token", async () => {
|
||||
(globalThis.fetch as ReturnType<typeof vi.fn>).mockResolvedValue({
|
||||
ok: true,
|
||||
status: 200,
|
||||
json: () =>
|
||||
Promise.resolve({
|
||||
takenBy: "Max",
|
||||
sharedBy: "Owner",
|
||||
generatedAt: "2026-03-06T10:00:00.000Z",
|
||||
medications: [
|
||||
{
|
||||
name: "Aspirin",
|
||||
genericName: "Acetylsalicylic Acid",
|
||||
imageUrl: null,
|
||||
packageType: "blister",
|
||||
packCount: 1,
|
||||
blistersPerPack: 2,
|
||||
pillsPerBlister: 10,
|
||||
totalPills: null,
|
||||
looseTablets: 0,
|
||||
currentStock: 18,
|
||||
capacity: 20,
|
||||
daysLeft: 18,
|
||||
nextIntakeDate: "2026-03-07",
|
||||
depletionDate: "2026-03-24",
|
||||
priority: "normal",
|
||||
expiryDate: null,
|
||||
medicationStartDate: null,
|
||||
prescriptionEnabled: false,
|
||||
prescriptionRemainingRefills: null,
|
||||
},
|
||||
],
|
||||
}),
|
||||
});
|
||||
|
||||
renderSharedOverview("/share/abcdef0123456789/overview");
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("sharedOverview.title")).toBeInTheDocument();
|
||||
expect(screen.getAllByText("Aspirin").length).toBeGreaterThan(0);
|
||||
expect(screen.getAllByText("Acetylsalicylic Acid").length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
expect(globalThis.fetch).toHaveBeenCalledWith("/api/share/abcdef0123456789/overview");
|
||||
});
|
||||
|
||||
it("renders not found state for missing token", async () => {
|
||||
(globalThis.fetch as ReturnType<typeof vi.fn>).mockResolvedValue({
|
||||
ok: false,
|
||||
status: 404,
|
||||
json: () => Promise.resolve({ error: "not_found" }),
|
||||
});
|
||||
|
||||
renderSharedOverview("/share/abcdef0123456789/overview");
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("sharedOverview.error.notFound")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it("renders expired state for expired token", async () => {
|
||||
(globalThis.fetch as ReturnType<typeof vi.fn>).mockResolvedValue({
|
||||
ok: false,
|
||||
status: 410,
|
||||
json: () => Promise.resolve({ error: "expired", expiredAt: "2026-03-01T10:00:00.000Z" }),
|
||||
});
|
||||
|
||||
renderSharedOverview("/share/abcdef0123456789/overview");
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("sharedOverview.error.expired")).toBeInTheDocument();
|
||||
expect(screen.getByText("sharedOverview.expiredOn")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it("renders rate-limit error state", async () => {
|
||||
(globalThis.fetch as ReturnType<typeof vi.fn>).mockResolvedValue({
|
||||
ok: false,
|
||||
status: 429,
|
||||
json: () => Promise.resolve({ error: "rate_limited" }),
|
||||
});
|
||||
|
||||
renderSharedOverview("/share/abcdef0123456789/overview");
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("sharedOverview.error.rateLimit")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
+14
-10
@@ -59,18 +59,22 @@ Object.defineProperty(window, "history", {
|
||||
});
|
||||
|
||||
// Mock react-i18next globally
|
||||
const mockT = (key: string, options?: Record<string, unknown>) => {
|
||||
if (options?.count !== undefined) return `${key}_${options.count}`;
|
||||
if (options?.max !== undefined) return `Max ${options.max} chars`;
|
||||
if (options?.days !== undefined) return `${key} (${options.days} days)`;
|
||||
return key;
|
||||
};
|
||||
|
||||
const mockI18n = {
|
||||
language: "en",
|
||||
changeLanguage: vi.fn(),
|
||||
};
|
||||
|
||||
vi.mock("react-i18next", () => ({
|
||||
useTranslation: () => ({
|
||||
t: (key: string, options?: Record<string, unknown>) => {
|
||||
if (options?.count !== undefined) return `${key}_${options.count}`;
|
||||
if (options?.max !== undefined) return `Max ${options.max} chars`;
|
||||
if (options?.days !== undefined) return `${key} (${options.days} days)`;
|
||||
return key;
|
||||
},
|
||||
i18n: {
|
||||
language: "en",
|
||||
changeLanguage: vi.fn(),
|
||||
},
|
||||
t: mockT,
|
||||
i18n: mockI18n,
|
||||
}),
|
||||
I18nextProvider: ({ children }: { children: React.ReactNode }) => children,
|
||||
initReactI18next: { type: "3rdParty", init: vi.fn() },
|
||||
|
||||
@@ -264,6 +264,35 @@ export type ExpiredLinkData = {
|
||||
expiredAt: string;
|
||||
};
|
||||
|
||||
export type SharedMedicationOverviewItem = {
|
||||
name: string;
|
||||
genericName: string | null;
|
||||
imageUrl: string | null;
|
||||
packageType: PackageType;
|
||||
packCount: number;
|
||||
blistersPerPack: number;
|
||||
pillsPerBlister: number;
|
||||
totalPills: number | null;
|
||||
looseTablets: number;
|
||||
currentStock: number | null;
|
||||
capacity: number | null;
|
||||
daysLeft: number | null;
|
||||
nextIntakeDate: string | null;
|
||||
depletionDate: string | null;
|
||||
priority: "normal" | "high" | null;
|
||||
expiryDate: string | null;
|
||||
medicationStartDate: string | null;
|
||||
prescriptionEnabled: boolean;
|
||||
prescriptionRemainingRefills: number | null;
|
||||
};
|
||||
|
||||
export type SharedMedicationOverviewResponse = {
|
||||
takenBy: string;
|
||||
sharedBy: string | null;
|
||||
generatedAt: string;
|
||||
medications: SharedMedicationOverviewItem[];
|
||||
};
|
||||
|
||||
// =============================================================================
|
||||
// Field Validation Limits (must match backend)
|
||||
// =============================================================================
|
||||
|
||||
Reference in New Issue
Block a user