refactor(frontend): modularize App.tsx into components, pages, hooks, and context (#60)

- Extract App.tsx from 764 lines to ~404 lines
- Create reusable components: MedDetailModal, MobileEditModal, ShareDialog, etc.
- Add AppContext for global state management
- Split pages: DashboardPage, MedicationsPage, SchedulePage, SettingsPage, PlannerPage
- Create custom hooks: useAuth, useMedications, useSettings, useDoses, useSchedule
- Add utility functions in separate modules
- Fix stock status logic (>30 days = green/normal)
- Fix reminder threshold calculation (use reminderDaysBefore not lowStockDays)
- Fix takenBy validation (send [] instead of null)
- Fix datetime format for blister start times (add Z suffix)
- Style 'All OK' status as green/bold

BREAKING: None - all existing functionality preserved
This commit is contained in:
Daniel Volz
2026-01-22 05:38:34 +01:00
committed by GitHub
parent 89edd74de3
commit 8718311876
44 changed files with 7448 additions and 5139 deletions
+255
View File
@@ -0,0 +1,255 @@
// =============================================================================
// useSettings Hook - Settings state and operations
// =============================================================================
import { useCallback, useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
export interface Settings {
emailEnabled: boolean;
notificationEmail: string;
reminderDaysBefore: number;
repeatDailyReminders: boolean;
skipRemindersForTakenDoses: boolean;
repeatRemindersEnabled: boolean;
reminderRepeatIntervalMinutes: number;
maxNaggingReminders: number;
lowStockDays: number;
normalStockDays: number;
highStockDays: number;
smtpHost: string;
smtpPort: number;
smtpUser: string;
smtpPass: string;
smtpFrom: string;
smtpSecure: boolean;
hasSmtpPassword: boolean;
lastAutoEmailSent: string | null;
nextScheduledCheck: string | null;
lastNotificationType: "stock" | "intake" | null;
lastNotificationChannel: "email" | "push" | "both" | null;
shoutrrrEnabled: boolean;
shoutrrrUrl: string;
emailStockReminders: boolean;
emailIntakeReminders: boolean;
shoutrrrStockReminders: boolean;
shoutrrrIntakeReminders: boolean;
stockCalculationMode: "automatic" | "manual";
expiryWarningDays: number;
}
const defaultSettings: Settings = {
emailEnabled: false,
notificationEmail: "",
reminderDaysBefore: 7,
repeatDailyReminders: false,
skipRemindersForTakenDoses: false,
repeatRemindersEnabled: false,
reminderRepeatIntervalMinutes: 30,
maxNaggingReminders: 5,
lowStockDays: 30,
normalStockDays: 90,
highStockDays: 180,
smtpHost: "",
smtpPort: 587,
smtpUser: "",
smtpPass: "",
smtpFrom: "",
smtpSecure: false,
hasSmtpPassword: false,
lastAutoEmailSent: null,
nextScheduledCheck: null,
lastNotificationType: null,
lastNotificationChannel: null,
shoutrrrEnabled: false,
shoutrrrUrl: "",
emailStockReminders: true,
emailIntakeReminders: true,
shoutrrrStockReminders: true,
shoutrrrIntakeReminders: true,
stockCalculationMode: "automatic",
expiryWarningDays: 30
};
export interface UseSettingsReturn {
settings: Settings;
setSettings: React.Dispatch<React.SetStateAction<Settings>>;
savedSettings: Settings;
settingsLoading: boolean;
settingsSaving: boolean;
settingsSaved: boolean;
testingEmail: boolean;
testEmailResult: { success: boolean; message: string } | null;
setTestEmailResult: React.Dispatch<React.SetStateAction<{ success: boolean; message: string } | null>>;
testingShoutrrr: boolean;
testShoutrrrResult: { success: boolean; message: string } | null;
setTestShoutrrrResult: React.Dispatch<React.SetStateAction<{ success: boolean; message: string } | null>>;
loadSettings: () => void;
saveSettings: (e: React.FormEvent) => Promise<void>;
testEmail: () => Promise<void>;
testShoutrrr: () => Promise<void>;
hasUnsavedChanges: boolean;
}
export function useSettings(): UseSettingsReturn {
const { i18n } = useTranslation();
const [settings, setSettings] = useState<Settings>(defaultSettings);
const [savedSettings, setSavedSettings] = useState<Settings>(defaultSettings);
const [settingsLoading, setSettingsLoading] = useState(false);
const [settingsSaving, setSettingsSaving] = useState(false);
const [settingsSaved, setSettingsSaved] = useState(false);
const [testingEmail, setTestingEmail] = useState(false);
const [testEmailResult, setTestEmailResult] = useState<{ success: boolean; message: string } | null>(null);
const [testingShoutrrr, setTestingShoutrrr] = useState(false);
const [testShoutrrrResult, setTestShoutrrrResult] = useState<{ success: boolean; message: string } | null>(null);
// 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()))
.then((data) => {
const newSettings = { ...defaultSettings, ...data, smtpPass: "" };
setSettings(newSettings);
setSavedSettings(newSettings);
setSettingsSaved(false);
})
.catch(() => {})
.finally(() => setSettingsLoading(false));
}, []);
// Load settings on mount
useEffect(() => {
loadSettings();
}, [loadSettings]);
const saveSettings = useCallback(
async (e: React.FormEvent) => {
e.preventDefault();
// Auto-disable email if no recipient is set
const effectiveEmailEnabled = settings.emailEnabled && !!settings.notificationEmail?.trim();
// Auto-disable push if no URL is set
const effectiveShoutrrrEnabled = settings.shoutrrrEnabled && !!settings.shoutrrrUrl?.trim();
// Validate email if email notifications are enabled
if (effectiveEmailEnabled && settings.notificationEmail) {
const emailRegex = /^[a-z0-9._%+\-]+@[a-z0-9.\-]+\.[a-z]{2,}$/i;
if (!emailRegex.test(settings.notificationEmail)) {
setTestEmailResult({ success: false, message: "Invalid email address" });
return;
}
}
setSettingsSaving(true);
setTestEmailResult(null);
const payload = {
emailEnabled: effectiveEmailEnabled,
notificationEmail: settings.notificationEmail,
reminderDaysBefore: settings.reminderDaysBefore,
repeatDailyReminders: settings.repeatDailyReminders,
skipRemindersForTakenDoses: settings.skipRemindersForTakenDoses,
repeatRemindersEnabled: settings.repeatRemindersEnabled,
reminderRepeatIntervalMinutes: settings.reminderRepeatIntervalMinutes,
maxNaggingReminders: settings.maxNaggingReminders ?? 5,
lowStockDays: settings.lowStockDays,
normalStockDays: settings.normalStockDays,
highStockDays: settings.highStockDays,
shoutrrrEnabled: effectiveShoutrrrEnabled,
shoutrrrUrl: settings.shoutrrrUrl,
emailStockReminders: settings.emailStockReminders,
emailIntakeReminders: settings.emailIntakeReminders,
shoutrrrStockReminders: settings.shoutrrrStockReminders,
shoutrrrIntakeReminders: settings.shoutrrrIntakeReminders,
stockCalculationMode: settings.stockCalculationMode,
language: i18n.language,
smtpHost: settings.smtpHost,
smtpPort: settings.smtpPort,
smtpUser: settings.smtpUser,
smtpPass: settings.smtpPass || undefined,
smtpFrom: settings.smtpFrom,
smtpSecure: settings.smtpSecure
};
await fetch("/api/settings", {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload)
}).catch(() => null);
const updatedSettings = {
...settings,
emailEnabled: effectiveEmailEnabled,
shoutrrrEnabled: effectiveShoutrrrEnabled
};
setSettings(updatedSettings);
setSettingsSaving(false);
setSavedSettings(updatedSettings);
setSettingsSaved(true);
},
[settings, i18n.language]
);
const testEmail = useCallback(async () => {
setTestingEmail(true);
setTestEmailResult(null);
try {
const res = await fetch("/api/settings/test-email", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ email: settings.notificationEmail })
});
const data = await res.json();
setTestEmailResult({ success: res.ok, message: data.message || (res.ok ? "Email sent!" : "Failed to send email") });
} catch {
setTestEmailResult({ success: false, message: "Failed to send test email" });
} finally {
setTestingEmail(false);
}
}, [settings.notificationEmail]);
const testShoutrrr = useCallback(async () => {
setTestingShoutrrr(true);
setTestShoutrrrResult(null);
try {
const res = await fetch("/api/settings/test-shoutrrr", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ url: settings.shoutrrrUrl })
});
const data = await res.json();
setTestShoutrrrResult({
success: res.ok,
message: data.message || (res.ok ? "Notification sent!" : "Failed to send notification")
});
} catch {
setTestShoutrrrResult({ success: false, message: "Failed to send test notification" });
} finally {
setTestingShoutrrr(false);
}
}, [settings.shoutrrrUrl]);
// Check for unsaved changes
const hasUnsavedChanges = JSON.stringify(settings) !== JSON.stringify(savedSettings);
return {
settings,
setSettings,
savedSettings,
settingsLoading,
settingsSaving,
settingsSaved,
testingEmail,
testEmailResult,
setTestEmailResult,
testingShoutrrr,
testShoutrrrResult,
setTestShoutrrrResult,
loadSettings,
saveSettings,
testEmail,
testShoutrrr,
hasUnsavedChanges
};
}