feat: embed medication overview into shared links

Closes #424
This commit is contained in:
Daniel Volz
2026-03-14 20:26:17 +01:00
committed by GitHub
parent fd3134be24
commit e0fb77d494
35 changed files with 2607 additions and 1297 deletions
+127 -51
View File
@@ -47,6 +47,7 @@ export interface Settings {
shoutrrrPrescriptionReminders: boolean;
stockCalculationMode: "automatic" | "manual";
shareStockStatus: boolean;
shareMedicationOverview: boolean;
upcomingTodayOnly: boolean;
shareScheduleTodayOnly: boolean;
swapDashboardMainSections: boolean;
@@ -98,6 +99,7 @@ const defaultSettings: Settings = {
shoutrrrPrescriptionReminders: true,
stockCalculationMode: "automatic",
shareStockStatus: true,
shareMedicationOverview: false,
upcomingTodayOnly: false,
shareScheduleTodayOnly: false,
swapDashboardMainSections: false,
@@ -145,6 +147,16 @@ export function useSettings(): UseSettingsReturn {
// loadSettings captures the current generation; if it changes before
// the fetch completes, the stale response is silently discarded.
const loadGenerationRef = useRef(0);
const latestSettingsRef = useRef(settings);
const latestSavedSettingsRef = useRef(savedSettings);
useEffect(() => {
latestSettingsRef.current = settings;
}, [settings]);
useEffect(() => {
latestSavedSettingsRef.current = savedSettings;
}, [savedSettings]);
const resetSettingsState = useCallback(() => {
loadGenerationRef.current += 1; // Invalidate any in-flight loadSettings
@@ -214,6 +226,69 @@ export function useSettings(): UseSettingsReturn {
return response;
}, []);
const buildSettingsPayload = useCallback(
(settingsToSave: Settings) => {
const effectiveEmailEnabled = settingsToSave.emailEnabled && !!settingsToSave.notificationEmail?.trim();
const effectiveShoutrrrEnabled = settingsToSave.shoutrrrEnabled && !!settingsToSave.shoutrrrUrl?.trim();
const hasEmailStock =
effectiveEmailEnabled && settingsToSave.emailStockReminders && !!settingsToSave.notificationEmail?.trim();
const hasShoutrrrStock =
effectiveShoutrrrEnabled && settingsToSave.shoutrrrStockReminders && !!settingsToSave.shoutrrrUrl?.trim();
const hasAnyStockReminder = hasEmailStock || hasShoutrrrStock;
const repeatDailyReminders = hasAnyStockReminder ? settingsToSave.repeatDailyReminders : false;
return {
emailEnabled: effectiveEmailEnabled,
notificationEmail: settingsToSave.notificationEmail,
reminderDaysBefore: settingsToSave.reminderDaysBefore,
repeatDailyReminders,
skipRemindersForTakenDoses: settingsToSave.skipRemindersForTakenDoses,
repeatRemindersEnabled: settingsToSave.repeatRemindersEnabled,
reminderRepeatIntervalMinutes: settingsToSave.reminderRepeatIntervalMinutes,
maxNaggingReminders: settingsToSave.maxNaggingReminders ?? 5,
lowStockDays: settingsToSave.lowStockDays,
normalStockDays: settingsToSave.normalStockDays,
highStockDays: settingsToSave.highStockDays,
shoutrrrEnabled: effectiveShoutrrrEnabled,
shoutrrrUrl: settingsToSave.shoutrrrUrl,
emailStockReminders: settingsToSave.emailStockReminders,
emailIntakeReminders: settingsToSave.emailIntakeReminders,
emailPrescriptionReminders: settingsToSave.emailPrescriptionReminders,
shoutrrrStockReminders: settingsToSave.shoutrrrStockReminders,
shoutrrrIntakeReminders: settingsToSave.shoutrrrIntakeReminders,
shoutrrrPrescriptionReminders: settingsToSave.shoutrrrPrescriptionReminders,
stockCalculationMode: settingsToSave.stockCalculationMode,
shareStockStatus: settingsToSave.shareStockStatus,
shareMedicationOverview: settingsToSave.shareMedicationOverview,
upcomingTodayOnly: settingsToSave.upcomingTodayOnly,
shareScheduleTodayOnly: settingsToSave.shareScheduleTodayOnly,
swapDashboardMainSections: settingsToSave.swapDashboardMainSections,
language: i18n.language,
smtpHost: settingsToSave.smtpHost,
smtpPort: settingsToSave.smtpPort,
smtpUser: settingsToSave.smtpUser,
smtpPass: settingsToSave.smtpPass || undefined,
smtpFrom: settingsToSave.smtpFrom,
smtpSecure: settingsToSave.smtpSecure,
};
},
[i18n.language]
);
const flushSettingsWithKeepalive = useCallback(
(settingsToSave: Settings) => {
const payload = buildSettingsPayload(settingsToSave);
void fetch("/api/settings", {
method: "PUT",
headers: { "Content-Type": "application/json" },
credentials: "include",
keepalive: true,
body: JSON.stringify(payload),
}).catch(() => {});
},
[buildSettingsPayload]
);
// Load settings function - exposed for manual refresh (e.g., after auth)
const loadSettings = useCallback(() => {
setSettingsLoading(true);
@@ -331,52 +406,19 @@ export function useSettings(): UseSettingsReturn {
// Internal save function (no event needed)
const performSave = useCallback(
async (settingsToSave: Settings) => {
// Auto-disable email if no recipient is set
const effectiveEmailEnabled = settingsToSave.emailEnabled && !!settingsToSave.notificationEmail?.trim();
// Auto-disable push if no URL is set
const effectiveShoutrrrEnabled = settingsToSave.shoutrrrEnabled && !!settingsToSave.shoutrrrUrl?.trim();
async (settingsToSave: Settings, options?: { syncState?: boolean }) => {
const syncState = options?.syncState ?? true;
const payload = buildSettingsPayload(settingsToSave);
setSettingsSaving(true);
const payload = {
emailEnabled: effectiveEmailEnabled,
notificationEmail: settingsToSave.notificationEmail,
reminderDaysBefore: settingsToSave.reminderDaysBefore,
repeatDailyReminders: settingsToSave.repeatDailyReminders,
skipRemindersForTakenDoses: settingsToSave.skipRemindersForTakenDoses,
repeatRemindersEnabled: settingsToSave.repeatRemindersEnabled,
reminderRepeatIntervalMinutes: settingsToSave.reminderRepeatIntervalMinutes,
maxNaggingReminders: settingsToSave.maxNaggingReminders ?? 5,
lowStockDays: settingsToSave.lowStockDays,
normalStockDays: settingsToSave.normalStockDays,
highStockDays: settingsToSave.highStockDays,
shoutrrrEnabled: effectiveShoutrrrEnabled,
shoutrrrUrl: settingsToSave.shoutrrrUrl,
emailStockReminders: settingsToSave.emailStockReminders,
emailIntakeReminders: settingsToSave.emailIntakeReminders,
emailPrescriptionReminders: settingsToSave.emailPrescriptionReminders,
shoutrrrStockReminders: settingsToSave.shoutrrrStockReminders,
shoutrrrIntakeReminders: settingsToSave.shoutrrrIntakeReminders,
shoutrrrPrescriptionReminders: settingsToSave.shoutrrrPrescriptionReminders,
stockCalculationMode: settingsToSave.stockCalculationMode,
shareStockStatus: settingsToSave.shareStockStatus,
upcomingTodayOnly: settingsToSave.upcomingTodayOnly,
shareScheduleTodayOnly: settingsToSave.shareScheduleTodayOnly,
swapDashboardMainSections: settingsToSave.swapDashboardMainSections,
language: i18n.language,
smtpHost: settingsToSave.smtpHost,
smtpPort: settingsToSave.smtpPort,
smtpUser: settingsToSave.smtpUser,
smtpPass: settingsToSave.smtpPass || undefined,
smtpFrom: settingsToSave.smtpFrom,
smtpSecure: settingsToSave.smtpSecure,
};
if (syncState) {
setSettingsSaving(true);
}
try {
const response = await fetchWithRefresh("/api/settings", {
method: "PUT",
headers: { "Content-Type": "application/json" },
keepalive: true,
body: JSON.stringify(payload),
});
@@ -384,19 +426,27 @@ export function useSettings(): UseSettingsReturn {
throw new Error(`SETTINGS_SAVE_FAILED_${response.status}`);
}
const updatedSettings = { ...settingsToSave };
setSettings(updatedSettings);
setSavedSettings(updatedSettings);
setSettingsSaved(true);
if (syncState) {
const updatedSettings = { ...settingsToSave };
setSettings(updatedSettings);
setSavedSettings(updatedSettings);
setSettingsSaved(true);
} else {
latestSavedSettingsRef.current = { ...settingsToSave };
}
} catch {
setSettingsSaved(false);
// Keep UI aligned with backend truth if save failed (auth/session/network/server error).
loadSettings();
if (syncState) {
setSettingsSaved(false);
// Keep UI aligned with backend truth if save failed (auth/session/network/server error).
loadSettings();
}
} finally {
setSettingsSaving(false);
if (syncState) {
setSettingsSaving(false);
}
}
},
[fetchWithRefresh, i18n.language, loadSettings]
[buildSettingsPayload, fetchWithRefresh, loadSettings]
);
// Debounced auto-save: fires whenever settings change
@@ -424,8 +474,8 @@ export function useSettings(): UseSettingsReturn {
}
debounceRef.current = setTimeout(() => {
performSave(settings);
}, 600);
void performSave(settings);
}, 50);
return () => {
if (debounceRef.current) {
@@ -434,6 +484,32 @@ export function useSettings(): UseSettingsReturn {
};
}, [settings, savedSettings, performSave]);
useEffect(() => {
const flushPendingSettings = () => {
if (JSON.stringify(latestSettingsRef.current) === JSON.stringify(latestSavedSettingsRef.current)) {
return;
}
flushSettingsWithKeepalive(latestSettingsRef.current);
};
window.addEventListener("pagehide", flushPendingSettings);
return () => {
window.removeEventListener("pagehide", flushPendingSettings);
if (debounceRef.current) {
clearTimeout(debounceRef.current);
}
if (JSON.stringify(latestSettingsRef.current) === JSON.stringify(latestSavedSettingsRef.current)) {
return;
}
flushSettingsWithKeepalive(latestSettingsRef.current);
};
}, [flushSettingsWithKeepalive]);
// Mark initial load as done after first settings load completes
useEffect(() => {
if (!settingsLoading && !initialLoadDone.current) {
+16 -4
View File
@@ -7,6 +7,8 @@ import type { Medication } from "../types";
import { withCorrelation } from "../utils/correlation";
import { log } from "../utils/logger";
const SHARE_ALL_VALUE = "all";
export interface UseShareReturn {
showShareDialog: boolean;
sharePeople: string[];
@@ -43,10 +45,20 @@ export function useShare(): UseShareReturn {
setShareSelectedPerson("");
setShareSelectedDays(30);
// Get unique takenBy people from all medications (flatten arrays)
const allPeople = meds.flatMap((m) => m.takenBy || []);
const uniquePeople = [...new Set(allPeople)].filter(Boolean).sort();
setSharePeople(uniquePeople);
// Include both per-intake assignments and legacy medication-level assignments.
const uniquePeople = [
...new Set(
meds.flatMap((medication) => [
...(medication.intakes
?.map((intake) => intake.takenBy)
.filter((person): person is string => Boolean(person)) ?? []),
...(medication.takenBy || []),
])
),
]
.filter(Boolean)
.sort();
setSharePeople(uniquePeople.length > 0 ? [SHARE_ALL_VALUE, ...uniquePeople] : []);
log.info("[ShareDialog] Opened", { medicationCount: meds.length, personCount: uniquePeople.length });
if (uniquePeople.length > 0) {
setShareSelectedPerson(uniquePeople[0]);