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:
@@ -0,0 +1,17 @@
|
||||
// Hooks barrel export
|
||||
export { useDoses } from "./useDoses";
|
||||
export type { UseDosesReturn } from "./useDoses";
|
||||
export { useCollapsedDays } from "./useCollapsedDays";
|
||||
export type { UseCollapsedDaysReturn } from "./useCollapsedDays";
|
||||
export { useTheme } from "./useTheme";
|
||||
export type { Theme, UseThemeReturn } from "./useTheme";
|
||||
export { useSettings } from "./useSettings";
|
||||
export type { Settings, UseSettingsReturn } from "./useSettings";
|
||||
export { useShare } from "./useShare";
|
||||
export type { UseShareReturn } from "./useShare";
|
||||
export { useMedications } from "./useMedications";
|
||||
export type { UseMedicationsReturn } from "./useMedications";
|
||||
export { useMedicationForm, defaultBlister, defaultForm } from "./useMedicationForm";
|
||||
export type { UseMedicationFormReturn } from "./useMedicationForm";
|
||||
export { useRefill } from "./useRefill";
|
||||
export type { UseRefillReturn } from "./useRefill";
|
||||
@@ -0,0 +1,67 @@
|
||||
// =============================================================================
|
||||
// useCollapsedDays Hook - Day collapse/expand state management
|
||||
// =============================================================================
|
||||
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { loadCollapsedDaysFromStorage, userStorageKey } from "../utils/storage";
|
||||
|
||||
export interface UseCollapsedDaysReturn {
|
||||
manuallyCollapsedDays: Set<string>;
|
||||
manuallyExpandedDays: Set<string>;
|
||||
toggleDayCollapse: (dateStr: string, isAutoCollapsed: boolean) => void;
|
||||
}
|
||||
|
||||
export function useCollapsedDays(userId: number | undefined): UseCollapsedDaysReturn {
|
||||
const [manuallyCollapsedDays, setManuallyCollapsedDays] = useState<Set<string>>(new Set());
|
||||
const [manuallyExpandedDays, setManuallyExpandedDays] = useState<Set<string>>(new Set());
|
||||
|
||||
// Load collapsed/expanded state from localStorage when user changes
|
||||
useEffect(() => {
|
||||
if (typeof window !== "undefined" && userId) {
|
||||
const { collapsed, expanded } = loadCollapsedDaysFromStorage(
|
||||
userStorageKey(userId, "collapsedDays"),
|
||||
userStorageKey(userId, "expandedDays")
|
||||
);
|
||||
setManuallyCollapsedDays(collapsed);
|
||||
setManuallyExpandedDays(expanded);
|
||||
}
|
||||
}, [userId]);
|
||||
|
||||
// Toggle day collapse/expand
|
||||
const toggleDayCollapse = useCallback(
|
||||
(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 (userId) localStorage.setItem(userStorageKey(userId, "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 (userId) localStorage.setItem(userStorageKey(userId, "collapsedDays"), JSON.stringify([...next]));
|
||||
return next;
|
||||
});
|
||||
}
|
||||
},
|
||||
[userId]
|
||||
);
|
||||
|
||||
return {
|
||||
manuallyCollapsedDays,
|
||||
manuallyExpandedDays,
|
||||
toggleDayCollapse
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,176 @@
|
||||
// =============================================================================
|
||||
// useDoses Hook - Dose tracking state and operations
|
||||
// =============================================================================
|
||||
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
|
||||
export interface UseDosesReturn {
|
||||
takenDoses: Set<string>;
|
||||
setTakenDoses: React.Dispatch<React.SetStateAction<Set<string>>>;
|
||||
dismissedDoses: Set<string>;
|
||||
clearingMissed: boolean;
|
||||
showClearMissedConfirm: boolean;
|
||||
setShowClearMissedConfirm: (show: boolean) => void;
|
||||
getDoseId: (baseDoseId: string, person: string | null) => string;
|
||||
countTakenDoses: (doses: Array<{ id: string; takenBy: string[] }>) => { total: number; taken: number };
|
||||
markDoseTaken: (doseId: string) => Promise<void>;
|
||||
undoDoseTaken: (doseId: string) => Promise<void>;
|
||||
dismissMissedDoses: (doseIds: string[]) => Promise<void>;
|
||||
loadTakenDoses: () => Promise<void>;
|
||||
}
|
||||
|
||||
export function useDoses(): UseDosesReturn {
|
||||
const [takenDoses, setTakenDoses] = useState<Set<string>>(new Set());
|
||||
const [dismissedDoses, setDismissedDoses] = useState<Set<string>>(new Set());
|
||||
const [showClearMissedConfirm, setShowClearMissedConfirm] = useState(false);
|
||||
const [clearingMissed, setClearingMissed] = useState(false);
|
||||
|
||||
// Load taken doses from server
|
||||
const loadTakenDoses = useCallback(async () => {
|
||||
try {
|
||||
const res = await fetch("/api/doses/taken", { credentials: "include" });
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
const taken = new Set<string>();
|
||||
const dismissed = new Set<string>();
|
||||
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
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Poll for taken doses from server (works with or without auth)
|
||||
useEffect(() => {
|
||||
loadTakenDoses();
|
||||
|
||||
// Poll for updates every 5 seconds (real-time sync with share links)
|
||||
const interval = setInterval(loadTakenDoses, 5000);
|
||||
return () => clearInterval(interval);
|
||||
}, [loadTakenDoses]);
|
||||
|
||||
// Get dose ID with optional person suffix
|
||||
const getDoseId = useCallback((baseDoseId: string, person: string | null): string => {
|
||||
return person ? `${baseDoseId}-${person}` : baseDoseId;
|
||||
}, []);
|
||||
|
||||
// Count taken doses for a day/item
|
||||
const countTakenDoses = useCallback(
|
||||
(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 };
|
||||
},
|
||||
[takenDoses, getDoseId]
|
||||
);
|
||||
|
||||
const markDoseTaken = useCallback(async (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;
|
||||
});
|
||||
}
|
||||
}, []);
|
||||
|
||||
const undoDoseTaken = useCallback(async (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
|
||||
const dismissMissedDoses = useCallback(async (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);
|
||||
}
|
||||
}, []);
|
||||
|
||||
return {
|
||||
takenDoses,
|
||||
setTakenDoses,
|
||||
dismissedDoses,
|
||||
clearingMissed,
|
||||
showClearMissedConfirm,
|
||||
setShowClearMissedConfirm,
|
||||
getDoseId,
|
||||
countTakenDoses,
|
||||
markDoseTaken,
|
||||
undoDoseTaken,
|
||||
dismissMissedDoses,
|
||||
loadTakenDoses
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,233 @@
|
||||
import { useState, useMemo, useEffect, useCallback } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import type { Medication, FormState, FormBlister, FieldErrors } from "../types";
|
||||
import { FIELD_LIMITS } from "../types";
|
||||
import { toDateValue, toTimeValue } from "../utils/formatters";
|
||||
|
||||
export const defaultBlister = (): FormBlister => {
|
||||
const now = new Date();
|
||||
return {
|
||||
usage: "1",
|
||||
every: "1",
|
||||
startDate: toDateValue(now),
|
||||
startTime: toTimeValue(now)
|
||||
};
|
||||
};
|
||||
|
||||
export const defaultForm = (): FormState => ({
|
||||
name: "",
|
||||
genericName: "",
|
||||
takenBy: [],
|
||||
packCount: "1",
|
||||
blistersPerPack: "1",
|
||||
pillsPerBlister: "1",
|
||||
looseTablets: "0",
|
||||
pillWeightMg: "",
|
||||
expiryDate: "",
|
||||
notes: "",
|
||||
intakeRemindersEnabled: false,
|
||||
blisters: [defaultBlister()]
|
||||
});
|
||||
|
||||
export interface UseMedicationFormReturn {
|
||||
form: FormState;
|
||||
setForm: React.Dispatch<React.SetStateAction<FormState>>;
|
||||
originalForm: FormState;
|
||||
editingId: number | null;
|
||||
setEditingId: React.Dispatch<React.SetStateAction<number | null>>;
|
||||
showEditModal: boolean;
|
||||
setShowEditModal: React.Dispatch<React.SetStateAction<boolean>>;
|
||||
fieldErrors: FieldErrors;
|
||||
formSaved: boolean;
|
||||
setFormSaved: React.Dispatch<React.SetStateAction<boolean>>;
|
||||
hasValidationErrors: boolean;
|
||||
formChanged: boolean;
|
||||
pendingImage: File | null;
|
||||
setPendingImage: React.Dispatch<React.SetStateAction<File | null>>;
|
||||
pendingImagePreview: string | null;
|
||||
setPendingImagePreview: React.Dispatch<React.SetStateAction<string | null>>;
|
||||
takenByInput: string;
|
||||
setTakenByInput: React.Dispatch<React.SetStateAction<string>>;
|
||||
validateField: (field: keyof FieldErrors, value: string | string[]) => string | undefined;
|
||||
setBlisterValue: (idx: number, field: keyof FormBlister, value: string) => void;
|
||||
addBlister: () => void;
|
||||
removeBlister: (idx: number) => void;
|
||||
startEdit: (med: Medication, openEditModal: () => void) => void;
|
||||
resetForm: () => void;
|
||||
handleValueChange: <K extends keyof FormState>(key: K, value: string) => void;
|
||||
addTakenByPerson: (name: string) => void;
|
||||
removeTakenByPerson: (name: string) => void;
|
||||
handleTakenByKeyDown: (e: React.KeyboardEvent<HTMLInputElement>) => void;
|
||||
}
|
||||
|
||||
export function useMedicationForm(): UseMedicationFormReturn {
|
||||
const { t } = useTranslation();
|
||||
const [form, setForm] = useState<FormState>(defaultForm());
|
||||
const [originalForm, setOriginalForm] = useState<FormState>(defaultForm());
|
||||
const [editingId, setEditingId] = useState<number | null>(null);
|
||||
const [showEditModal, setShowEditModal] = useState(false);
|
||||
const [fieldErrors, setFieldErrors] = useState<FieldErrors>({});
|
||||
const [formSaved, setFormSaved] = useState(false);
|
||||
const [pendingImage, setPendingImage] = useState<File | null>(null);
|
||||
const [pendingImagePreview, setPendingImagePreview] = useState<string | null>(null);
|
||||
const [takenByInput, setTakenByInput] = useState("");
|
||||
|
||||
// Validate form fields
|
||||
const validateField = useCallback((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;
|
||||
}, [t]);
|
||||
|
||||
// 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, validateField]);
|
||||
|
||||
const setBlisterValue = useCallback((idx: number, field: keyof FormBlister, value: string) => {
|
||||
setForm((prev) => {
|
||||
const next = [...prev.blisters];
|
||||
next[idx] = { ...next[idx], [field]: value };
|
||||
return { ...prev, blisters: next };
|
||||
});
|
||||
}, []);
|
||||
|
||||
const addBlister = useCallback(() => {
|
||||
setForm((prev) => ({ ...prev, blisters: [...prev.blisters, defaultBlister()] }));
|
||||
}, []);
|
||||
|
||||
const removeBlister = useCallback((idx: number) => {
|
||||
setForm((prev) => ({ ...prev, blisters: prev.blisters.filter((_, i) => i !== idx) }));
|
||||
}, []);
|
||||
|
||||
const startEdit = useCallback((med: Medication, openEditModal: () => void) => {
|
||||
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();
|
||||
}
|
||||
}, []);
|
||||
|
||||
const resetForm = useCallback(() => {
|
||||
setEditingId(null);
|
||||
setShowEditModal(false);
|
||||
setPendingImage(null);
|
||||
setPendingImagePreview(null);
|
||||
setTakenByInput("");
|
||||
setFormSaved(false);
|
||||
const newForm = defaultForm();
|
||||
setForm(newForm);
|
||||
setOriginalForm(newForm);
|
||||
}, []);
|
||||
|
||||
const handleValueChange = useCallback(<K extends keyof FormState>(key: K, value: string) => {
|
||||
setForm((prev) => ({ ...prev, [key]: value }));
|
||||
}, []);
|
||||
|
||||
// Tag input helpers for "Taken By" field
|
||||
const addTakenByPerson = useCallback((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("");
|
||||
}, [form.takenBy]);
|
||||
|
||||
const removeTakenByPerson = useCallback((name: string) => {
|
||||
setForm(prev => ({ ...prev, takenBy: prev.takenBy.filter(p => p !== name) }));
|
||||
}, []);
|
||||
|
||||
const handleTakenByKeyDown = useCallback((e: React.KeyboardEvent<HTMLInputElement>) => {
|
||||
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]);
|
||||
}
|
||||
}, [takenByInput, form.takenBy, addTakenByPerson, removeTakenByPerson]);
|
||||
|
||||
return {
|
||||
form,
|
||||
setForm,
|
||||
originalForm,
|
||||
editingId,
|
||||
setEditingId,
|
||||
showEditModal,
|
||||
setShowEditModal,
|
||||
fieldErrors,
|
||||
formSaved,
|
||||
setFormSaved,
|
||||
hasValidationErrors,
|
||||
formChanged,
|
||||
pendingImage,
|
||||
setPendingImage,
|
||||
pendingImagePreview,
|
||||
setPendingImagePreview,
|
||||
takenByInput,
|
||||
setTakenByInput,
|
||||
validateField,
|
||||
setBlisterValue,
|
||||
addBlister,
|
||||
removeBlister,
|
||||
startEdit,
|
||||
resetForm,
|
||||
handleValueChange,
|
||||
addTakenByPerson,
|
||||
removeTakenByPerson,
|
||||
handleTakenByKeyDown,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
import { useState, useCallback } from "react";
|
||||
import type { Medication } from "../types";
|
||||
|
||||
export interface UseMedicationsReturn {
|
||||
meds: Medication[];
|
||||
setMeds: React.Dispatch<React.SetStateAction<Medication[]>>;
|
||||
loading: boolean;
|
||||
saving: boolean;
|
||||
setSaving: React.Dispatch<React.SetStateAction<boolean>>;
|
||||
uploadingImage: boolean;
|
||||
loadMeds: () => void;
|
||||
deleteMed: (id: number, editingId: number | null, resetForm: () => void) => Promise<void>;
|
||||
uploadMedImage: (medId: number, file: File) => Promise<void>;
|
||||
deleteMedImage: (medId: number) => Promise<void>;
|
||||
}
|
||||
|
||||
export function useMedications(): UseMedicationsReturn {
|
||||
const [meds, setMeds] = useState<Medication[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [uploadingImage, setUploadingImage] = useState(false);
|
||||
|
||||
const loadMeds = useCallback(() => {
|
||||
setLoading(true);
|
||||
fetch("/api/medications")
|
||||
.then((res) => res.json())
|
||||
.then((data) => setMeds(Array.isArray(data) ? data : []))
|
||||
.catch(() => setMeds([]))
|
||||
.finally(() => setLoading(false));
|
||||
}, []);
|
||||
|
||||
const deleteMed = useCallback(async (id: number, editingId: number | null, resetForm: () => void) => {
|
||||
await fetch(`/api/medications/${id}`, { method: "DELETE" }).catch(() => null);
|
||||
if (editingId === id) resetForm();
|
||||
loadMeds();
|
||||
}, [loadMeds]);
|
||||
|
||||
const uploadMedImage = useCallback(async (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);
|
||||
}, [loadMeds]);
|
||||
|
||||
const deleteMedImage = useCallback(async (medId: number) => {
|
||||
await fetch(`/api/medications/${medId}/image`, { method: "DELETE" }).catch(() => null);
|
||||
loadMeds();
|
||||
}, [loadMeds]);
|
||||
|
||||
return {
|
||||
meds,
|
||||
setMeds,
|
||||
loading,
|
||||
saving,
|
||||
setSaving,
|
||||
uploadingImage,
|
||||
loadMeds,
|
||||
deleteMed,
|
||||
uploadMedImage,
|
||||
deleteMedImage,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,240 @@
|
||||
import { useState, useCallback } from "react";
|
||||
import type { Medication, RefillEntry, Coverage, FormState } from "../types";
|
||||
import { getMedTotal } from "../types";
|
||||
|
||||
export interface UseRefillReturn {
|
||||
// Refill state
|
||||
showRefillModal: boolean;
|
||||
setShowRefillModal: React.Dispatch<React.SetStateAction<boolean>>;
|
||||
refillPacks: number;
|
||||
setRefillPacks: React.Dispatch<React.SetStateAction<number>>;
|
||||
refillLoose: number;
|
||||
setRefillLoose: React.Dispatch<React.SetStateAction<number>>;
|
||||
refillSaving: boolean;
|
||||
refillHistory: RefillEntry[];
|
||||
refillHistoryExpanded: boolean;
|
||||
setRefillHistoryExpanded: React.Dispatch<React.SetStateAction<boolean>>;
|
||||
|
||||
// Edit stock (correction) state
|
||||
showEditStockModal: boolean;
|
||||
setShowEditStockModal: React.Dispatch<React.SetStateAction<boolean>>;
|
||||
editStockFullBlisters: number;
|
||||
setEditStockFullBlisters: React.Dispatch<React.SetStateAction<number>>;
|
||||
editStockPartialBlisterPills: number;
|
||||
setEditStockPartialBlisterPills: React.Dispatch<React.SetStateAction<number>>;
|
||||
editStockSaving: boolean;
|
||||
|
||||
// Actions
|
||||
loadRefillHistory: (medId: number) => Promise<void>;
|
||||
submitRefill: (
|
||||
medId: number,
|
||||
editingId: number | null,
|
||||
setForm: React.Dispatch<React.SetStateAction<FormState>>,
|
||||
loadMeds: () => void
|
||||
) => Promise<void>;
|
||||
submitStockCorrection: (
|
||||
medId: number,
|
||||
selectedMed: Medication,
|
||||
loadMeds: () => void
|
||||
) => Promise<void>;
|
||||
openRefillModal: () => void;
|
||||
closeRefillModal: () => void;
|
||||
openEditStockModal: (selectedMed: Medication, coverage: { all: Coverage[] }) => void;
|
||||
closeEditStockModal: () => void;
|
||||
}
|
||||
|
||||
export function useRefill(): UseRefillReturn {
|
||||
// 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<RefillEntry[]>([]);
|
||||
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);
|
||||
|
||||
// Load refill history for a medication
|
||||
const loadRefillHistory = useCallback(async (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
|
||||
const submitRefill = useCallback(async (
|
||||
medId: number,
|
||||
editingId: number | null,
|
||||
setForm: React.Dispatch<React.SetStateAction<FormState>>,
|
||||
loadMeds: () => void
|
||||
) => {
|
||||
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);
|
||||
}, [refillPacks, refillLoose, showRefillModal, loadRefillHistory]);
|
||||
|
||||
// Submit a stock correction - user says how many pills they have RIGHT NOW
|
||||
const submitStockCorrection = useCallback(async (
|
||||
medId: number,
|
||||
selectedMed: Medication,
|
||||
loadMeds: () => void
|
||||
) => {
|
||||
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
|
||||
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;
|
||||
|
||||
// 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 }),
|
||||
});
|
||||
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);
|
||||
}, [editStockFullBlisters, editStockPartialBlisterPills, showEditStockModal]);
|
||||
|
||||
const openRefillModal = useCallback(() => {
|
||||
setShowRefillModal(true);
|
||||
window.history.pushState({ modal: 'refill' }, '');
|
||||
}, []);
|
||||
|
||||
const closeRefillModal = useCallback(() => {
|
||||
if (showRefillModal) {
|
||||
window.history.back();
|
||||
}
|
||||
}, [showRefillModal]);
|
||||
|
||||
const openEditStockModal = useCallback((selectedMed: Medication, coverage: { all: Coverage[] }) => {
|
||||
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' }, '');
|
||||
}, []);
|
||||
|
||||
const closeEditStockModal = useCallback(() => {
|
||||
if (showEditStockModal) {
|
||||
window.history.back();
|
||||
}
|
||||
}, [showEditStockModal]);
|
||||
|
||||
return {
|
||||
showRefillModal,
|
||||
setShowRefillModal,
|
||||
refillPacks,
|
||||
setRefillPacks,
|
||||
refillLoose,
|
||||
setRefillLoose,
|
||||
refillSaving,
|
||||
refillHistory,
|
||||
refillHistoryExpanded,
|
||||
setRefillHistoryExpanded,
|
||||
showEditStockModal,
|
||||
setShowEditStockModal,
|
||||
editStockFullBlisters,
|
||||
setEditStockFullBlisters,
|
||||
editStockPartialBlisterPills,
|
||||
setEditStockPartialBlisterPills,
|
||||
editStockSaving,
|
||||
loadRefillHistory,
|
||||
submitRefill,
|
||||
submitStockCorrection,
|
||||
openRefillModal,
|
||||
closeRefillModal,
|
||||
openEditStockModal,
|
||||
closeEditStockModal,
|
||||
};
|
||||
}
|
||||
@@ -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
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,122 @@
|
||||
// =============================================================================
|
||||
// useShare Hook - Share dialog state and operations
|
||||
// =============================================================================
|
||||
|
||||
import { useCallback, useState } from "react";
|
||||
import type { Medication } from "../types";
|
||||
|
||||
export interface UseShareReturn {
|
||||
showShareDialog: boolean;
|
||||
sharePeople: string[];
|
||||
shareSelectedPerson: string;
|
||||
setShareSelectedPerson: React.Dispatch<React.SetStateAction<string>>;
|
||||
shareSelectedDays: number;
|
||||
setShareSelectedDays: React.Dispatch<React.SetStateAction<number>>;
|
||||
shareGenerating: boolean;
|
||||
shareLink: string | null;
|
||||
setShareLink: React.Dispatch<React.SetStateAction<string | null>>;
|
||||
shareCopied: boolean;
|
||||
setShareCopied: React.Dispatch<React.SetStateAction<boolean>>;
|
||||
openShareDialog: (meds: Medication[]) => void;
|
||||
generateShareLink: () => Promise<void>;
|
||||
copyShareLink: () => void;
|
||||
closeShareDialog: () => void;
|
||||
resetShareDialogState: () => void;
|
||||
}
|
||||
|
||||
export function useShare(): UseShareReturn {
|
||||
const [showShareDialog, setShowShareDialog] = useState(false);
|
||||
const [sharePeople, setSharePeople] = useState<string[]>([]);
|
||||
const [shareSelectedPerson, setShareSelectedPerson] = useState<string>("");
|
||||
const [shareSelectedDays, setShareSelectedDays] = useState<number>(30);
|
||||
const [shareGenerating, setShareGenerating] = useState(false);
|
||||
const [shareLink, setShareLink] = useState<string | null>(null);
|
||||
const [shareCopied, setShareCopied] = useState(false);
|
||||
|
||||
const openShareDialog = useCallback((meds: Medication[]) => {
|
||||
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]);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const generateShareLink = useCallback(async () => {
|
||||
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);
|
||||
}
|
||||
}, [shareSelectedPerson, shareSelectedDays]);
|
||||
|
||||
const copyShareLink = useCallback(() => {
|
||||
if (shareLink) {
|
||||
navigator.clipboard.writeText(shareLink);
|
||||
setShareCopied(true);
|
||||
setTimeout(() => setShareCopied(false), 2000);
|
||||
}
|
||||
}, [shareLink]);
|
||||
|
||||
const closeShareDialog = useCallback(() => {
|
||||
if (showShareDialog) {
|
||||
window.history.back();
|
||||
}
|
||||
}, [showShareDialog]);
|
||||
|
||||
// Internal function to reset share dialog state (called by popstate handler)
|
||||
const resetShareDialogState = useCallback(() => {
|
||||
setShowShareDialog(false);
|
||||
setShareLink(null);
|
||||
setShareCopied(false);
|
||||
}, []);
|
||||
|
||||
return {
|
||||
showShareDialog,
|
||||
sharePeople,
|
||||
shareSelectedPerson,
|
||||
setShareSelectedPerson,
|
||||
shareSelectedDays,
|
||||
setShareSelectedDays,
|
||||
shareGenerating,
|
||||
shareLink,
|
||||
setShareLink,
|
||||
shareCopied,
|
||||
setShareCopied,
|
||||
openShareDialog,
|
||||
generateShareLink,
|
||||
copyShareLink,
|
||||
closeShareDialog,
|
||||
resetShareDialogState
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
// =============================================================================
|
||||
// useTheme Hook - Theme (dark/light mode) state management
|
||||
// =============================================================================
|
||||
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
|
||||
export type Theme = "light" | "dark";
|
||||
|
||||
export interface UseThemeReturn {
|
||||
theme: Theme;
|
||||
toggleTheme: () => void;
|
||||
}
|
||||
|
||||
export function useTheme(): UseThemeReturn {
|
||||
const [theme, setTheme] = useState<Theme>(() => {
|
||||
if (typeof window !== "undefined") {
|
||||
return (localStorage.getItem("theme") as Theme) || "dark";
|
||||
}
|
||||
return "dark";
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
document.documentElement.setAttribute("data-theme", theme);
|
||||
localStorage.setItem("theme", theme);
|
||||
}, [theme]);
|
||||
|
||||
const toggleTheme = useCallback(() => {
|
||||
setTheme((prev) => (prev === "dark" ? "light" : "dark"));
|
||||
}, []);
|
||||
|
||||
return { theme, toggleTheme };
|
||||
}
|
||||
Reference in New Issue
Block a user