diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md
index ef391b0..45dbaae 100644
--- a/.github/copilot-instructions.md
+++ b/.github/copilot-instructions.md
@@ -222,6 +222,8 @@ The `main` branch is protected - releases must go through the automated release
> ⚠️ **MANDATORY**: GitHub Releases MUST contain a written message!
> Not just auto-generated commit lists, but a brief descriptive text.
+**Release title:** Use just `vX.Y.Z` (e.g., `v1.4.1`), NOT "Release vX.Y.Z".
+
**Keep it informative but concise.** Users want to know what changed and where to find it.
**Required structure of release notes:**
@@ -243,6 +245,12 @@ The `main` branch is protected - releases must go through the automated release
- ❌ Number of tests added
- ❌ Internal API changes (unless breaking)
- ❌ Excessive emoji on every bullet point
+- ❌ .gitignore changes or other developer-only file changes
+- ❌ AI/Copilot instruction updates
+- ❌ CI/CD workflow changes (unless affecting users)
+- ❌ Code refactoring without user-visible changes
+
+**Only include user-relevant changes** - things that affect what users see or experience in the app.
**Example of good release notes:**
diff --git a/.gitignore b/.gitignore
index 50dc15b..6e51a9d 100644
--- a/.gitignore
+++ b/.gitignore
@@ -71,3 +71,4 @@ Thumbs.db
*.local
.cache/
.turbo/
+docs/TECH_STACK.md
\ No newline at end of file
diff --git a/backend/package-lock.json b/backend/package-lock.json
index 4355a3c..495bd31 100644
--- a/backend/package-lock.json
+++ b/backend/package-lock.json
@@ -1,12 +1,12 @@
{
"name": "medassist-ng-backend",
- "version": "1.1.0",
+ "version": "1.4.1",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "medassist-ng-backend",
- "version": "1.1.0",
+ "version": "1.4.1",
"dependencies": {
"@fastify/cookie": "^10.0.1",
"@fastify/cors": "^10.0.1",
@@ -2079,7 +2079,6 @@
"resolved": "https://registry.npmjs.org/@libsql/client/-/client-0.10.0.tgz",
"integrity": "sha512-2ERn08T4XOVx34yBtUPq0RDjAdd9TJ5qNH/izugr208ml2F94mk92qC64kXyDVQINodWJvp3kAdq6P4zTtCZ7g==",
"license": "MIT",
- "peer": true,
"dependencies": {
"@libsql/core": "^0.10.0",
"@libsql/hrana-client": "^0.6.2",
@@ -4579,7 +4578,6 @@
"dev": true,
"hasInstallScript": true,
"license": "MIT",
- "peer": true,
"bin": {
"esbuild": "bin/esbuild"
},
@@ -5776,7 +5774,6 @@
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
"dev": true,
"license": "MIT",
- "peer": true,
"engines": {
"node": ">=12"
},
@@ -6538,7 +6535,6 @@
"integrity": "sha512-5C1sg4USs1lfG0GFb2RLXsdpXqBSEhAaA/0kPL01wxzpMqLILNxIxIOKiILz+cdg/pLnOUxFYOR5yhHU666wbw==",
"dev": true,
"license": "MIT",
- "peer": true,
"dependencies": {
"esbuild": "~0.27.0",
"get-tsconfig": "^4.7.5"
@@ -6602,7 +6598,6 @@
"integrity": "sha512-dZwN5L1VlUBewiP6H9s2+B3e3Jg96D0vzN+Ry73sOefebhYr9f94wwkMNN/9ouoU8pV1BqA1d1zGk8928cx0rg==",
"dev": true,
"license": "MIT",
- "peer": true,
"dependencies": {
"esbuild": "^0.27.0",
"fdir": "^6.5.0",
@@ -6678,7 +6673,6 @@
"integrity": "sha512-E4t7DJ9pESL6E3I8nFjPa4xGUd3PmiWDLsDztS2qXSJWfHtbQnwAWylaBvSNY48I3vr8PTqIZlyK8TE3V3CA4Q==",
"dev": true,
"license": "MIT",
- "peer": true,
"dependencies": {
"@vitest/expect": "4.0.16",
"@vitest/mocker": "4.0.16",
diff --git a/frontend/package-lock.json b/frontend/package-lock.json
index 59bf3ff..851f2c7 100644
--- a/frontend/package-lock.json
+++ b/frontend/package-lock.json
@@ -1,12 +1,12 @@
{
"name": "medassist-ng-frontend",
- "version": "1.1.0",
+ "version": "1.4.1",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "medassist-ng-frontend",
- "version": "1.1.0",
+ "version": "1.4.1",
"dependencies": {
"i18next": "^24.2.2",
"i18next-browser-languagedetector": "^8.0.4",
diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx
index 9b8cd82..e3a18de 100644
--- a/frontend/src/App.tsx
+++ b/frontend/src/App.tsx
@@ -1,142 +1,16 @@
-import { useEffect, useMemo, useState } from "react";
-import { Routes, Route, useNavigate, useLocation, Navigate, useParams } from "react-router-dom";
-import { useTranslation } from "react-i18next";
-import { AuthProvider, useAuth, AuthPage, UserProfile } from "./components/Auth";
+import { useEffect, useState } from "react";
+import { Routes, Route, Navigate } from "react-router-dom";
+import { AuthProvider, useAuth, AuthPage } from "./components/Auth";
+import { AppHeader } from "./components/AppHeader";
+import { SharedSchedule, Lightbox, MedDetailModal, UserFilterModal, ShareDialog, ProfileModal, AboutModal } from "./components";
+import { AppProvider, useAppContext } from "./context";
+import { PlannerPage, SchedulePage, SettingsPage, DashboardPage, MedicationsPage } from "./pages";
// Vite injects this at build time from package.json
declare const __APP_VERSION__: string;
-const FRONTEND_VERSION = typeof __APP_VERSION__ !== 'undefined' ? __APP_VERSION__ : 'unknown';
+export const FRONTEND_VERSION = typeof __APP_VERSION__ !== 'undefined' ? __APP_VERSION__ : 'unknown';
const GITHUB_REPO = 'DanielVolz/medassist-ng';
-const GITHUB_URL = `https://github.com/${GITHUB_REPO}`;
-
-// Simple semver comparison: returns -1 if a < b, 0 if equal, 1 if a > b
-function compareSemver(a: string, b: string): number {
- const parseVersion = (v: string) => v.replace(/^v/, '').split('.').map(n => parseInt(n, 10) || 0);
- const pa = parseVersion(a);
- const pb = parseVersion(b);
- for (let i = 0; i < 3; i++) {
- const va = pa[i] || 0;
- const vb = pb[i] || 0;
- if (va < vb) return -1;
- if (va > vb) return 1;
- }
- return 0;
-}
-
-type Blister = {
- usage: number;
- every: number;
- start: string;
-};
-
-type Medication = {
- id: number;
- name: string;
- genericName?: string | null;
- takenBy: string[];
- packCount: number;
- blistersPerPack: number;
- pillsPerBlister: number;
- looseTablets: number;
- stockAdjustment?: number;
- lastStockCorrectionAt?: string | null; // When stock was last corrected - consumed doses before this don't count
- pillWeightMg?: number | null;
- blisters: Blister[];
- imageUrl?: string | null;
- expiryDate?: string | null;
- notes?: string | null;
- intakeRemindersEnabled?: boolean;
- updatedAt: string | number | null;
-};
-
-// Helper to calculate total pills including stockAdjustment
-function getMedTotal(med: Medication): number {
- return med.packCount * med.blistersPerPack * med.pillsPerBlister + med.looseTablets + (med.stockAdjustment ?? 0);
-}
-
-// Helper to get the base package size (without stockAdjustment)
-function getPackageSize(med: Medication): number {
- return med.packCount * med.blistersPerPack * med.pillsPerBlister + med.looseTablets;
-}
-
-type PlannerRow = {
- medicationId: number;
- medicationName: string;
- totalPills: number;
- plannerUsage: number;
- blisterSize: number;
- blistersNeeded: number;
- fullBlisters: number;
- loosePills: number;
- enough: boolean;
-};
-
-type RefillEntry = {
- id: number;
- packsAdded: number;
- loosePillsAdded: number;
- refillDate: string;
-};
-
-type FormBlister = { usage: string; every: string; startDate: string; startTime: string };
-
-type FormState = {
- name: string;
- genericName: string;
- takenBy: string[]; // Changed from string to array
- packCount: string;
- blistersPerPack: string;
- pillsPerBlister: string;
- looseTablets: string;
- pillWeightMg: string;
- expiryDate: string;
- notes: string;
- intakeRemindersEnabled: boolean;
- blisters: FormBlister[];
-};
-
-const defaultBlister = (): FormBlister => {
- const now = new Date();
- return {
- usage: "1",
- every: "1",
- startDate: toDateValue(now),
- startTime: toTimeValue(now)
- };
-};
-
-const defaultForm = (): FormState => ({ name: "", genericName: "", takenBy: [], packCount: "1", blistersPerPack: "1", pillsPerBlister: "1", looseTablets: "0", pillWeightMg: "", expiryDate: "", notes: "", intakeRemindersEnabled: false, blisters: [defaultBlister()] });
-
-// Field validation limits (must match backend)
-const FIELD_LIMITS = {
- name: { min: 1, max: 100 },
- genericName: { max: 100 },
- takenBy: { max: 100 },
- notes: { max: 2000 }
-} as const;
-
-type FieldErrors = {
- name?: string;
- genericName?: string;
- takenBy?: string;
- notes?: string;
-};
-
-const todayIso = () => new Date().toISOString();
-const plusDaysIso = (days: number) => {
- const d = new Date();
- d.setDate(d.getDate() + days);
- return d.toISOString();
-};
-
-type Coverage = {
- name: string;
- medsLeft: number;
- daysLeft: number | null;
- depletionDate: string | null;
- depletionTime: number | null;
- nextDose: string | null;
-};
+export const GITHUB_URL = `https://github.com/${GITHUB_REPO}`;
// =============================================================================
// Main App Wrapper with Auth
@@ -156,8 +30,6 @@ export default function App() {
function AppRouter() {
const { user, authState, loading, authError } = useAuth();
- const location = useLocation();
- const navigate = useNavigate();
// Show loading while checking auth state
if (loading) {
@@ -221,367 +93,58 @@ function AppRouter() {
}
// Auth disabled or user is logged in - show main app
- return ;
+ return (
+
+
+
+ );
}
// =============================================================================
// Main App Content
// =============================================================================
-// Helper for user-specific localStorage keys
-function userStorageKey(userId: number | undefined, key: string): string {
- return userId ? `user_${userId}_${key}` : key;
-}
-
function AppContent() {
- const { t, i18n } = useTranslation();
- const { user, authState, logout } = useAuth();
+ // Get shared state from AppContext
+ const ctx = useAppContext();
+ const {
+ // Medications
+ meds, loadMeds,
+ // Settings
+ settings,
+ // Refill
+ showRefillModal, setShowRefillModal, refillPacks, setRefillPacks, refillLoose, setRefillLoose,
+ refillSaving, refillHistory, refillHistoryExpanded, setRefillHistoryExpanded,
+ showEditStockModal, setShowEditStockModal, editStockFullBlisters, setEditStockFullBlisters,
+ editStockPartialBlisterPills, setEditStockPartialBlisterPills, editStockSaving,
+ openRefillModal, closeRefillModal, openEditStockModal, closeEditStockModal,
+ // Share
+ showShareDialog, sharePeople, shareSelectedPerson, setShareSelectedPerson,
+ shareSelectedDays, setShareSelectedDays, shareGenerating, shareLink, setShareLink,
+ shareCopied, setShareCopied, generateShareLink, copyShareLink, closeShareDialog, resetShareDialogState,
+ // Computed
+ coverage,
+ // Modal state
+ selectedMed, setSelectedMed, showImageLightbox, setShowImageLightbox,
+ scheduleLightboxImage, setScheduleLightboxImage, selectedUser, setSelectedUser,
+ // Modal helpers
+ openMedDetail, closeMedDetail, openImageLightbox, closeImageLightbox,
+ openScheduleLightbox, closeScheduleLightbox, closeUserFilter,
+ } = ctx;
+
+ // Wrapper to pass meds to openShareDialog
+ const openShareDialog = () => ctx.openShareDialog();
+
+ // Local-only state (not shared across components)
const [showProfile, setShowProfile] = useState(false);
const [showAbout, setShowAbout] = useState(false);
- const [backendVersion, setBackendVersion] = useState(null);
- const [updateCheckResult, setUpdateCheckResult] = useState<{
- status: 'idle' | 'checking' | 'up-to-date' | 'update-available' | 'error';
- latestVersion?: string;
- lastChecked?: string;
- }>({ status: 'idle' });
- const [meds, setMeds] = useState([]);
- const [plannerRows, setPlannerRows] = useState([]);
- const [plannerLoading, setPlannerLoading] = useState(false);
- const [loading, setLoading] = useState(false);
- const [saving, setSaving] = useState(false);
- const [formSaved, setFormSaved] = useState(false);
- const [originalForm, setOriginalForm] = useState(defaultForm());
- const [editingId, setEditingId] = useState(null);
- const [showEditModal, setShowEditModal] = useState(false);
- const [form, setForm] = useState(defaultForm());
- const [fieldErrors, setFieldErrors] = useState({});
- const [range, setRange] = useState<{ start: string; end: string }>({
- start: toInputValue(todayIso()),
- end: toInputValue(plusDaysIso(3))
- });
-
- // Validate form fields
- const validateField = (field: keyof FieldErrors, value: string | string[]): string | undefined => {
- const limits = FIELD_LIMITS[field];
- // Skip validation for takenBy array (individual items validated on add)
- if (field === 'takenBy') return undefined;
- const strValue = typeof value === 'string' ? value : '';
- if (field === 'name' && (!strValue || strValue.trim().length === 0)) {
- return t('common.validation.required');
- }
- if ('max' in limits && strValue.length > limits.max) {
- return t('common.validation.maxLength', { max: limits.max, current: strValue.length });
- }
- return undefined;
- };
-
- // Check if form has any errors
- const hasValidationErrors = useMemo(() => {
- return Object.values(fieldErrors).some(error => error !== undefined);
- }, [fieldErrors]);
-
- // Check if form has been modified from original state
- const formChanged = useMemo(() => {
- return JSON.stringify(form) !== JSON.stringify(originalForm);
- }, [form, originalForm]);
-
- // Reset formSaved when form changes
- useEffect(() => {
- if (formChanged) {
- setFormSaved(false);
- }
- }, [formChanged]);
-
- // Validate all fields when form changes
- useEffect(() => {
- const errors: FieldErrors = {};
- (['name', 'genericName', 'notes'] as const).forEach(field => {
- const error = validateField(field, form[field]);
- if (error) errors[field] = error;
- });
- setFieldErrors(errors);
- }, [form.name, form.genericName, form.notes, t]);
-
- // Load user-specific planner data when user changes
- useEffect(() => {
- if (typeof window !== "undefined" && user?.id) {
- const savedRows = localStorage.getItem(userStorageKey(user.id, "plannerRows"));
- const savedRange = localStorage.getItem(userStorageKey(user.id, "plannerRange"));
-
- if (savedRows) {
- try { setPlannerRows(JSON.parse(savedRows)); } catch { setPlannerRows([]); }
- } else {
- setPlannerRows([]);
- }
-
- if (savedRange) {
- try { setRange(JSON.parse(savedRange)); } catch { /* keep default */ }
- } else {
- setRange({ start: toInputValue(todayIso()), end: toInputValue(plusDaysIso(3)) });
- }
- } else {
- setPlannerRows([]);
- setRange({ start: toInputValue(todayIso()), end: toInputValue(plusDaysIso(3)) });
- }
- }, [user?.id]);
-
- const navigate = useNavigate();
- const location = useLocation();
- const currentPath = location.pathname;
-
- // Settings state
- const [settings, setSettings] = useState({
- 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 as string | null,
- nextScheduledCheck: null as string | null,
- lastNotificationType: null as "stock" | "intake" | null,
- lastNotificationChannel: null as "email" | "push" | "both" | null,
- // Shoutrrr/ntfy settings
- shoutrrrEnabled: false,
- shoutrrrUrl: "",
- // Granular notification settings
- emailStockReminders: true,
- emailIntakeReminders: true,
- shoutrrrStockReminders: true,
- shoutrrrIntakeReminders: true,
- // Stock calculation mode: "automatic" or "manual"
- stockCalculationMode: "automatic" as "automatic" | "manual",
- // Admin settings (from .env, read-only)
- expiryWarningDays: 30,
- });
- const [savedSettings, setSavedSettings] = useState(settings);
- 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);
- const [sendingPlannerEmail, setSendingPlannerEmail] = useState(false);
- const [plannerEmailResult, setPlannerEmailResult] = useState<{ success: boolean; message: string } | null>(null);
- const [sendingReminderEmail, setSendingReminderEmail] = useState(false);
- const [reminderEmailResult, setReminderEmailResult] = useState<{ success: boolean; message: string } | null>(null);
- const [uploadingImage, setUploadingImage] = useState(false);
- const [pendingImage, setPendingImage] = useState(null);
- const [pendingImagePreview, setPendingImagePreview] = useState(null);
- const [selectedMed, setSelectedMed] = useState(null);
- const [showImageLightbox, setShowImageLightbox] = useState(false);
- const [scheduleLightboxImage, setScheduleLightboxImage] = useState(null);
- const [selectedUser, setSelectedUser] = useState(null);
- const [scheduleDays, setScheduleDays] = useState(30);
- const [showPastDays, setShowPastDays] = useState(false);
- const [takenDoses, setTakenDoses] = useState>(new Set());
- const [dismissedDoses, setDismissedDoses] = useState>(new Set());
- // Clear missed doses confirmation dialog
- const [showClearMissedConfirm, setShowClearMissedConfirm] = useState(false);
- const [clearingMissed, setClearingMissed] = useState(false);
- // Tag input state for "Taken By" field
- const [takenByInput, setTakenByInput] = useState("");
- // Share dialog state
- const [showShareDialog, setShowShareDialog] = useState(false);
- const [sharePeople, setSharePeople] = useState([]);
- const [shareSelectedPerson, setShareSelectedPerson] = useState("");
- const [shareSelectedDays, setShareSelectedDays] = useState(30);
- const [shareGenerating, setShareGenerating] = useState(false);
- const [shareLink, setShareLink] = useState(null);
- const [shareCopied, setShareCopied] = useState(false);
- // Export/Import state
- const [exporting, setExporting] = useState(false);
- const [importing, setImporting] = useState(false);
- const [exportIncludeImages, setExportIncludeImages] = useState(true);
- const [showExportModal, setShowExportModal] = useState(false);
- const [importResult, setImportResult] = useState<{medications: number, doses: number, shares: number} | null>(null);
- // User dropdown state (for mobile click-based behavior)
- const [userDropdownOpen, setUserDropdownOpen] = useState(false);
-
- const [showImportConfirm, setShowImportConfirm] = useState(false);
- const [pendingImportData, setPendingImportData] = useState(null);
- // Refill state
- const [showRefillModal, setShowRefillModal] = useState(false);
- const [refillPacks, setRefillPacks] = useState(1);
- const [refillLoose, setRefillLoose] = useState(0);
- const [refillSaving, setRefillSaving] = useState(false);
- const [refillHistory, setRefillHistory] = useState([]);
- const [refillHistoryExpanded, setRefillHistoryExpanded] = useState(false);
- // Edit stock (correction) state
- const [showEditStockModal, setShowEditStockModal] = useState(false);
- const [editStockFullBlisters, setEditStockFullBlisters] = useState(0);
- const [editStockPartialBlisterPills, setEditStockPartialBlisterPills] = useState(0);
- const [editStockSaving, setEditStockSaving] = useState(false);
- // Collapsed days state (manually collapsed days are persisted)
- const [manuallyCollapsedDays, setManuallyCollapsedDays] = useState>(new Set());
- const [manuallyExpandedDays, setManuallyExpandedDays] = useState>(new Set());
-
- // Load user-specific scheduleDays when user changes
- useEffect(() => {
- if (typeof window !== "undefined" && user?.id) {
- const storedDays = localStorage.getItem(userStorageKey(user.id, "scheduleDays"));
- setScheduleDays(storedDays ? Number(storedDays) : 30);
-
- // Load manually collapsed/expanded days from localStorage
- const { collapsed, expanded } = loadCollapsedDaysFromStorage(
- userStorageKey(user.id, "collapsedDays"),
- userStorageKey(user.id, "expandedDays")
- );
- setManuallyCollapsedDays(collapsed);
- setManuallyExpandedDays(expanded);
- }
- }, [user?.id]);
-
- // Poll for taken doses from server (works with or without auth)
- useEffect(() => {
- async function loadTakenDoses() {
- try {
- const res = await fetch("/api/doses/taken", { credentials: "include" });
- if (res.ok) {
- const data = await res.json();
- const taken = new Set();
- const dismissed = new Set();
- for (const d of data.doses) {
- if (d.dismissed) {
- dismissed.add(d.doseId);
- } else {
- taken.add(d.doseId);
- }
- }
- setTakenDoses(taken);
- setDismissedDoses(dismissed);
- }
- // Don't reset on error - keep current state
- } catch {
- // Don't reset on error - keep current state
- }
- }
- loadTakenDoses();
-
- // Poll for updates every 5 seconds (real-time sync with share links)
- const interval = setInterval(loadTakenDoses, 5000);
- return () => clearInterval(interval);
- }, []);
-
- // Get dose ID with optional person suffix
- function getDoseId(baseDoseId: string, person: string | null): string {
- return person ? `${baseDoseId}-${person}` : baseDoseId;
- }
-
- // Count taken doses for a day/item
- function countTakenDoses(doses: Array<{ id: string; takenBy: string[] }>): { total: number; taken: number } {
- let total = 0;
- let taken = 0;
- for (const d of doses) {
- const people = (d.takenBy || []).length > 0 ? d.takenBy : [null];
- for (const person of people) {
- total++;
- if (takenDoses.has(getDoseId(d.id, person))) taken++;
- }
- }
- return { total, taken };
- }
-
- async function markDoseTaken(doseId: string) {
- // Optimistic update
- setTakenDoses((prev) => {
- const next = new Set(prev);
- next.add(doseId);
- return next;
- });
-
- // Send to server
- try {
- await fetch("/api/doses/taken", {
- method: "POST",
- headers: { "Content-Type": "application/json" },
- credentials: "include",
- body: JSON.stringify({ doseId }),
- });
- } catch {
- // Revert on error
- setTakenDoses((prev) => {
- const next = new Set(prev);
- next.delete(doseId);
- return next;
- });
- }
- }
-
- async function undoDoseTaken(doseId: string) {
- // Optimistic update
- setTakenDoses((prev) => {
- const next = new Set(prev);
- next.delete(doseId);
- return next;
- });
-
- // Send to server
- try {
- await fetch(`/api/doses/taken/${encodeURIComponent(doseId)}`, {
- method: "DELETE",
- credentials: "include",
- });
- } catch {
- // Revert on error
- setTakenDoses((prev) => {
- const next = new Set(prev);
- next.add(doseId);
- return next;
- });
- }
- }
-
- // Dismiss missed doses without deducting from stock
- async function dismissMissedDoses(doseIds: string[]) {
- if (doseIds.length === 0) return;
-
- setClearingMissed(true);
- try {
- const res = await fetch("/api/doses/dismiss", {
- method: "POST",
- headers: { "Content-Type": "application/json" },
- credentials: "include",
- body: JSON.stringify({ doseIds }),
- });
-
- if (res.ok) {
- // Update local state - move these from neither set to dismissed set
- setDismissedDoses((prev) => {
- const next = new Set(prev);
- for (const id of doseIds) next.add(id);
- return next;
- });
- setShowClearMissedConfirm(false);
- }
- } catch {
- // Error - dialog stays open
- } finally {
- setClearingMissed(false);
- }
- }
// Close modal on Escape key
useEffect(() => {
const handleEscape = (e: KeyboardEvent) => {
if (e.key === "Escape") {
// Close modals in order of priority (topmost first)
- if (userDropdownOpen) {
- setUserDropdownOpen(false);
- } else if (scheduleLightboxImage) {
+ if (scheduleLightboxImage) {
closeScheduleLightbox();
} else if (showImageLightbox) {
closeImageLightbox();
@@ -589,9 +152,6 @@ function AppContent() {
closeEditStockModal();
} else if (showRefillModal) {
closeRefillModal();
- } else if (showEditModal) {
- closeEditModal();
- resetForm();
} else if (showShareDialog) {
closeShareDialog();
} else if (showAbout) {
@@ -607,7 +167,7 @@ function AppContent() {
};
document.addEventListener("keydown", handleEscape);
return () => document.removeEventListener("keydown", handleEscape);
- }, [selectedMed, showImageLightbox, scheduleLightboxImage, selectedUser, showProfile, showAbout, showShareDialog, showEditModal, showRefillModal, showEditStockModal, userDropdownOpen]);
+ }, [selectedMed, showImageLightbox, scheduleLightboxImage, selectedUser, showProfile, showAbout, showShareDialog, showRefillModal, showEditStockModal]);
// Handle browser back button to close modals (in priority order)
useEffect(() => {
@@ -623,9 +183,6 @@ function AppContent() {
setShowEditStockModal(false);
} else if (showRefillModal) {
setShowRefillModal(false);
- } else if (showEditModal) {
- setShowEditModal(false);
- resetForm();
} else if (showShareDialog) {
resetShareDialogState();
} else if (showAbout) {
@@ -640,20 +197,7 @@ function AppContent() {
};
window.addEventListener('popstate', handlePopState);
return () => window.removeEventListener('popstate', handlePopState);
- }, [selectedMed, showImageLightbox, scheduleLightboxImage, selectedUser, showProfile, showAbout, showShareDialog, showEditModal, showRefillModal, showEditStockModal]);
-
- // Close user dropdown when clicking outside
- useEffect(() => {
- if (!userDropdownOpen) return;
- const handleClickOutside = (e: MouseEvent) => {
- const target = e.target as HTMLElement;
- if (!target.closest('.user-menu')) {
- setUserDropdownOpen(false);
- }
- };
- document.addEventListener("click", handleClickOutside);
- return () => document.removeEventListener("click", handleClickOutside);
- }, [userDropdownOpen]);
+ }, [selectedMed, showImageLightbox, scheduleLightboxImage, selectedUser, showProfile, showAbout, showShareDialog, showRefillModal, showEditStockModal]);
// Close tooltips on scroll/touch (for mobile)
useEffect(() => {
@@ -691,7 +235,7 @@ function AppContent() {
// Prevent background scroll when modal is open
useEffect(() => {
- const isModalOpen = selectedMed || selectedUser || showProfile || showAbout || showShareDialog || showEditModal;
+ const isModalOpen = selectedMed || selectedUser || showProfile || showAbout || showShareDialog;
if (isModalOpen) {
const scrollY = window.scrollY;
document.body.classList.add('modal-open');
@@ -708,7 +252,7 @@ function AppContent() {
document.body.classList.remove('modal-open');
document.body.style.top = '';
};
- }, [selectedMed, selectedUser, showProfile, showAbout, showShareDialog, showEditModal]);
+ }, [selectedMed, selectedUser, showProfile, showAbout, showShareDialog]);
// Update selectedMed when meds change (e.g., after refill)
useEffect(() => {
@@ -724,389 +268,22 @@ function AppContent() {
}
}, [meds, selectedMed]);
- // Check if settings have changed
- const settingsChanged = settings.emailEnabled !== savedSettings.emailEnabled ||
- settings.notificationEmail !== savedSettings.notificationEmail ||
- settings.reminderDaysBefore !== savedSettings.reminderDaysBefore ||
- settings.repeatDailyReminders !== savedSettings.repeatDailyReminders ||
- settings.lowStockDays !== savedSettings.lowStockDays ||
- settings.normalStockDays !== savedSettings.normalStockDays ||
- settings.highStockDays !== savedSettings.highStockDays ||
- settings.shoutrrrEnabled !== savedSettings.shoutrrrEnabled ||
- settings.shoutrrrUrl !== savedSettings.shoutrrrUrl;
-
- const schedule = useMemo(() => buildSchedulePreview(meds, i18n.language, true), [meds, i18n.language]);
- const totalTablets = useMemo(() => deriveTotal(form), [form]);
- const coverage = useMemo(() => calculateCoverage(meds, schedule.events, i18n.language, settings.reminderDaysBefore, settings.stockCalculationMode, takenDoses), [meds, schedule.events, i18n.language, settings.reminderDaysBefore, settings.stockCalculationMode, takenDoses]);
- const depletionByMed = useMemo(() => Object.fromEntries(coverage.all.map((c) => [c.name, c.depletionTime])), [coverage.all]);
- const coverageByMed = useMemo(() => Object.fromEntries(coverage.all.map((c) => [c.name, c])), [coverage.all]);
-
- // Get all unique people from medications for autocomplete suggestions
- const existingPeople = useMemo(() => {
- const allPeople = meds.flatMap(m => m.takenBy || []);
- return [...new Set(allPeople)].filter(Boolean).sort();
- }, [meds]);
-
- // Get worst stock status for a day's medications (for coloring day blocks)
- const getDayStockStatus = (dayMeds: { medName: string; lastWhen: number }[]) => {
- const statuses = dayMeds.map((item) => {
- const cov = coverageByMed[item.medName];
- const depletionTime = depletionByMed[item.medName];
-
- // Will be out of stock by this day?
- if (typeof depletionTime === "number" && item.lastWhen > depletionTime) {
- return "danger";
- }
-
- if (!cov) return "success";
- const { daysLeft, medsLeft } = cov;
-
- // Currently out of stock
- if (medsLeft <= 0 || daysLeft === 0) return "danger";
- // No schedule (can't calculate)
- if (daysLeft === null) return "success";
- // Low stock: < lowStockDays (warning)
- if (daysLeft < settings.lowStockDays) return "warning";
- // Normal/High stock
- return "success";
- });
- return statuses.includes("danger") ? "danger" : statuses.includes("warning") ? "warning" : "success";
+ const handleSubmitStockCorrection = async (medId: number) => {
+ if (!selectedMed) return;
+ await ctx.submitStockCorrection(medId, selectedMed, loadMeds);
};
- const groupedSchedule = useMemo(() => {
- type DoseInfo = { id: string; timeStr: string; when: number; usage: number; takenBy: string[] };
- const days = new Map }>();
- schedule.events.slice(0, 2000).forEach((event) => {
- const day = days.get(event.dateStr) ?? { dateStr: event.dateStr, date: new Date(event.when), isPast: event.isPast, meds: new Map() };
- const medEntry = day.meds.get(event.medName) ?? { medName: event.medName, total: 0, doses: [], lastWhen: event.when };
- medEntry.total += event.usage;
- medEntry.doses.push({ id: event.id, timeStr: event.timeStr, when: event.when, usage: event.usage, takenBy: event.takenBy || [] });
- medEntry.lastWhen = Math.max(medEntry.lastWhen, event.when);
- day.meds.set(event.medName, medEntry);
- days.set(event.dateStr, day);
- });
- return Array.from(days.values()).map((d) => ({ dateStr: d.dateStr, date: d.date, isPast: d.isPast, meds: Array.from(d.meds.values()) }));
- }, [schedule.events]);
-
- const pastDays = useMemo(() => groupedSchedule.filter(d => d.isPast), [groupedSchedule]);
- const futureDays = useMemo(() => groupedSchedule.filter(d => !d.isPast).slice(0, scheduleDays), [groupedSchedule, scheduleDays]);
-
- // Calculate missed past dose IDs for the "Clear missed" feature
- const missedPastDoseIds = useMemo(() => {
- const totalPastDoses = pastDays.flatMap(d =>
- d.meds.flatMap(m =>
- m.doses.flatMap(dose =>
- (dose.takenBy || []).length > 0
- ? dose.takenBy.map((p: string) => `${dose.id}-${p}`)
- : [dose.id]
- )
- )
- );
- return totalPastDoses.filter(id => !takenDoses.has(id) && !dismissedDoses.has(id));
- }, [pastDays, takenDoses, dismissedDoses]);
-
- // Load medications and settings when user changes (or on initial mount)
- useEffect(() => {
- loadMeds();
- loadSettings();
- // Reset planner when user changes
- setPlannerRows([]);
- setRange({ start: toInputValue(todayIso()), end: toInputValue(plusDaysIso(3)) });
- }, [user?.id]);
-
- function loadMeds() {
- setLoading(true);
- fetch("/api/medications")
- .then((res) => res.json())
- .then((data) => setMeds(Array.isArray(data) ? data : []))
- .catch(() => setMeds([]))
- .finally(() => setLoading(false));
- }
-
- function loadSettings() {
- setSettingsLoading(true);
- fetch("/api/settings")
- .then((res) => res.json())
- .then((data) => {
- const newSettings = { ...settings, ...data, smtpPass: "" };
- setSettings(newSettings);
- setSavedSettings(newSettings);
- setSettingsSaved(false);
- })
- .catch(() => {})
- .finally(() => setSettingsLoading(false));
- }
-
- async function saveSettings(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,
- // Granular notification settings
- emailStockReminders: settings.emailStockReminders,
- emailIntakeReminders: settings.emailIntakeReminders,
- shoutrrrStockReminders: settings.shoutrrrStockReminders,
- shoutrrrIntakeReminders: settings.shoutrrrIntakeReminders,
- // Stock calculation mode
- stockCalculationMode: settings.stockCalculationMode,
- // Language setting (for backend notifications)
- language: i18n.language,
- // SMTP (legacy - not saved, read from .env)
- 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);
-
- // Update local state with effective values
- const updatedSettings = {
- ...settings,
- emailEnabled: effectiveEmailEnabled,
- shoutrrrEnabled: effectiveShoutrrrEnabled
- };
- setSettings(updatedSettings);
- setSettingsSaving(false);
- setSavedSettings(updatedSettings);
- setSettingsSaved(true);
- }
-
- // Load refill history for a medication
- async function loadRefillHistory(medId: number) {
- try {
- const res = await fetch(`/api/medications/${medId}/refills`, { credentials: "include" });
- if (res.ok) {
- const data = await res.json();
- setRefillHistory(Array.isArray(data) ? data : (data.refills || []));
- } else {
- setRefillHistory([]);
- }
- } catch {
- setRefillHistory([]);
- }
- }
-
- // Submit a refill
- async function submitRefill(medId: number) {
- if (refillPacks < 1 && refillLoose < 1) return;
- setRefillSaving(true);
- try {
- const res = await fetch(`/api/medications/${medId}/refill`, {
- method: "POST",
- headers: { "Content-Type": "application/json" },
- credentials: "include",
- body: JSON.stringify({ packsAdded: refillPacks, loosePillsAdded: refillLoose }),
- });
- if (res.ok) {
- const data = await res.json();
- // Update form values if we're in edit mode
- if (editingId === medId && data.newStock) {
- setForm(f => ({
- ...f,
- packCount: String(data.newStock.packCount),
- looseTablets: String(data.newStock.looseTablets),
- }));
- }
- // Reset refill form
- setRefillPacks(1);
- setRefillLoose(0);
- // Close refill modal via history back for proper back-button support
- if (showRefillModal) {
- window.history.back();
- }
- // Reload medications to get updated stock
- loadMeds();
- // Reload refill history
- await loadRefillHistory(medId);
- }
- } catch {
- // ignore
- }
- setRefillSaving(false);
- }
-
- // Submit a stock correction - user says how many pills they have RIGHT NOW
- // The server sets lastStockCorrectionAt, so consumed doses before now won't count anymore
- async function submitStockCorrection(medId: number) {
- if (!selectedMed) return;
- setEditStockSaving(true);
- try {
- // Auto-convert: handle full blister and negative partial blister
- let finalFullBlisters = editStockFullBlisters;
- let finalPartialPills = editStockPartialBlisterPills;
-
- // Handle full blister: e.g. 9 pills in a 9-pill blister = +1 full blister, 0 partial
- if (finalPartialPills >= selectedMed.pillsPerBlister) {
- finalFullBlisters += 1;
- finalPartialPills = 0;
- }
-
- // Handle negative partial: e.g. -3 with 136 full = 135 full, 6 partial (for 9-pill blister)
- if (finalPartialPills < 0 && finalFullBlisters > 0) {
- finalFullBlisters -= 1;
- finalPartialPills = selectedMed.pillsPerBlister + finalPartialPills;
- }
-
- // Ensure we don't go negative
- if (finalPartialPills < 0) finalPartialPills = 0;
- if (finalFullBlisters < 0) finalFullBlisters = 0;
-
- // What the user says they have RIGHT NOW = the new DB total
- // The server will set lastStockCorrectionAt, so all previous consumed doses are ignored
- const desiredTotal = finalFullBlisters * selectedMed.pillsPerBlister + finalPartialPills;
-
- // The "base" from DB structure (without any stockAdjustment)
- const baseTotal = selectedMed.packCount * selectedMed.blistersPerPack * selectedMed.pillsPerBlister + selectedMed.looseTablets;
-
- // stockAdjustment = what we need to make getMedTotal() return desiredTotal
- const newStockAdjustment = desiredTotal - baseTotal;
-
- console.log('submitStockCorrection:', {
- input: { fullBlisters: editStockFullBlisters, partial: editStockPartialBlisterPills },
- final: { fullBlisters: finalFullBlisters, partial: finalPartialPills },
- desiredTotal,
- baseTotal,
- newStockAdjustment
- });
-
- // Use the PATCH endpoint - it sets stockAdjustment AND lastStockCorrectionAt
- const res = await fetch(`/api/medications/${medId}/stock-adjustment`, {
- method: "PATCH",
- headers: { "Content-Type": "application/json" },
- credentials: "include",
- body: JSON.stringify({ stockAdjustment: newStockAdjustment }),
- });
- console.log('PATCH response:', res.status, res.ok);
- if (res.ok) {
- // Close edit stock modal via history back
- if (showEditStockModal) {
- window.history.back();
- }
- // Reload medications to get updated stock
- loadMeds();
- }
- } catch {
- // ignore
- }
- setEditStockSaving(false);
- }
-
- // Helper to open medication detail modal with refill history
- function openMedDetail(med: Medication) {
- setSelectedMed(med);
- setRefillHistory([]);
- setRefillHistoryExpanded(false);
- loadRefillHistory(med.id);
- // Push history state so browser back closes modal instead of navigating
- window.history.pushState({ modal: 'medDetail', medId: med.id }, '');
- }
-
- // Helper to close medication detail modal via history back
- function closeMedDetail() {
+ // For MedDetailModal: refill without form update (not editing)
+ const handleSubmitRefill = async (medId: number) => {
+ await ctx.submitRefill(medId, null, () => {}, loadMeds);
+ };
+
+ // Wrapper for openEditStockModal (provides selectedMed and coverage)
+ const handleOpenEditStockModal = () => {
if (selectedMed) {
- window.history.back();
+ openEditStockModal(selectedMed, coverage);
}
- }
-
- // Modal helper functions for browser back button support
- function openImageLightbox() {
- setShowImageLightbox(true);
- window.history.pushState({ modal: 'imageLightbox' }, '');
- }
- function closeImageLightbox() {
- if (showImageLightbox) {
- window.history.back();
- }
- }
-
- function openScheduleLightbox(imageUrl: string) {
- setScheduleLightboxImage(imageUrl);
- window.history.pushState({ modal: 'scheduleLightbox' }, '');
- }
- function closeScheduleLightbox() {
- if (scheduleLightboxImage) {
- window.history.back();
- }
- }
-
- function openRefillModal() {
- setShowRefillModal(true);
- window.history.pushState({ modal: 'refill' }, '');
- }
- function closeRefillModal() {
- if (showRefillModal) {
- window.history.back();
- }
- }
-
- function openEditStockModal() {
- if (!selectedMed) return;
- // Get current stock from coverage (after consumption)
- const medCoverage = coverage.all.find(c => c.name === selectedMed.name);
- const dbTotal = getMedTotal(selectedMed);
- const currentStock = medCoverage ? Math.round(medCoverage.medsLeft) : dbTotal;
-
- // Simply divide into full blisters and partial
- const fullBlisters = Math.floor(currentStock / selectedMed.pillsPerBlister);
- const partialPills = currentStock % selectedMed.pillsPerBlister;
-
- // Pre-fill with current values
- setEditStockFullBlisters(fullBlisters);
- setEditStockPartialBlisterPills(partialPills);
- setShowEditStockModal(true);
- window.history.pushState({ modal: 'editStock' }, '');
- }
- function closeEditStockModal() {
- if (showEditStockModal) {
- window.history.back();
- }
- }
-
- function openEditModal() {
- setShowEditModal(true);
- window.history.pushState({ modal: 'edit' }, '');
- }
- function closeEditModal() {
- if (showEditModal) {
- window.history.back();
- }
- }
+ };
function openProfile() {
setShowProfile(true);
@@ -1121,22 +298,6 @@ function AppContent() {
function openAbout() {
setShowAbout(true);
window.history.pushState({ modal: 'about' }, '');
- // Fetch backend version when opening
- fetch('/api/health')
- .then(res => res.json())
- .then(data => setBackendVersion(data.version || 'unknown'))
- .catch(() => setBackendVersion('unknown'));
- // Restore cached update check result from sessionStorage
- const cached = sessionStorage.getItem('updateCheckResult');
- if (cached) {
- try {
- const parsed = JSON.parse(cached);
- // Only use cache if less than 1 hour old
- if (parsed.lastChecked && Date.now() - new Date(parsed.lastChecked).getTime() < 60 * 60 * 1000) {
- setUpdateCheckResult(parsed);
- }
- } catch { /* ignore */ }
- }
}
function closeAbout() {
if (showAbout) {
@@ -1144,4255 +305,100 @@ function AppContent() {
}
}
- async function checkForUpdates() {
- setUpdateCheckResult({ status: 'checking' });
- try {
- const res = await fetch(`https://api.github.com/repos/${GITHUB_REPO}/releases/latest`);
- if (!res.ok) throw new Error('Failed to fetch');
- const data = await res.json();
- const latestVersion = data.tag_name?.replace(/^v/, '') || data.name?.replace(/^v/, '');
- const lastChecked = new Date().toISOString();
-
- // Compare with current version (use frontend version as reference)
- const currentVersion = FRONTEND_VERSION;
- const needsUpdate = compareSemver(currentVersion, latestVersion) < 0;
-
- const result = {
- status: needsUpdate ? 'update-available' as const : 'up-to-date' as const,
- latestVersion,
- lastChecked,
- };
- setUpdateCheckResult(result);
- // Cache result in sessionStorage
- sessionStorage.setItem('updateCheckResult', JSON.stringify(result));
- } catch {
- setUpdateCheckResult({ status: 'error' });
- }
- }
-
- function openUserFilter(person: string) {
- setSelectedUser(person);
- window.history.pushState({ modal: 'userFilter', person }, '');
- }
- function closeUserFilter() {
- if (selectedUser) {
- window.history.back();
- }
- }
-
- async function testEmail() {
- if (!settings.notificationEmail) return;
- 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();
- if (res.ok) {
- setTestEmailResult({ success: true, message: data.message || "Email sent!" });
- } else {
- setTestEmailResult({ success: false, message: data.error || "Failed to send" });
- }
- } catch {
- setTestEmailResult({ success: false, message: "Network error" });
- }
- setTestingEmail(false);
- }
-
- async function testShoutrrr() {
- if (!settings.shoutrrrUrl) return;
- 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();
- if (res.ok) {
- setTestShoutrrrResult({ success: true, message: data.message || "Notification sent!" });
- } else {
- setTestShoutrrrResult({ success: false, message: data.error || "Failed to send" });
- }
- } catch {
- setTestShoutrrrResult({ success: false, message: "Network error" });
- }
- setTestingShoutrrr(false);
- }
-
- async function sendPlannerEmail() {
- if (!settings.notificationEmail || plannerRows.length === 0) return;
- setSendingPlannerEmail(true);
- setPlannerEmailResult(null);
-
- try {
- const res = await fetch("/api/planner/send-email", {
- method: "POST",
- headers: { "Content-Type": "application/json" },
- body: JSON.stringify({
- email: settings.notificationEmail,
- from: range.start,
- until: range.end,
- rows: plannerRows,
- }),
- });
- const data = await res.json();
- if (res.ok) {
- setPlannerEmailResult({ success: true, message: data.message || "Email sent!" });
- } else {
- setPlannerEmailResult({ success: false, message: data.error || "Failed to send" });
- }
- } catch {
- setPlannerEmailResult({ success: false, message: "Network error" });
- }
- setSendingPlannerEmail(false);
- }
-
- async function sendReminderEmail() {
- if (!settings.notificationEmail || coverage.low.length === 0) return;
- setSendingReminderEmail(true);
- setReminderEmailResult(null);
-
- try {
- const res = await fetch("/api/reminder/send-email", {
- method: "POST",
- headers: { "Content-Type": "application/json" },
- body: JSON.stringify({
- email: settings.notificationEmail,
- lowStock: coverage.low,
- }),
- });
- const data = await res.json();
- if (res.ok) {
- setReminderEmailResult({ success: true, message: data.message || "Email sent!" });
- // Reload settings to get updated lastAutoEmailSent
- loadSettings();
- } else {
- setReminderEmailResult({ success: false, message: data.error || "Failed to send" });
- }
- } catch {
- setReminderEmailResult({ success: false, message: "Network error" });
- }
- setSendingReminderEmail(false);
- }
-
- // Export data to JSON file
- async function handleExport(includeImages: boolean = true) {
- setExporting(true);
- try {
- const res = await fetch(`/api/export?includeSensitive=true&includeImages=${includeImages}`, {
- credentials: "include",
- });
- if (!res.ok) throw new Error("Export failed");
- const data = await res.json();
-
- // Create download
- const blob = new Blob([JSON.stringify(data, null, 2)], { type: "application/json" });
- const url = URL.createObjectURL(blob);
- const a = document.createElement("a");
- const dateStr = new Date().toISOString().split("T")[0];
- a.href = url;
- a.download = `${t('exportImport.downloadFilename')}-${dateStr}.json`;
- document.body.appendChild(a);
- a.click();
- document.body.removeChild(a);
- URL.revokeObjectURL(url);
- } catch (err) {
- console.error("Export error:", err);
- }
- setExporting(false);
- }
-
- // Handle file selection for import
- function handleImportFileSelect(e: React.ChangeEvent) {
- const file = e.target.files?.[0];
- if (!file) return;
-
- const reader = new FileReader();
- reader.onload = (event) => {
- try {
- const data = JSON.parse(event.target?.result as string);
- if (!data.version || !data.exportedAt) {
- alert(t('exportImport.invalidFile'));
- return;
- }
- setPendingImportData(data);
- setShowImportConfirm(true);
- } catch {
- alert(t('exportImport.invalidFile'));
- }
- };
- reader.readAsText(file);
- // Reset file input
- e.target.value = "";
- }
-
- // Confirm and execute import
- async function handleImportConfirm() {
- if (!pendingImportData) return;
- setImporting(true);
- setShowImportConfirm(false);
-
- try {
- const res = await fetch("/api/import", {
- method: "POST",
- headers: { "Content-Type": "application/json" },
- credentials: "include",
- body: JSON.stringify(pendingImportData),
- });
-
- // Get the response text first to handle non-JSON responses
- const text = await res.text();
- let data;
- try {
- data = text ? JSON.parse(text) : {};
- } catch {
- console.error("Import response parse error:", text);
- alert(t('exportImport.importError') + ": Server returned invalid response");
- return;
- }
-
- if (!res.ok) {
- alert(t('exportImport.importError') + ": " + (data.error || `HTTP ${res.status}`));
- return;
- }
-
- // Show success message in UI instead of browser alert
- setImportResult({
- medications: data.imported?.medications || 0,
- doses: data.imported?.doseHistory || 0,
- shares: data.imported?.shareLinks || 0,
- });
-
- // Reload all data
- loadMeds();
- loadSettings();
- loadTakenDoses();
- } catch (err) {
- console.error("Import error:", err);
- alert(t('exportImport.importError'));
- }
-
- setPendingImportData(null);
- setImporting(false);
- }
-
- // Helper function to load taken doses (extracted from useEffect)
- async function loadTakenDoses() {
- try {
- const res = await fetch("/api/doses/taken", { credentials: "include" });
- if (res.ok) {
- const data = await res.json();
- setTakenDoses(new Set(data.doses.map((d: { doseId: string }) => d.doseId)));
- }
- } catch {
- // Silently fail
- }
- }
-
- async function deleteMed(id: number) {
- await fetch(`/api/medications/${id}`, { method: "DELETE" }).catch(() => null);
- if (editingId === id) resetForm();
- loadMeds();
- }
-
- async function uploadMedImage(medId: number, file: File) {
- setUploadingImage(true);
- const formData = new FormData();
- formData.append("file", file);
-
- try {
- const res = await fetch(`/api/medications/${medId}/image`, {
- method: "POST",
- body: formData,
- });
- if (res.ok) {
- loadMeds();
- }
- } catch {
- // ignore
- }
- setUploadingImage(false);
- }
-
- async function deleteMedImage(medId: number) {
- await fetch(`/api/medications/${medId}/image`, { method: "DELETE" }).catch(() => null);
- loadMeds();
- }
-
- function setBlisterValue(idx: number, field: keyof FormBlister, value: string) {
- setForm((prev) => {
- const next = [...prev.blisters];
- next[idx] = { ...next[idx], [field]: value };
- return { ...prev, blisters: next };
- });
- }
-
- function addBlister() {
- setForm((prev) => ({ ...prev, blisters: [...prev.blisters, defaultBlister()] }));
- }
-
- function removeBlister(idx: number) {
- setForm((prev) => ({ ...prev, blisters: prev.blisters.filter((_, i) => i !== idx) }));
- }
-
- function startEdit(med: Medication) {
- setEditingId(med.id);
- setTakenByInput(""); // Clear tag input when starting edit
- setFormSaved(false);
- const editForm: FormState = {
- name: med.name,
- genericName: med.genericName ?? "",
- takenBy: med.takenBy || [], // Already an array from API
- packCount: String(med.packCount),
- blistersPerPack: String(med.blistersPerPack),
- pillsPerBlister: String(med.pillsPerBlister),
- looseTablets: String(med.looseTablets),
- pillWeightMg: med.pillWeightMg ? String(med.pillWeightMg) : "",
- expiryDate: med.expiryDate ? med.expiryDate.slice(0, 10) : "",
- notes: med.notes ?? "",
- intakeRemindersEnabled: med.intakeRemindersEnabled ?? false,
- blisters: med.blisters.map((s) => ({
- usage: String(s.usage),
- every: String(s.every),
- startDate: toDateValue(s.start),
- startTime: toTimeValue(s.start)
- })),
- };
- setForm(editForm);
- setOriginalForm(editForm);
- // Show modal on mobile
- if (window.innerWidth <= 768) {
- openEditModal();
- }
- }
-
- function resetForm() {
- setEditingId(null);
- setShowEditModal(false);
- setPendingImage(null);
- setPendingImagePreview(null);
- setTakenByInput("");
- setFormSaved(false);
- const newForm = defaultForm();
- setForm(newForm);
- setOriginalForm(newForm);
- }
-
- function handleValueChange(key: K, value: string) {
- setForm((prev) => ({ ...prev, [key]: value }));
- }
-
- // Tag input helpers for "Taken By" field
- function addTakenByPerson(name: string) {
- const trimmed = name.trim();
- if (trimmed && trimmed.length <= FIELD_LIMITS.takenBy.max && !form.takenBy.includes(trimmed)) {
- setForm(prev => ({ ...prev, takenBy: [...prev.takenBy, trimmed] }));
- }
- setTakenByInput("");
- }
-
- function removeTakenByPerson(name: string) {
- setForm(prev => ({ ...prev, takenBy: prev.takenBy.filter(p => p !== name) }));
- }
-
- function handleTakenByKeyDown(e: React.KeyboardEvent) {
- if (e.key === 'Enter' || e.key === ',') {
- e.preventDefault();
- addTakenByPerson(takenByInput);
- } else if (e.key === 'Backspace' && !takenByInput && form.takenBy.length > 0) {
- // Remove last tag on backspace when input is empty
- removeTakenByPerson(form.takenBy[form.takenBy.length - 1]);
- }
- }
-
- async function saveMedication(e: React.FormEvent) {
- e.preventDefault();
- if (!form.name.trim()) return;
- setSaving(true);
-
- const payload = {
- name: form.name.trim(),
- genericName: form.genericName.trim() || null,
- takenBy: form.takenBy.filter(name => name.trim()), // Send array, filter empty strings
- packCount: Number(form.packCount) || 0,
- blistersPerPack: Math.max(1, Number(form.blistersPerPack) || 1),
- pillsPerBlister: Math.max(1, Number(form.pillsPerBlister) || 1),
- looseTablets: Math.max(0, Number(form.looseTablets) || 0),
- pillWeightMg: form.pillWeightMg ? Number(form.pillWeightMg) : null,
- expiryDate: form.expiryDate || null,
- notes: form.notes.trim() || null,
- intakeRemindersEnabled: form.intakeRemindersEnabled,
- blisters: form.blisters.map((s) => ({
- usage: Number(s.usage) || 0,
- every: Math.max(1, Number(s.every) || 1),
- start: toIsoString(combineDateAndTime(s.startDate, s.startTime))
- })),
- };
-
- const method = editingId ? "PUT" : "POST";
- const url = editingId ? `/api/medications/${editingId}` : "/api/medications";
- const wasEditing = editingId;
-
- try {
- const res = await fetch(url, { method, headers: { "Content-Type": "application/json" }, body: JSON.stringify(payload) });
-
- // If creating new medication and we have a pending image, upload it
- if (!wasEditing && pendingImage && res.ok) {
- const newMed = await res.json();
- if (newMed?.id) {
- await uploadMedImage(newMed.id, pendingImage);
- }
- }
-
- // Mark as saved and update original form to current state
- if (res.ok) {
- setFormSaved(true);
- setOriginalForm(form);
- }
- } catch {
- // ignore
- }
-
- setSaving(false);
-
- // Only reset form if creating new medication, not when editing
- if (!wasEditing) {
- resetForm();
- } else {
- // Close modal on mobile after edit (via history back for proper back-button support)
- if (showEditModal) {
- window.history.back();
- }
- }
-
- loadMeds();
- }
-
- async function runPlanner(e: React.FormEvent) {
- e.preventDefault();
- setPlannerLoading(true);
- const body = { startDate: toIsoString(range.start), endDate: toIsoString(range.end) };
- const rows = await fetch("/api/medications/usage", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(body) })
- .then((res) => res.json())
- .catch(() => []) as PlannerRow[];
- setPlannerRows(rows);
- setPlannerLoading(false);
- // Save to user-specific localStorage
- if (user?.id) {
- localStorage.setItem(userStorageKey(user.id, "plannerRange"), JSON.stringify(range));
- localStorage.setItem(userStorageKey(user.id, "plannerRows"), JSON.stringify(rows));
- }
- }
-
- function resetRange() {
- setRange({ start: toInputValue(todayIso()), end: toInputValue(plusDaysIso(3)) });
- setPlannerRows([]);
- if (user?.id) {
- localStorage.removeItem(userStorageKey(user.id, "plannerRange"));
- localStorage.removeItem(userStorageKey(user.id, "plannerRows"));
- }
- }
-
- // Share dialog functions
- async function openShareDialog() {
- setShowShareDialog(true);
- window.history.pushState({ modal: 'share' }, '');
- setShareLink(null);
- setShareCopied(false);
- 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);
- if (uniquePeople.length > 0) {
- setShareSelectedPerson(uniquePeople[0]);
- }
- }
-
- async function generateShareLink() {
- if (!shareSelectedPerson) return;
- setShareGenerating(true);
- setShareCopied(false);
-
- try {
- const res = await fetch("/api/share", {
- method: "POST",
- headers: { "Content-Type": "application/json" },
- body: JSON.stringify({
- takenBy: shareSelectedPerson,
- scheduleDays: shareSelectedDays,
- }),
- });
-
- if (res.ok) {
- const data = await res.json();
- const fullUrl = `${window.location.origin}/share/${data.token}`;
- setShareLink(fullUrl);
- } else {
- const err = await res.json();
- alert(err.error || "Failed to generate share link");
- }
- } catch {
- alert("Failed to generate share link");
- } finally {
- setShareGenerating(false);
- }
- }
-
- function copyShareLink() {
- if (shareLink) {
- navigator.clipboard.writeText(shareLink);
- setShareCopied(true);
- setTimeout(() => setShareCopied(false), 2000);
- }
- }
-
- function closeShareDialog() {
- if (showShareDialog) {
- window.history.back();
- }
- }
-
- // Internal function to reset share dialog state (called by popstate handler)
- function resetShareDialogState() {
- setShowShareDialog(false);
- setShareLink(null);
- setShareCopied(false);
- }
-
- // Toggle day collapse/expand
- function toggleDayCollapse(dateStr: string, isAutoCollapsed: boolean) {
- if (isAutoCollapsed) {
- // Day is auto-collapsed (all taken) - toggle the expanded override
- setManuallyExpandedDays((prev) => {
- const next = new Set(prev);
- if (next.has(dateStr)) {
- next.delete(dateStr);
- } else {
- next.add(dateStr);
- }
- if (user?.id) localStorage.setItem(userStorageKey(user.id, "expandedDays"), JSON.stringify([...next]));
- return next;
- });
- } else {
- // Day is not auto-collapsed - toggle manual collapse
- setManuallyCollapsedDays((prev) => {
- const next = new Set(prev);
- if (next.has(dateStr)) {
- next.delete(dateStr);
- } else {
- next.add(dateStr);
- }
- if (user?.id) localStorage.setItem(userStorageKey(user.id, "collapsedDays"), JSON.stringify([...next]));
- return next;
- });
- }
- }
-
- const [theme, setTheme] = useState<"light" | "dark">(() => {
- if (typeof window !== "undefined") {
- return (localStorage.getItem("theme") as "light" | "dark") || "dark";
- }
- return "dark";
- });
-
- useEffect(() => {
- document.documentElement.setAttribute("data-theme", theme);
- localStorage.setItem("theme", theme);
- }, [theme]);
-
- function toggleTheme() {
- setTheme((prev) => (prev === "dark" ? "light" : "dark"));
- }
-
- // Page titles based on current route
- const pageInfo = {
- "/dashboard": { eyebrow: t('header.eyebrow.overview'), title: t('nav.dashboard') },
- "/medications": { eyebrow: t('header.eyebrow.inventory'), title: t('nav.medications') },
- "/planner": { eyebrow: t('header.eyebrow.planner'), title: t('nav.planner') },
- "/settings": { eyebrow: t('header.eyebrow.settings'), title: t('nav.settings') },
- "/schedule": { eyebrow: t('header.eyebrow.schedule'), title: t('dashboard.schedules.title') },
- }[currentPath] || { eyebrow: t('header.eyebrow.overview'), title: t('nav.dashboard') };
-
return (
-
-
-
-
-
{pageInfo.eyebrow}
-
{pageInfo.title}
-
-
-
-
-
-
-
-
- {/* Settings button only shown when auth is disabled (no user dropdown available) */}
- {!authState?.authEnabled && (
-
- )}
-
- {authState?.authEnabled && user && (
-
-
-
-
- {user.avatarUrl ? (
-
- ) : (
-
{user.username.charAt(0).toUpperCase()}
- )}
- {user.username}
-
-
-
-
-
-
-
-
-
- )}
-
-
+
{/* Profile Modal */}
- {showProfile && (
-
closeProfile()}>
-
e.stopPropagation()}>
-
- closeProfile()} />
-
-
- )}
+
{/* About Modal */}
- {showAbout && (
-
closeAbout()}>
-
e.stopPropagation()}>
-
-
-
-
-
-
{t('about.appName', 'MedAssist')}
-
{t('about.description', 'Personal medication tracking and reminder app')}
;
- }
-
- // Count medications with "Low" stock status (based on lowStockDays setting)
- const lowStockCount = coverage.all.filter(c => {
- if (c.medsLeft <= 0) return true; // out of stock
- if (c.daysLeft === null) return false; // no schedule
- return c.daysLeft < settings.lowStockDays;
- }).length;
-
- if (coverage.low.length === 0) {
- // No critical meds (≤3 days)
- if (lowStockCount === 0) {
- // All good - everything is Normal or High
- return
{t('dashboard.reorder.allGood')}
;
- } else {
- // Some meds are Low but not critical
- return
- {item.doses.map((dose) => {
- // If no takenBy, show single checkbox; otherwise show one per person
- const people = (dose.takenBy || []).length > 0 ? dose.takenBy : [null];
- return (
-
- {item.doses.map((dose) => {
- // If no takenBy, show single checkbox; otherwise show one per person
- const people = (dose.takenBy || []).length > 0 ? dose.takenBy : [null];
- return (
-
+ {/* Settings button only shown when auth is disabled (no user dropdown available) */}
+ {!authState?.authEnabled && (
+
+ )}
+
+ {authState?.authEnabled && user && (
+
;
+ }
+
+ // Count medications with "Low" stock status (based on lowStockDays setting)
+ const lowStockCount = coverage.all.filter(c => {
+ if (c.medsLeft <= 0) return true; // out of stock
+ if (c.daysLeft === null) return false; // no schedule
+ return c.daysLeft < settings.lowStockDays;
+ }).length;
+
+ if (coverage.low.length === 0) {
+ // No critical meds (≤3 days)
+ if (lowStockCount === 0) {
+ // All good - everything is Normal or High
+ return
{t('dashboard.reorder.allGood')}
;
+ } else {
+ // Some meds are Low but not critical
+ return
+ {item.doses.map((dose) => {
+ // If no takenBy, show single checkbox; otherwise show one per person
+ const people = (dose.takenBy || []).length > 0 ? dose.takenBy : [null];
+ return (
+
+ {item.doses.map((dose) => {
+ // If no takenBy, show single checkbox; otherwise show one per person
+ const people = (dose.takenBy || []).length > 0 ? dose.takenBy : [null];
+ return (
+