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

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

BREAKING: None - all existing functionality preserved
This commit is contained in:
Daniel Volz
2026-01-22 05:38:34 +01:00
committed by GitHub
parent 89edd74de3
commit 8718311876
44 changed files with 7448 additions and 5139 deletions
+8
View File
@@ -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:**
+1
View File
@@ -71,3 +71,4 @@ Thumbs.db
*.local
.cache/
.turbo/
docs/TECH_STACK.md
+2 -8
View File
@@ -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",
+2 -2
View File
@@ -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",
+129 -5123
View File
File diff suppressed because it is too large Load Diff
+152
View File
@@ -0,0 +1,152 @@
import { useState, useEffect } from 'react';
import { useTranslation } from 'react-i18next';
import { FRONTEND_VERSION, GITHUB_URL } from '../App';
interface UpdateCheckResult {
status: 'checking' | 'up-to-date' | 'update-available' | 'error';
latestVersion?: string;
lastChecked?: string;
}
interface AboutModalProps {
isOpen: boolean;
onClose: () => void;
}
export default function AboutModal({ isOpen, onClose }: AboutModalProps) {
const { t } = useTranslation();
const [backendVersion, setBackendVersion] = useState<string | null>(null);
const [updateCheckResult, setUpdateCheckResult] = useState<UpdateCheckResult | null>(null);
// Fetch backend version and cached update result on mount
useEffect(() => {
if (!isOpen) return;
// Fetch backend version
fetch('/api/health')
.then(res => res.json())
.then(data => setBackendVersion(data.version || 'unknown'))
.catch(() => setBackendVersion('unknown'));
// Load cached update check result
const cached = sessionStorage.getItem('updateCheckResult');
if (cached) {
try {
const parsed = JSON.parse(cached);
if (parsed && typeof parsed === 'object') {
setUpdateCheckResult(parsed);
}
} catch {
// ignore
}
}
}, [isOpen]);
async function checkForUpdates() {
setUpdateCheckResult({ status: 'checking' });
try {
const res = await fetch(`https://api.github.com/repos/DanielVolz/medassist-ng/releases/latest`);
if (!res.ok) throw new Error('Failed to fetch');
const data = await res.json();
const latestVersion = (data.tag_name || '').replace(/^v/, '');
const currentVersion = FRONTEND_VERSION.replace(/^v/, '');
const isUpToDate = latestVersion === currentVersion;
const result: UpdateCheckResult = {
status: isUpToDate ? 'up-to-date' : 'update-available',
latestVersion,
lastChecked: new Date().toISOString()
};
setUpdateCheckResult(result);
// Cache the result
sessionStorage.setItem('updateCheckResult', JSON.stringify(result));
} catch {
setUpdateCheckResult({ status: 'error' });
}
}
if (!isOpen) return null;
return (
<div className="modal-overlay" onClick={onClose}>
<div className="modal-content about-modal" onClick={(e) => e.stopPropagation()}>
<button className="modal-close" onClick={onClose}>×</button>
<div className="about-header">
<div className="about-logo">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5">
<path d="M19.5 12c0 4.14-3.36 7.5-7.5 7.5S4.5 16.14 4.5 12 7.86 4.5 12 4.5s7.5 3.36 7.5 7.5z"/>
<path d="M12 8v4l2.5 2.5"/>
<path d="M9 2h6M12 2v2"/>
</svg>
</div>
<h2>{t('about.appName', 'MedAssist')}</h2>
<p className="about-tagline">{t('about.description', 'Personal medication tracking and reminder app')}</p>
</div>
<div className="about-versions">
<div className="about-version-row">
<span className="about-version-label">{t('about.frontendVersion', 'Frontend')}</span>
<span className="about-version-value">{FRONTEND_VERSION}</span>
</div>
<div className="about-version-row">
<span className="about-version-label">{t('about.backendVersion', 'Backend')}</span>
<span className="about-version-value">{backendVersion || '...'}</span>
</div>
</div>
<div className="about-update-section">
<button className="about-update-btn" onClick={checkForUpdates} disabled={updateCheckResult?.status === 'checking'}>
{updateCheckResult?.status === 'checking' ? (
<>
<span className="spinner-small"></span>
{t('about.checking', 'Checking...')}
</>
) : (
<>
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<path d="M21 12a9 9 0 0 0-9-9 9.75 9.75 0 0 0-6.74 2.74L3 8"/>
<path d="M3 3v5h5"/>
<path d="M3 12a9 9 0 0 0 9 9 9.75 9.75 0 0 0 6.74-2.74L21 16"/>
<path d="M16 16h5v5"/>
</svg>
{t('about.checkForUpdates', 'Check for Updates')}
</>
)}
</button>
{updateCheckResult && updateCheckResult.status !== 'checking' && (
<div className={`about-update-result ${updateCheckResult.status}`}>
{updateCheckResult.status === 'up-to-date' && (
<span className="update-status-text"> {t('about.upToDate', 'You are up to date!')}</span>
)}
{updateCheckResult.status === 'update-available' && (
<span className="update-status-text">
{t('about.updateAvailable', 'Update available')}: <strong>v{updateCheckResult.latestVersion}</strong>
<a href={`${GITHUB_URL}/releases/latest`} target="_blank" rel="noopener noreferrer" className="update-download-link">
{t('about.downloadUpdate', 'Download')}
</a>
</span>
)}
{updateCheckResult.status === 'error' && (
<span className="update-status-text"> {t('about.checkFailed', 'Could not check for updates')}</span>
)}
{updateCheckResult.lastChecked && (
<span className="update-last-checked">
{t('about.lastChecked', 'Last checked')}: {new Date(updateCheckResult.lastChecked).toLocaleString()}
</span>
)}
</div>
)}
</div>
<div className="about-links">
<a href={GITHUB_URL} target="_blank" rel="noopener noreferrer" className="about-link">
<svg viewBox="0 0 24 24" fill="currentColor">
<path d="M12 0c-6.626 0-12 5.373-12 12 0 5.302 3.438 9.8 8.207 11.387.599.111.793-.261.793-.577v-2.234c-3.338.726-4.033-1.416-4.033-1.416-.546-1.387-1.333-1.756-1.333-1.756-1.089-.745.083-.729.083-.729 1.205.084 1.839 1.237 1.839 1.237 1.07 1.834 2.807 1.304 3.492.997.107-.775.418-1.305.762-1.604-2.665-.305-5.467-1.334-5.467-5.931 0-1.311.469-2.381 1.236-3.221-.124-.303-.535-1.524.117-3.176 0 0 1.008-.322 3.301 1.23.957-.266 1.983-.399 3.003-.404 1.02.005 2.047.138 3.006.404 2.291-1.552 3.297-1.23 3.297-1.23.653 1.653.242 2.874.118 3.176.77.84 1.235 1.911 1.235 3.221 0 4.609-2.807 5.624-5.479 5.921.43.372.823 1.102.823 2.222v3.293c0 .319.192.694.801.576 4.765-1.589 8.199-6.086 8.199-11.386 0-6.627-5.373-12-12-12z"/>
</svg>
{t('about.viewOnGitHub', 'View on GitHub')}
</a>
</div>
<div className="about-footer">
<p className="about-copyright">{t('about.copyright', '© {{year}} Daniel Volz', { year: new Date().getFullYear() })}</p>
<p className="about-license">{t('about.license', 'GPL-3.0 License')}</p>
</div>
</div>
</div>
);
}
+112
View File
@@ -0,0 +1,112 @@
/**
* AppHeader - Main application header with navigation and user menu
*/
import { useState, useEffect } from "react";
import { useNavigate, useLocation } from "react-router-dom";
import { useTranslation } from "react-i18next";
import { useAuth } from "./Auth";
import { useTheme } from "../hooks";
interface AppHeaderProps {
onOpenProfile: () => void;
onOpenAbout: () => void;
}
export function AppHeader({ onOpenProfile, onOpenAbout }: AppHeaderProps) {
const { t } = useTranslation();
const navigate = useNavigate();
const location = useLocation();
const currentPath = location.pathname;
const { user, authState, logout } = useAuth();
const { theme, toggleTheme } = useTheme();
// User dropdown state (for mobile click-based behavior)
const [userDropdownOpen, setUserDropdownOpen] = useState(false);
// 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]);
// 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 (
<header className="hero">
<div className="hero-title">
<img src="/favicon.svg" alt="MedAssist-ng" className="hero-logo" />
<div>
<p className="eyebrow">{pageInfo.eyebrow}</p>
<h1>{pageInfo.title}</h1>
</div>
</div>
<div className="header-actions">
<div className="tabs">
<button className={currentPath === "/dashboard" || currentPath === "/" ? "pill primary" : "pill"} onClick={() => navigate("/dashboard")}>{t('nav.dashboard')}</button>
<button className={currentPath === "/medications" ? "pill primary" : "pill"} onClick={() => navigate("/medications")}>{t('nav.medications')}</button>
<button className={currentPath === "/planner" ? "pill primary" : "pill"} onClick={() => navigate("/planner")}>{t('nav.planner')}</button>
</div>
{/* Settings button only shown when auth is disabled (no user dropdown available) */}
{!authState?.authEnabled && (
<button className={`icon-btn ${currentPath === "/settings" ? "active" : ""}`} onClick={() => navigate("/settings")} title={t('nav.settings')}></button>
)}
<button className="icon-btn" onClick={toggleTheme} title={theme === "dark" ? t('tooltips.lightMode') : t('tooltips.darkMode')}>
{theme === "dark" ? "☀️" : "🌙"}
</button>
{authState?.authEnabled && user && (
<div className={`user-menu ${userDropdownOpen ? 'open' : ''}`}>
<button className="user-menu-btn" onClick={() => setUserDropdownOpen(!userDropdownOpen)}>
{user.avatarUrl ? (
<img src={`/api/images/${user.avatarUrl}`} alt={user.username} className="user-avatar-img" />
) : (
<span className="user-avatar">{user.username.charAt(0).toUpperCase()}</span>
)}
</button>
<div className="user-dropdown">
<div className="dropdown-header">
{user.avatarUrl ? (
<img src={`/api/images/${user.avatarUrl}`} alt={user.username} className="dropdown-avatar-img" />
) : (
<div className="dropdown-avatar">{user.username.charAt(0).toUpperCase()}</div>
)}
<span className="dropdown-username">{user.username}</span>
</div>
<div className="dropdown-menu">
<button className="dropdown-item" onClick={() => { onOpenProfile(); setUserDropdownOpen(false); }}>
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2"/><circle cx="12" cy="7" r="4"/></svg>
{t('auth.profile', 'Profile')}
</button>
<button className="dropdown-item" onClick={() => { navigate('/settings'); setUserDropdownOpen(false); }}>
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1 0 2.83 2 2 0 0 1-2.83 0l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-2 2 2 2 0 0 1-2-2v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83 0 2 2 0 0 1 0-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1-2-2 2 2 0 0 1 2-2h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 0-2.83 2 2 0 0 1 2.83 0l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 2-2 2 2 0 0 1 2 2v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 0 2 2 0 0 1 0 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 2 2 2 2 0 0 1-2 2h-.09a1.65 1.65 0 0 0-1.51 1z"/></svg>
{t('nav.settings', 'Settings')}
</button>
<button className="dropdown-item" onClick={() => { onOpenAbout(); setUserDropdownOpen(false); }}>
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><circle cx="12" cy="12" r="10"/><path d="M12 16v-4"/><path d="M12 8h.01"/></svg>
{t('about.title', 'About')}
</button>
<button className="dropdown-item danger" onClick={() => { logout(); setUserDropdownOpen(false); }}>
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4"/><polyline points="16 17 21 12 16 7"/><line x1="21" y1="12" x2="9" y2="12"/></svg>
{t('auth.signOut', 'Sign Out')}
</button>
</div>
</div>
</div>
)}
</div>
</header>
);
}
+50
View File
@@ -0,0 +1,50 @@
// =============================================================================
// ConfirmModal Component - Simple confirmation dialog
// =============================================================================
import { ReactNode } from "react";
export interface ConfirmModalProps {
title: string;
message: string | ReactNode;
confirmLabel: string;
cancelLabel: string;
onConfirm: () => void;
onCancel: () => void;
isLoading?: boolean;
confirmVariant?: "primary" | "danger" | "success";
}
export function ConfirmModal({
title,
message,
confirmLabel,
cancelLabel,
onConfirm,
onCancel,
isLoading = false,
confirmVariant = "primary"
}: ConfirmModalProps) {
return (
<div className="modal-overlay" onClick={onCancel}>
<div className="modal-content" onClick={(e) => e.stopPropagation()} style={{ maxWidth: "450px" }}>
<button className="modal-close" onClick={onCancel}>
×
</button>
<h2 style={{ marginBottom: "16px", paddingRight: "2rem" }}>{title}</h2>
<div style={{ marginBottom: "24px" }}>{typeof message === "string" ? <p>{message}</p> : message}</div>
<div
className="modal-footer"
style={{ padding: "1rem 0 0 0", borderTop: "none", justifyContent: "flex-end" }}
>
<button type="button" className="ghost" onClick={onCancel} disabled={isLoading}>
{cancelLabel}
</button>
<button type="button" className={confirmVariant} onClick={onConfirm} disabled={isLoading}>
{confirmLabel}
</button>
</div>
</div>
</div>
);
}
+64
View File
@@ -0,0 +1,64 @@
import { useTranslation } from 'react-i18next';
interface ExportModalProps {
isOpen: boolean;
onClose: () => void;
onExport: (includeImages: boolean) => void;
exporting: boolean;
}
export default function ExportModal({ isOpen, onClose, onExport, exporting }: ExportModalProps) {
const { t } = useTranslation();
if (!isOpen) return null;
return (
<div className="modal-overlay" onClick={onClose}>
<div className="modal-content" onClick={(e) => e.stopPropagation()} style={{maxWidth: "450px"}}>
<button className="modal-close" onClick={onClose}>×</button>
<h2 style={{marginBottom: "16px", paddingRight: "2rem"}}>{t('exportImport.exportOptions')}</h2>
<div style={{display: 'flex', flexDirection: 'column', gap: '12px'}}>
<button
type="button"
className="action-card"
onClick={() => {
onClose();
onExport(true);
}}
disabled={exporting}
style={{textAlign: 'left', cursor: 'pointer', border: '1px solid var(--border)', borderRadius: '8px'}}
>
<div className="action-card-content" style={{flex: 1}}>
<span className="action-card-title">{t('exportImport.exportWithImages')}</span>
<span className="action-card-desc">{t('exportImport.exportWithImagesDesc')}</span>
</div>
</button>
<button
type="button"
className="action-card"
onClick={() => {
onClose();
onExport(false);
}}
disabled={exporting}
style={{textAlign: 'left', cursor: 'pointer', border: '1px solid var(--border)', borderRadius: '8px'}}
>
<div className="action-card-content" style={{flex: 1}}>
<span className="action-card-title">{t('exportImport.exportDataOnly')}</span>
<span className="action-card-desc">{t('exportImport.exportDataOnlyDesc')}</span>
</div>
</button>
</div>
<div className="modal-footer" style={{padding: "1rem 0 0 0", borderTop: "none", justifyContent: "flex-end"}}>
<button
type="button"
className="ghost"
onClick={onClose}
>
{t('exportImport.cancelButton')}
</button>
</div>
</div>
</div>
);
}
+28
View File
@@ -0,0 +1,28 @@
// =============================================================================
// Lightbox Component - Full-screen image viewer
// =============================================================================
import { MouseEvent } from "react";
export interface LightboxProps {
src: string;
alt: string;
onClose: () => void;
}
export function Lightbox({ src, alt, onClose }: LightboxProps) {
function handleOverlayClick(e: MouseEvent) {
if (e.target === e.currentTarget) {
onClose();
}
}
return (
<div className="lightbox-overlay" onClick={handleOverlayClick}>
<button className="lightbox-close" onClick={onClose}>
×
</button>
<img src={src} alt={alt} className="lightbox-image" onClick={(e) => e.stopPropagation()} />
</div>
);
}
+498
View File
@@ -0,0 +1,498 @@
/**
* MedDetailModal - Medication detail view with nested modals
* Displays medication information, stock, schedules, and provides refill/edit functionality
*
* Can work in two modes:
* 1. Context mode: Uses useAppContext() for all state (when no props provided)
* 2. Props mode: Accepts all required data as props (for gradual adoption)
*/
import { useTranslation } from "react-i18next";
import type { Medication, Coverage, RefillEntry, StockThresholds } from "../types";
import { MedicationAvatar, Lightbox } from "../components";
import { getMedTotal, getPackageSize } from "../types";
import { formatNumber, generateICS } from "../utils";
import { getStockStatus } from "../utils/schedule";
// =============================================================================
// Local Helper Functions
// =============================================================================
/**
* Calculate blister stock - divides current pills into full blisters and partial
*/
function getBlisterStock(
currentPills: number,
pillsPerBlister: number,
_originalLooseTablets: number,
_originalTotalPills: number
): { fullBlisters: number; openBlisterPills: number; loosePills: number } {
if (pillsPerBlister <= 0 || pillsPerBlister === 1) {
return { fullBlisters: 0, openBlisterPills: 0, loosePills: currentPills };
}
const fullBlisters = Math.floor(currentPills / pillsPerBlister);
const openBlisterPills = currentPills % pillsPerBlister;
return { fullBlisters, openBlisterPills, loosePills: 0 };
}
/**
* Format full blisters column
*/
function formatFullBlisters(fullBlisters: number, t: (key: string) => string): string {
if (fullBlisters === 0) return "—";
return `${fullBlisters} ${fullBlisters === 1 ? t("common.blister") : t("common.blisters")}`;
}
/**
* Format open blister column
*/
function formatOpenBlisterAndLoose(
openBlisterPills: number,
_loosePills: number,
pillsPerBlister: number,
t: (key: string) => string
): string {
if (openBlisterPills > 0) {
return `${openBlisterPills} ${t("common.of")} ${pillsPerBlister} ${t("common.pills")}`;
}
return "—";
}
// =============================================================================
// Props Interface
// =============================================================================
export interface MedDetailModalProps {
// Required
selectedMed: Medication | null;
coverage: { all: Coverage[] };
settings: StockThresholds;
// Modal state
showImageLightbox: boolean;
showRefillModal: boolean;
showEditStockModal: boolean;
// Modal actions
onClose: () => void;
onOpenImageLightbox: () => void;
onCloseImageLightbox: () => void;
onOpenRefillModal: () => void;
onCloseRefillModal: () => void;
onOpenEditStockModal: () => void;
onCloseEditStockModal: () => void;
// Refill state
refillPacks: number;
onRefillPacksChange: (value: number) => void;
refillLoose: number;
onRefillLooseChange: (value: number) => void;
refillSaving: boolean;
refillHistory: RefillEntry[];
refillHistoryExpanded: boolean;
onRefillHistoryExpandedChange: (value: boolean) => void;
onSubmitRefill: (medId: number) => Promise<void>;
// Edit stock state
editStockFullBlisters: number;
onEditStockFullBlistersChange: (value: number) => void;
editStockPartialBlisterPills: number;
onEditStockPartialBlisterPillsChange: (value: number) => void;
editStockSaving: boolean;
onSubmitStockCorrection: (medId: number) => Promise<void>;
}
export function MedDetailModal({
selectedMed,
coverage,
settings,
showImageLightbox,
showRefillModal,
showEditStockModal,
onClose,
onOpenImageLightbox,
onCloseImageLightbox,
onOpenRefillModal,
onCloseRefillModal,
onOpenEditStockModal,
onCloseEditStockModal,
refillPacks,
onRefillPacksChange,
refillLoose,
onRefillLooseChange,
refillSaving,
refillHistory,
refillHistoryExpanded,
onRefillHistoryExpandedChange,
onSubmitRefill,
editStockFullBlisters,
onEditStockFullBlistersChange,
editStockPartialBlisterPills,
onEditStockPartialBlisterPillsChange,
editStockSaving,
onSubmitStockCorrection,
}: MedDetailModalProps) {
const { t, i18n } = useTranslation();
if (!selectedMed) return null;
const medCoverage = coverage.all.find((c) => c.name === selectedMed.name);
const packageSize = getPackageSize(selectedMed);
const currentStock = medCoverage ? Math.round(medCoverage.medsLeft) : getMedTotal(selectedMed);
const status = medCoverage ? getStockStatus(medCoverage.daysLeft, medCoverage.medsLeft, settings) : null;
const textClass = status?.className === "danger" ? "danger-text" : status?.className === "warning" ? "warning-text" : "success-text";
const stock = getBlisterStock(currentStock, selectedMed.pillsPerBlister, selectedMed.looseTablets, packageSize);
return (
<div className="modal-overlay" onClick={onClose}>
<div className="modal-content med-detail-modal" onClick={(e) => e.stopPropagation()}>
<button className="modal-close" onClick={onClose}>
×
</button>
<div className="med-detail-body">
{/* Header */}
<div className="med-detail-header">
<div
className={`med-detail-avatar-wrapper ${selectedMed.imageUrl ? "clickable" : ""}`}
onClick={() => selectedMed.imageUrl && onOpenImageLightbox()}
>
<MedicationAvatar name={selectedMed.name} imageUrl={selectedMed.imageUrl} size="lg" />
{selectedMed.imageUrl && <span className="expand-icon">🔍</span>}
</div>
<div className="med-detail-titles">
<h2>{selectedMed.name}</h2>
{selectedMed.genericName && <span className="med-generic-name">{selectedMed.genericName}</span>}
{selectedMed.takenBy && (selectedMed.takenBy || []).length > 0 && (
<span className="med-taken-by">
{t("modal.for")} {selectedMed.takenBy.join(", ")}
</span>
)}
</div>
</div>
{/* Stock Info Section */}
<div className="med-detail-section">
<h3>{t("modal.stockInfo")}</h3>
<div className="med-detail-grid">
<div className="med-detail-item">
<span className="med-detail-label">{t("table.fullBlisters")}</span>
<span className={`med-detail-value ${textClass}`}>{formatFullBlisters(stock.fullBlisters, t)}</span>
</div>
<div className="med-detail-item">
<span className="med-detail-label">{t("table.openBlister")}</span>
<span className={`med-detail-value ${textClass}`}>
{formatOpenBlisterAndLoose(stock.openBlisterPills, stock.loosePills, selectedMed.pillsPerBlister ?? 1, t)}
</span>
</div>
<div className="med-detail-item full-width">
<span className="med-detail-label">{t("modal.currentStock")}</span>
<span className={`med-detail-value ${textClass}`}>
{currentStock} / {packageSize}
</span>
</div>
</div>
</div>
{/* Package Details Section */}
<div className="med-detail-section">
<h3>{t("modal.packageDetails")}</h3>
<div className="med-detail-grid">
<div className="med-detail-item">
<span className="med-detail-label">{t("modal.packs")}</span>
<span className="med-detail-value">{selectedMed.packCount}</span>
</div>
<div className="med-detail-item">
<span className="med-detail-label">{t("modal.blistersPerPack")}</span>
<span className="med-detail-value">{selectedMed.blistersPerPack}</span>
</div>
<div className="med-detail-item">
<span className="med-detail-label">{t("modal.pillsPerBlister")}</span>
<span className="med-detail-value">{selectedMed.pillsPerBlister}</span>
</div>
{selectedMed.pillWeightMg && (
<div className="med-detail-item">
<span className="med-detail-label">{t("modal.pillWeight")}</span>
<span className="med-detail-value">{selectedMed.pillWeightMg} mg</span>
</div>
)}
{selectedMed.expiryDate && (
<div className="med-detail-item">
<span className="med-detail-label">{t("modal.expiryDate")}</span>
<span className={`med-detail-value ${new Date(selectedMed.expiryDate) < new Date() ? "danger-text" : ""}`}>
{new Date(selectedMed.expiryDate).toLocaleDateString(i18n.language, {
day: "2-digit",
month: "short",
year: "numeric",
})}
</span>
</div>
)}
</div>
</div>
{/* Intake Schedule Section */}
{selectedMed.blisters.length > 0 && (
<div className="med-detail-section">
<h3>
{t("modal.intakeSchedule")}{" "}
{selectedMed.intakeRemindersEnabled && (
<span className="reminder-icon info-tooltip" data-tooltip={t("tooltips.intakeReminders")}>
🔔
</span>
)}
</h3>
<div className="med-detail-schedules">
{selectedMed.blisters.map((blister, idx) => {
const personCount = Math.max(1, selectedMed.takenBy?.length || 1);
const totalUsage = blister.usage * personCount;
return (
<div key={idx} className="med-schedule-item">
<span className="med-schedule-usage">
{totalUsage} {totalUsage !== 1 ? t("common.pills") : t("common.pill")}
{selectedMed.pillWeightMg && ` (${totalUsage * selectedMed.pillWeightMg} mg)`}
</span>
<span className="med-schedule-freq">
{t("form.blisters.every")} {blister.every} {blister.every !== 1 ? t("common.days") : t("common.day")}
</span>
<span className="med-schedule-time">
{t("modal.at")}{" "}
{new Date(blister.start).toLocaleTimeString(i18n.language, {
hour: "2-digit",
minute: "2-digit",
})}
</span>
</div>
);
})}
</div>
</div>
)}
{/* Coverage Status Section */}
{medCoverage && status && (
<div className="med-detail-section">
<h3 className="section-header-with-badge">
{t("modal.coverageStatus")}
<span className={`status-chip small ${status.className}`}>{t(status.label)}</span>
</h3>
<div className="med-detail-grid">
<div className="med-detail-item">
<span className="med-detail-label">{t("modal.daysLeft")}</span>
<span className="med-detail-value">{medCoverage.daysLeft !== null ? formatNumber(medCoverage.daysLeft) : "—"}</span>
</div>
<div className="med-detail-item">
<span className="med-detail-label">{t("modal.runsOut")}</span>
<span className="med-detail-value">{medCoverage.depletionDate ?? "—"}</span>
</div>
</div>
</div>
)}
{/* Notes Section */}
{selectedMed.notes && (
<div className="med-detail-section">
<h3>📝 {t("modal.notes")}</h3>
<div className="med-notes-content">{selectedMed.notes}</div>
</div>
)}
{/* Refill History Section */}
{refillHistory.length > 0 && (
<div className="med-detail-section">
<h3 className="section-header-clickable" onClick={() => onRefillHistoryExpandedChange(!refillHistoryExpanded)}>
{t("refill.history")} ({refillHistory.length})
<span className="expand-arrow">{refillHistoryExpanded ? "▼" : "▶"}</span>
</h3>
{refillHistoryExpanded && (
<div className="refill-history-list">
{refillHistory.map((entry) => (
<div key={entry.id} className="refill-history-item">
<span className="refill-date">
{new Date(entry.refillDate).toLocaleDateString(i18n.language, {
day: "2-digit",
month: "short",
year: "numeric",
})}
,{" "}
{new Date(entry.refillDate).toLocaleTimeString(i18n.language, {
hour: "2-digit",
minute: "2-digit",
})}
</span>
<span className="refill-amount">
+{entry.packsAdded * selectedMed.blistersPerPack * selectedMed.pillsPerBlister + entry.loosePillsAdded}{" "}
{t("common.pills")}
</span>
</div>
))}
</div>
)}
</div>
)}
</div>
{/* Footer */}
<div className="med-detail-footer">
<button onClick={onClose}>{t("common.close")}</button>
<div className="footer-actions">
<button className="success" onClick={onOpenRefillModal}>
{t("refill.button")}
</button>
<button className="info" onClick={onOpenEditStockModal}>
{t("common.edit")}
</button>
{selectedMed.blisters.length > 0 && (
<button className="secondary icon-only" onClick={() => generateICS(selectedMed)} title={t("modal.exportTooltip")}>
📅
</button>
)}
</div>
</div>
</div>
{/* Image Lightbox */}
{showImageLightbox && selectedMed.imageUrl && (
<Lightbox src={`/api/images/${selectedMed.imageUrl}`} alt={selectedMed.name} onClose={onCloseImageLightbox} />
)}
{/* Refill Modal */}
{showRefillModal && (
<div
className="modal-overlay"
onClick={(e) => {
e.stopPropagation();
onCloseRefillModal();
}}
>
<div className="modal-content refill-modal" onClick={(e) => e.stopPropagation()}>
<button className="modal-close" onClick={onCloseRefillModal}>
×
</button>
<h2>{t("refill.title")}</h2>
<p className="refill-med-name">{selectedMed.name}</p>
<div className="refill-form">
<label>
{t("refill.packs")}
<input type="number" min="0" value={refillPacks} onChange={(e) => onRefillPacksChange(parseInt(e.target.value) || 0)} />
</label>
<label>
{t("refill.loosePills")}
<input type="number" min="0" value={refillLoose} onChange={(e) => onRefillLooseChange(parseInt(e.target.value) || 0)} />
</label>
</div>
<div className="modal-footer">
<button className="ghost" onClick={onCloseRefillModal}>
{t("common.cancel")}
</button>
<div className="refill-footer-right">
<button
className="success"
onClick={() => onSubmitRefill(selectedMed.id)}
disabled={(refillPacks < 1 && refillLoose < 1) || refillSaving}
>
{refillSaving ? t("common.saving") : t("refill.button")}
</button>
{(refillPacks > 0 || refillLoose > 0) && (
<span className="refill-preview">
+{refillPacks * selectedMed.blistersPerPack * selectedMed.pillsPerBlister + refillLoose} {t("common.pills")}
</span>
)}
</div>
</div>
</div>
</div>
)}
{/* Edit Stock Modal */}
{showEditStockModal && (
<div
className="modal-overlay"
onClick={(e) => {
e.stopPropagation();
onCloseEditStockModal();
}}
>
<div className="modal-content edit-stock-modal" onClick={(e) => e.stopPropagation()}>
<button className="modal-close" onClick={onCloseEditStockModal}>
×
</button>
<h2>{t("editStock.title")}</h2>
<p className="edit-stock-med-name">{selectedMed.name}</p>
<p className="edit-stock-hint">{t("editStock.hint")}</p>
{(() => {
const dbTotal = getMedTotal(selectedMed);
const currentTotal = medCoverage ? Math.round(medCoverage.medsLeft) : dbTotal;
const newTotal = editStockFullBlisters * selectedMed.pillsPerBlister + editStockPartialBlisterPills;
const difference = newTotal - currentTotal;
return (
<>
<div className="edit-stock-form">
<label>
{t("editStock.fullBlisters")} {t("editStock.pillsPerBlister", { count: selectedMed.pillsPerBlister })}
<input
type="number"
min="0"
value={editStockFullBlisters}
onChange={(e) => onEditStockFullBlistersChange(parseInt(e.target.value) || 0)}
/>
</label>
<label>
{t("editStock.partialBlisterPills")}
<input
type="number"
min={editStockFullBlisters > 0 ? -(selectedMed.pillsPerBlister - 1) : 0}
max={selectedMed.pillsPerBlister}
value={editStockPartialBlisterPills}
onChange={(e) => {
const val = parseInt(e.target.value) || 0;
const min = editStockFullBlisters > 0 ? -(selectedMed.pillsPerBlister - 1) : 0;
const max = selectedMed.pillsPerBlister;
onEditStockPartialBlisterPillsChange(Math.max(min, Math.min(val, max)));
}}
/>
</label>
</div>
<div className="edit-stock-summary">
<div className="summary-row">
<span>{t("editStock.currentTotal")}:</span>
<span>
{currentTotal} {t("common.pills")}
</span>
</div>
<div className="summary-row">
<span>{t("editStock.newTotal")}:</span>
<span>
{newTotal} {t("common.pills")}
</span>
</div>
<div className={`summary-row difference ${difference > 0 ? "positive" : difference < 0 ? "negative" : ""}`}>
<span>{t("editStock.difference")}:</span>
<span>
{difference > 0 ? "+" : ""}
{difference} {t("common.pills")}
</span>
</div>
</div>
</>
);
})()}
<div className="modal-footer">
<button className="ghost" onClick={onCloseEditStockModal}>
{t("common.cancel")}
</button>
<button
className="info"
onClick={() => onSubmitStockCorrection(selectedMed.id)}
disabled={editStockSaving}
>
{editStockSaving ? t("editStock.saving") : t("editStock.save")}
</button>
</div>
</div>
</div>
)}
</div>
);
}
@@ -0,0 +1,19 @@
// =============================================================================
// MedicationAvatar Component
// =============================================================================
export type MedicationAvatarProps = {
name: string;
imageUrl?: string | null;
size?: "sm" | "md" | "lg";
};
export function MedicationAvatar({ name, imageUrl, size = "sm" }: MedicationAvatarProps) {
const initials = name.split(" ").map(w => w[0]).join("").toUpperCase().slice(0, 2) || "?";
const sizeClass = `med-avatar med-avatar-${size}`;
if (imageUrl) {
return <img src={`/api/images/${imageUrl}`} alt={name} className={sizeClass} />;
}
return <div className={`${sizeClass} med-avatar-initials`}>{initials}</div>;
}
+341
View File
@@ -0,0 +1,341 @@
/**
* MobileEditModal - Full-screen edit form for medications (mobile-optimized)
* Handles new medication creation and editing existing medications
*/
import { useTranslation } from "react-i18next";
import type { Medication, FormState, FormBlister, FieldErrors } from "../types";
// Field limits for validation
const FIELD_LIMITS = {
name: { max: 100 },
genericName: { max: 100 },
takenBy: { max: 50 },
notes: { max: 1000 },
};
export interface MobileEditModalProps {
show: boolean;
editingId: number | null;
form: FormState;
onFormChange: (form: FormState) => void;
fieldErrors: FieldErrors;
saving: boolean;
formSaved: boolean;
formChanged: boolean;
hasValidationErrors: boolean;
// TakenBy tag input
takenByInput: string;
onTakenByInputChange: (value: string) => void;
existingPeople: string[];
onAddTakenByPerson: (person: string) => void;
onRemoveTakenByPerson: (person: string) => void;
onTakenByKeyDown: (e: React.KeyboardEvent<HTMLInputElement>) => void;
// Blister helpers
onSetBlisterValue: (idx: number, field: keyof FormBlister, value: string) => void;
onAddBlister: () => void;
onRemoveBlister: (idx: number) => void;
// Value change handler for numeric fields
onHandleValueChange: <K extends keyof FormState>(field: K, value: string) => void;
// Refill state (for edit mode)
refillPacks: number;
onRefillPacksChange: (value: number) => void;
refillLoose: number;
onRefillLooseChange: (value: number) => void;
refillSaving: boolean;
onSubmitRefill: (medId: number) => Promise<void>;
// Image handling
meds: Medication[];
onUploadMedImage: (medId: number, file: File) => Promise<void>;
onDeleteMedImage: (medId: number) => Promise<void>;
// Actions
onClose: () => void;
onResetForm: () => void;
onSaveMedication: (e: React.FormEvent) => void;
}
function deriveTotal(form: FormState) {
const packCount = Number(form.packCount) || 0;
const blistersPerPack = Number(form.blistersPerPack) || 0;
const pillsPerBlister = Number(form.pillsPerBlister) || 1;
const looseTablets = Number(form.looseTablets) || 0;
return packCount * blistersPerPack * pillsPerBlister + looseTablets;
}
export function MobileEditModal({
show,
editingId,
form,
onFormChange,
fieldErrors,
saving,
formSaved,
formChanged,
hasValidationErrors,
takenByInput,
onTakenByInputChange,
existingPeople,
onAddTakenByPerson,
onRemoveTakenByPerson,
onTakenByKeyDown,
onSetBlisterValue,
onAddBlister,
onRemoveBlister,
onHandleValueChange,
refillPacks,
onRefillPacksChange,
refillLoose,
onRefillLooseChange,
refillSaving,
onSubmitRefill,
meds,
onUploadMedImage,
onDeleteMedImage,
onClose,
onResetForm,
onSaveMedication,
}: MobileEditModalProps) {
const { t } = useTranslation();
if (!show) return null;
const currentMed = editingId ? meds.find((m) => m.id === editingId) : null;
return (
<div className="modal-overlay" onClick={onClose}>
<div className="modal-content edit-modal" onClick={(e) => e.stopPropagation()}>
<button
className="modal-close"
onClick={() => {
onClose();
onResetForm();
}}
>
×
</button>
<div className="edit-modal-header">
<h2>{editingId ? t("form.editEntry") : t("form.newEntry")}</h2>
</div>
<form className="form-grid mobile-edit-form" onSubmit={onSaveMedication}>
<label className={`full ${fieldErrors.name ? "has-error" : ""}`}>
{t("form.commercialName")}
<input
value={form.name}
onChange={(e) => onFormChange({ ...form, name: e.target.value })}
placeholder={t("form.placeholders.commercial")}
maxLength={FIELD_LIMITS.name.max}
required
/>
{fieldErrors.name && <span className="field-error">{fieldErrors.name}</span>}
</label>
<label className={`full ${fieldErrors.genericName ? "has-error" : ""}`}>
{t("form.genericName")}
<input
value={form.genericName}
onChange={(e) => onFormChange({ ...form, genericName: e.target.value })}
placeholder={t("form.placeholders.generic")}
maxLength={FIELD_LIMITS.genericName.max}
/>
{fieldErrors.genericName && <span className="field-error">{fieldErrors.genericName}</span>}
</label>
<label className={`full ${fieldErrors.takenBy ? "has-error" : ""}`}>
{t("form.takenBy")}
<div className="tag-input-container">
{form.takenBy.map((person) => (
<span key={person} className="tag">
{person}
<button type="button" className="tag-remove" onClick={() => onRemoveTakenByPerson(person)}>
×
</button>
</span>
))}
<input
value={takenByInput}
onChange={(e) => onTakenByInputChange(e.target.value)}
onKeyDown={onTakenByKeyDown}
onBlur={() => {
if (takenByInput.trim()) onAddTakenByPerson(takenByInput);
}}
placeholder={form.takenBy.length === 0 ? t("form.placeholders.takenBy") : t("form.placeholders.addPerson")}
maxLength={FIELD_LIMITS.takenBy.max}
list="takenby-suggestions-modal"
/>
<datalist id="takenby-suggestions-modal">
{existingPeople
.filter((p) => !form.takenBy.includes(p))
.map((person) => (
<option key={person} value={person} />
))}
</datalist>
</div>
{fieldErrors.takenBy && <span className="field-error">{fieldErrors.takenBy}</span>}
</label>
<label>
{t("form.packs")}
<input type="number" min="0" value={form.packCount} onChange={(e) => onHandleValueChange("packCount", e.target.value)} />
</label>
<label>
{t("form.blistersPerPack")}
<input type="number" min="0" value={form.blistersPerPack} onChange={(e) => onHandleValueChange("blistersPerPack", e.target.value)} />
</label>
<label>
{t("form.pillsPerBlister")}
<input type="number" min="1" value={form.pillsPerBlister} onChange={(e) => onHandleValueChange("pillsPerBlister", e.target.value)} />
</label>
<label>
{t("form.loosePills")}
<input type="number" min="0" value={form.looseTablets} onChange={(e) => onHandleValueChange("looseTablets", e.target.value)} />
</label>
<div className="full">
<p className="sub">
<strong>{t("form.total")}:</strong> {deriveTotal(form)} {t("common.pills")}
</p>
</div>
<label className="full">
{t("form.pillWeight")}
<input
type="number"
min="0"
step="0.1"
value={form.pillWeightMg}
onChange={(e) => onFormChange({ ...form, pillWeightMg: e.target.value })}
placeholder={t("form.placeholders.weight")}
/>
</label>
<label className="full">
{t("form.expiryDate")}
<input type="date" value={form.expiryDate} onChange={(e) => onFormChange({ ...form, expiryDate: e.target.value })} />
</label>
{/* Refill section - only shown when editing (mobile) */}
{editingId && (
<div className="full refill-section">
<h4 className="refill-title">{t("refill.title")}</h4>
<div className="refill-form-inline">
<label>
{t("refill.packs")}
<input type="number" min="0" value={refillPacks} onChange={(e) => onRefillPacksChange(parseInt(e.target.value) || 0)} />
</label>
<label>
{t("refill.loosePills")}
<input type="number" min="0" value={refillLoose} onChange={(e) => onRefillLooseChange(parseInt(e.target.value) || 0)} />
</label>
<button
type="button"
className="success"
onClick={() => onSubmitRefill(editingId)}
disabled={(refillPacks < 1 && refillLoose < 1) || refillSaving}
>
{refillSaving ? t("common.saving") : t("refill.button")}
</button>
{(refillPacks > 0 || refillLoose > 0) && (
<span className="refill-preview">
+{refillPacks * Number(form.blistersPerPack || 0) * Number(form.pillsPerBlister || 1) + refillLoose} {t("common.pills")}
</span>
)}
</div>
</div>
)}
<label className={`full ${fieldErrors.notes ? "has-error" : ""}`}>
{t("form.notes")}
<textarea
value={form.notes}
onChange={(e) => onFormChange({ ...form, notes: e.target.value })}
placeholder={t("form.placeholders.notes")}
rows={2}
maxLength={FIELD_LIMITS.notes.max}
className="auto-resize"
onInput={(e) => {
const target = e.target as HTMLTextAreaElement;
target.style.height = "auto";
target.style.height = target.scrollHeight + "px";
}}
/>
{form.notes.length > 0 && (
<span className={`char-count ${form.notes.length > FIELD_LIMITS.notes.max * 0.9 ? "warning" : ""}`}>
{t("common.validation.tooLong", { current: form.notes.length, max: FIELD_LIMITS.notes.max })}
</span>
)}
{fieldErrors.notes && <span className="field-error">{fieldErrors.notes}</span>}
</label>
{editingId && currentMed?.imageUrl ? (
<div className="full image-field">
<span className="field-label">{t("form.medicationImage")}</span>
<div className="image-preview">
<img src={currentMed.imageUrl} alt={currentMed.name} />
<button type="button" className="danger" onClick={() => onDeleteMedImage(editingId)}>
{t("form.removeImage")}
</button>
</div>
</div>
) : editingId ? (
<label className="full">
{t("form.medicationImage")}
<input type="file" accept="image/*" onChange={(e) => e.target.files?.[0] && onUploadMedImage(editingId, e.target.files[0])} />
</label>
) : null}
<fieldset className="full blister-section">
<legend>
{t("form.blisters.title")}
<label className="toggle-switch small" title={t("form.blisters.remindTooltip")}>
<input
type="checkbox"
checked={form.intakeRemindersEnabled}
onChange={(e) => onFormChange({ ...form, intakeRemindersEnabled: e.target.checked })}
/>
<span className="toggle-slider"></span>
</label>
<span className="legend-hint">{t("form.blisters.remind")}</span>
</legend>
{form.blisters.map((b, idx) => (
<div key={idx} className="blister-row">
<label className="compact">
<span>{t("form.blisters.usage")}</span>
<input type="number" min="0.5" step="0.5" value={b.usage} onChange={(e) => onSetBlisterValue(idx, "usage", e.target.value)} />
</label>
<label className="compact">
<span>{t("form.blisters.everyDays")}</span>
<input type="number" min="1" value={b.every} onChange={(e) => onSetBlisterValue(idx, "every", e.target.value)} />
</label>
<label className="compact full-row">
<span>{t("form.blisters.startDate")}</span>
<input type="date" value={b.startDate} onChange={(e) => onSetBlisterValue(idx, "startDate", e.target.value)} />
</label>
<label className="compact time-label">
<span>{t("form.blisters.startTime")}</span>
<input type="time" value={b.startTime} onChange={(e) => onSetBlisterValue(idx, "startTime", e.target.value)} />
</label>
{form.blisters.length > 1 && (
<button type="button" className="danger remove-blister-btn" onClick={() => onRemoveBlister(idx)}>
{t("common.remove")}
</button>
)}
</div>
))}
<button type="button" className="ghost add-blister" onClick={onAddBlister}>
+ {t("form.blisters.addIntake")}
</button>
</fieldset>
<div className="modal-footer">
<button
type="button"
className="ghost"
onClick={() => {
onClose();
onResetForm();
}}
>
{t("common.cancel")}
</button>
<button type="submit" disabled={saving || hasValidationErrors || (!formChanged && (formSaved || !!editingId))}>
{saving ? t("common.saving") : formSaved && !formChanged ? t("common.saved") : t("common.save")}
</button>
</div>
</form>
</div>
</div>
);
}
+22
View File
@@ -0,0 +1,22 @@
import { useTranslation } from 'react-i18next';
import { UserProfile } from './Auth';
interface ProfileModalProps {
isOpen: boolean;
onClose: () => void;
}
export default function ProfileModal({ isOpen, onClose }: ProfileModalProps) {
const { t } = useTranslation();
if (!isOpen) return null;
return (
<div className="modal-overlay" onClick={onClose}>
<div className="modal-content profile-modal" onClick={(e) => e.stopPropagation()}>
<button className="modal-close" onClick={onClose}>×</button>
<UserProfile onClose={onClose} />
</div>
</div>
);
}
+124
View File
@@ -0,0 +1,124 @@
/**
* ShareDialog - Modal for generating share links for medication schedules
* Allows sharing schedule view for a specific person
*/
import { useTranslation } from "react-i18next";
export interface ShareDialogProps {
show: boolean;
sharePeople: string[];
shareSelectedPerson: string;
onShareSelectedPersonChange: (person: string) => void;
shareSelectedDays: number;
onShareSelectedDaysChange: (days: number) => void;
shareGenerating: boolean;
shareLink: string | null;
onShareLinkChange: (link: string | null) => void;
shareCopied: boolean;
onShareCopiedChange: (copied: boolean) => void;
onClose: () => void;
onGenerateShareLink: () => Promise<void>;
onCopyShareLink: () => void;
}
export function ShareDialog({
show,
sharePeople,
shareSelectedPerson,
onShareSelectedPersonChange,
shareSelectedDays,
onShareSelectedDaysChange,
shareGenerating,
shareLink,
onShareLinkChange,
shareCopied,
onShareCopiedChange,
onClose,
onGenerateShareLink,
onCopyShareLink,
}: ShareDialogProps) {
const { t } = useTranslation();
if (!show) return null;
return (
<div className="modal-overlay" onClick={onClose}>
<div className="modal-content share-dialog-modal" onClick={(e) => e.stopPropagation()}>
<button className="modal-close" onClick={onClose}>
×
</button>
<div className="share-dialog-header">
<h2>🔗 {t("share.title")}</h2>
<p className="share-dialog-description">{t("share.description")}</p>
</div>
{sharePeople.length === 0 ? (
<div className="share-dialog-empty">
<p>{t("share.noPeople")}</p>
</div>
) : shareLink ? (
<div className="share-dialog-result">
<p className="share-success">{t("share.linkGenerated")}</p>
<div className="share-link-box">
<input
type="text"
value={shareLink}
readOnly
className="share-link-input"
onClick={(e) => (e.target as HTMLInputElement).select()}
/>
<button className="btn-copy" onClick={onCopyShareLink}>
{shareCopied ? "✓" : "📋"}
</button>
</div>
{shareCopied && <span className="share-copied-hint">{t("share.copied")}</span>}
<div className="share-dialog-footer">
<button
className="ghost"
onClick={() => {
onShareLinkChange(null);
onShareCopiedChange(false);
}}
>
{t("share.generateAnother")}
</button>
<button onClick={onClose}>{t("common.close")}</button>
</div>
</div>
) : (
<div className="share-dialog-form">
<div className="form-group">
<label>{t("share.selectPerson")}</label>
<select value={shareSelectedPerson} onChange={(e) => onShareSelectedPersonChange(e.target.value)}>
{sharePeople.map((person) => (
<option key={person} value={person}>
{person}
</option>
))}
</select>
</div>
<div className="form-group">
<label>{t("share.selectPeriod")}</label>
<select value={shareSelectedDays} onChange={(e) => onShareSelectedDaysChange(Number(e.target.value))}>
<option value={30}>{t("dashboard.schedules.1month")}</option>
<option value={90}>{t("dashboard.schedules.3months")}</option>
<option value={180}>{t("dashboard.schedules.6months")}</option>
</select>
</div>
<div className="share-dialog-footer">
<button className="ghost" onClick={onClose}>
{t("common.cancel")}
</button>
<button onClick={onGenerateShareLink} disabled={shareGenerating || !shareSelectedPerson}>
{shareGenerating ? t("share.generating") : t("share.generateLink")}
</button>
</div>
</div>
)}
</div>
</div>
);
}
+823
View File
@@ -0,0 +1,823 @@
// =============================================================================
// SharedSchedule Component - Public view for shared schedules
// =============================================================================
import { useEffect, useMemo, useState } from "react";
import { useParams } from "react-router-dom";
import { useTranslation } from "react-i18next";
import type { SharedScheduleData, ExpiredLinkData } from "../types";
import { getMedTotal } from "../types";
import { loadCollapsedDaysFromStorage } from "../utils/storage";
import { MedicationAvatar } from "./MedicationAvatar";
export function SharedSchedule() {
const { token } = useParams<{ token: string }>();
const { t, i18n } = useTranslation();
const [data, setData] = useState<SharedScheduleData | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [expiredData, setExpiredData] = useState<ExpiredLinkData | null>(null);
const [takenDoses, setTakenDoses] = useState<Set<string>>(new Set());
const [lightboxImage, setLightboxImage] = useState<{ url: string; name: string } | null>(null);
const [showPastDays, setShowPastDays] = useState(false);
const [theme, setTheme] = useState<"light" | "dark">(() => {
if (typeof window !== "undefined") {
return (localStorage.getItem("theme") as "light" | "dark") || "dark";
}
return "dark";
});
// Apply theme to document
useEffect(() => {
document.documentElement.setAttribute("data-theme", theme);
localStorage.setItem("theme", theme);
}, [theme]);
function toggleTheme() {
setTheme((prev) => (prev === "dark" ? "light" : "dark"));
}
// Collapsed days state for SharedSchedule (token-specific localStorage)
const [manuallyCollapsedDays, setManuallyCollapsedDays] = useState<Set<string>>(new Set());
const [manuallyExpandedDays, setManuallyExpandedDays] = useState<Set<string>>(new Set());
// Load collapsed/expanded state from localStorage
useEffect(() => {
if (token && typeof window !== "undefined") {
const { collapsed, expanded } = loadCollapsedDaysFromStorage(
`share_${token}_collapsedDays`,
`share_${token}_expandedDays`
);
setManuallyCollapsedDays(collapsed);
setManuallyExpandedDays(expanded);
}
}, [token]);
// Toggle day collapse/expand for SharedSchedule
function toggleDayCollapse(dateStr: string, isAutoCollapsed: boolean) {
if (isAutoCollapsed) {
setManuallyExpandedDays((prev) => {
const next = new Set(prev);
if (next.has(dateStr)) {
next.delete(dateStr);
} else {
next.add(dateStr);
}
if (token) localStorage.setItem(`share_${token}_expandedDays`, JSON.stringify([...next]));
return next;
});
} else {
setManuallyCollapsedDays((prev) => {
const next = new Set(prev);
if (next.has(dateStr)) {
next.delete(dateStr);
} else {
next.add(dateStr);
}
if (token) localStorage.setItem(`share_${token}_collapsedDays`, JSON.stringify([...next]));
return next;
});
}
}
// Helper functions for lightbox with history support (mobile back swipe)
function openLightbox(url: string, name: string) {
setLightboxImage({ url, name });
window.history.pushState({ modal: "lightbox" }, "");
}
function closeLightbox() {
if (lightboxImage) {
window.history.back();
}
}
// Close lightbox on Escape key
useEffect(() => {
function handleKeyDown(e: KeyboardEvent) {
if (e.key === "Escape" && lightboxImage) {
closeLightbox();
}
}
window.addEventListener("keydown", handleKeyDown);
return () => window.removeEventListener("keydown", handleKeyDown);
}, [lightboxImage]);
// Handle browser back button to close lightbox
useEffect(() => {
function handlePopState() {
if (lightboxImage) {
setLightboxImage(null);
}
}
window.addEventListener("popstate", handlePopState);
return () => window.removeEventListener("popstate", handlePopState);
}, [lightboxImage]);
// Load taken doses from server with polling for real-time sync
useEffect(() => {
if (token) {
async function loadTakenDoses() {
try {
const res = await fetch(`/api/share/${token}/doses`);
if (res.ok) {
const data = await res.json();
setTakenDoses(new Set(data.doses.map((d: { doseId: string }) => d.doseId)));
} else {
setTakenDoses(new Set());
}
} catch {
setTakenDoses(new Set());
}
}
loadTakenDoses();
// Poll for updates every 5 seconds (real-time sync with dashboard)
const interval = setInterval(loadTakenDoses, 5000);
return () => clearInterval(interval);
}
}, [token]);
// 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/share/${token}/doses`, {
method: "POST",
headers: { "Content-Type": "application/json" },
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/share/${token}/doses/${encodeURIComponent(doseId)}`, {
method: "DELETE"
});
} catch {
// Revert on error
setTakenDoses((prev) => {
const next = new Set(prev);
next.add(doseId);
return next;
});
}
}
useEffect(() => {
async function fetchData() {
if (!token) {
setError("Invalid link");
setLoading(false);
return;
}
try {
const res = await fetch(`/api/share/${token}`);
if (res.ok) {
const json = await res.json();
setData(json);
} else if (res.status === 410) {
// Link expired - get owner info
const json = await res.json();
setExpiredData({
ownerUsername: json.ownerUsername,
takenBy: json.takenBy,
expiredAt: json.expiredAt
});
} else if (res.status === 404) {
setError(t("share.notFound"));
} else {
setError(t("share.error"));
}
} catch {
setError(t("share.error"));
} finally {
setLoading(false);
}
}
fetchData();
}, [token, t]);
// Build schedule from medications - matches buildSchedulePreview logic exactly
const schedule = useMemo(() => {
if (!data) return [];
// Use same logic as buildSchedulePreview in main app
const now = new Date();
const todayStart = new Date(now.getFullYear(), now.getMonth(), now.getDate()); // Midnight today
// Use 180 days horizon like main app (scheduleDays only limits futureDays display)
const end = new Date();
end.setDate(end.getDate() + 180);
const doses: {
id: string;
when: number;
medName: string;
usage: number;
timeStr: string;
isPast: boolean;
takenBy: string[];
dateStr: string;
}[] = [];
for (const med of data.medications) {
med.blisters.forEach((blister, blisterIdx) => {
const startDate = new Date(blister.start);
if (Number.isNaN(startDate.getTime())) return;
// Use the same iteration method as buildSchedulePreview (setDate instead of adding ms)
// This ensures identical timestamps even across DST changes
for (let d = new Date(startDate); d <= end; d.setDate(d.getDate() + blister.every)) {
const t = d.getTime();
const isPast = d < todayStart;
// Generate dose ID matching Dashboard format: ${med.id}-${blisterIdx}-${whenMs}
const doseId = `${med.id}-${blisterIdx}-${t}`;
doses.push({
id: doseId,
when: t,
medName: med.name,
usage: blister.usage,
isPast,
takenBy: med.takenBy || [],
timeStr: d.toLocaleTimeString(i18n.language, { hour: "2-digit", minute: "2-digit" }),
dateStr: d.toLocaleDateString(i18n.language, { weekday: "short", day: "2-digit", month: "short" })
});
}
});
}
doses.sort((a, b) => a.when - b.when);
// Group by date - matches groupedSchedule logic in main app
type DoseInfo = (typeof doses)[number];
const days = new Map<
string,
{
dateStr: string;
date: Date;
isPast: boolean;
meds: Map<string, { medName: string; total: number; doses: DoseInfo[]; lastWhen: number }>;
}
>();
for (const dose of doses.slice(0, 2000)) {
const day = days.get(dose.dateStr) ?? { dateStr: dose.dateStr, date: new Date(dose.when), isPast: dose.isPast, meds: new Map() };
const medEntry = day.meds.get(dose.medName) ?? { medName: dose.medName, total: 0, doses: [], lastWhen: dose.when };
medEntry.total += dose.usage;
medEntry.doses.push(dose);
medEntry.lastWhen = Math.max(medEntry.lastWhen, dose.when);
day.meds.set(dose.medName, medEntry);
days.set(dose.dateStr, day);
}
return Array.from(days.values()).map((d) => ({
dateStr: d.dateStr,
date: d.date,
isPast: d.isPast,
meds: Array.from(d.meds.values())
}));
}, [data, i18n.language]);
// Split into past and future - matches main app logic
const pastDays = useMemo(() => schedule.filter((d) => d.isPast), [schedule]);
// Limit future days by scheduleDays setting (same as main app)
const futureDays = useMemo(() => schedule.filter((d) => !d.isPast).slice(0, data?.scheduleDays ?? 30), [schedule, data?.scheduleDays]);
// Calculate coverage for stock status colors (matches main app logic)
// This needs to account for taken doses and calculate depletion time
const { coverageByMed, depletionByMed } = useMemo(() => {
if (!data) return { coverageByMed: {}, depletionByMed: {} };
const coverage: Record<string, { daysLeft: number | null; medsLeft: number; dailyUsage: number }> = {};
const depletion: Record<string, number | null> = {};
// Calculate total pills taken per medication from takenDoses
// Each person's taken dose counts separately toward pills consumed
const takenByMed: Record<string, number> = {};
for (const dose of schedule.flatMap((d) => d.meds.flatMap((m) => m.doses))) {
// Check all person-specific dose IDs for this dose
const people = (dose.takenBy || []).length > 0 ? dose.takenBy : [null];
for (const person of people) {
const doseId = person ? `${dose.id}-${person}` : dose.id;
if (takenDoses.has(doseId)) {
takenByMed[dose.medName] = (takenByMed[dose.medName] || 0) + dose.usage;
}
}
}
for (const med of data.medications) {
const totalCount = getMedTotal(med);
const taken = takenByMed[med.name] || 0;
const currentCount = Math.max(0, totalCount - taken);
// Calculate daily usage from blisters, multiplied by number of people
const personCount = Math.max(1, med.takenBy?.length || 1);
const dailyUsage = med.blisters.reduce((sum, b) => sum + b.usage / b.every, 0) * personCount;
const daysLeft = dailyUsage > 0 ? currentCount / dailyUsage : null;
coverage[med.name] = { daysLeft, medsLeft: currentCount, dailyUsage };
// Calculate depletion time (when medication will run out)
if (dailyUsage > 0 && currentCount > 0) {
const daysUntilEmpty = currentCount / dailyUsage;
depletion[med.name] = Date.now() + daysUntilEmpty * 24 * 60 * 60 * 1000;
} else if (currentCount <= 0) {
depletion[med.name] = Date.now(); // Already empty
} else {
depletion[med.name] = null; // No usage schedule
}
}
return { coverageByMed: coverage, depletionByMed: depletion };
}, [data, schedule, takenDoses]);
// Stock thresholds from user settings (provided by API) or defaults
const lowStockDays = data?.stockThresholds?.lowStockDays ?? 30;
// Get worst stock status for a day's medications (matches main app logic with depletion)
const getDayStockStatus = (meds: { medName: string; lastWhen: number }[]) => {
const statuses = meds.map((item) => {
const coverage = 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 (!coverage) return "success";
const { daysLeft, medsLeft } = coverage;
// 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 < lowStockDays) return "warning";
// Normal/High stock
return "success";
});
return statuses.includes("danger") ? "danger" : statuses.includes("warning") ? "warning" : "success";
};
if (loading) {
return (
<div className="shared-schedule-page">
<div className="shared-schedule-loading">
<h1>💊 MedAssist</h1>
<p>{t("common.loading")}</p>
</div>
</div>
);
}
if (expiredData) {
return (
<div className="shared-schedule-page">
<div className="shared-schedule-error expired">
<h1>💊 MedAssist</h1>
<div className="expired-icon"></div>
<h2>{t("share.expired.title")}</h2>
<p className="expired-message">{t("share.expired.message", { takenBy: expiredData.takenBy })}</p>
<p className="expired-contact">{t("share.expired.contact", { username: expiredData.ownerUsername })}</p>
<p className="expired-date">{t("share.expired.expiredOn", { date: new Date(expiredData.expiredAt).toLocaleDateString(i18n.language) })}</p>
</div>
</div>
);
}
if (error || !data) {
return (
<div className="shared-schedule-page">
<div className="shared-schedule-error">
<h1>💊 MedAssist</h1>
<p className="error-message">{error || "Unknown error"}</p>
</div>
</div>
);
}
return (
<div className="shared-schedule-page">
<div className="shared-schedule-container">
<header className="shared-schedule-header">
<h1>
💊 {t("share.scheduleFor")} {data.takenBy}
</h1>
<div className="shared-schedule-header-actions">
<button className="icon-btn" onClick={toggleTheme} title={theme === "dark" ? t("tooltips.lightMode") : t("tooltips.darkMode")}>
{theme === "dark" ? "☀️" : "🌙"}
</button>
</div>
<p className="shared-schedule-period">
{t("share.period")}:{" "}
{data.scheduleDays === 30
? t("dashboard.schedules.1month")
: data.scheduleDays === 90
? t("dashboard.schedules.3months")
: t("dashboard.schedules.6months")}
</p>
</header>
<div className="timeline">
{schedule.length === 0 ? (
<p className="shared-schedule-empty">{t("share.noSchedule")}</p>
) : (
<>
{/* Past days toggle */}
{pastDays.length > 0 &&
(() => {
const totalPastDoses = pastDays.flatMap((d) =>
d.meds.flatMap((m) =>
m.doses.flatMap((dose) =>
(dose.takenBy || []).length > 0 ? dose.takenBy.map((p) => `${dose.id}-${p}`) : [dose.id]
)
)
);
const missedPastDoses = totalPastDoses.filter((id) => !takenDoses.has(id)).length;
return (
<div
className={`past-days-toggle ${showPastDays ? "expanded" : ""} ${missedPastDoses > 0 ? "has-missed" : ""}`}
onClick={() => setShowPastDays(!showPastDays)}
>
<span className="past-days-icon">{showPastDays ? "▼" : "▶"}</span>
<span className="past-days-label">
{showPastDays ? t("dashboard.schedules.hidePastDays") : t("dashboard.schedules.showPastDays")}
</span>
<span className="past-days-count">({t("dashboard.schedules.pastDaysCount", { count: pastDays.length })})</span>
{missedPastDoses > 0 ? (
<span className="past-days-warning" title={t("dashboard.schedules.missedDoses", { count: missedPastDoses })}>
{missedPastDoses}
</span>
) : totalPastDoses.length > 0 ? (
<span className="past-days-complete" title={t("dashboard.schedules.allTaken")}>
</span>
) : null}
</div>
);
})()}
{/* Past days (when expanded) */}
{showPastDays &&
pastDays.map((day) => {
const allDoseIds = day.meds.flatMap((item) =>
item.doses.flatMap((d) => ((d.takenBy || []).length > 0 ? d.takenBy.map((p) => `${d.id}-${p}`) : [d.id]))
);
const allDayTaken = allDoseIds.length > 0 && allDoseIds.every((id) => takenDoses.has(id));
const takenCount = allDoseIds.filter((id) => takenDoses.has(id)).length;
const isManuallyExpanded = manuallyExpandedDays.has(day.dateStr);
const isCollapsed = !isManuallyExpanded;
// Calculate stock status for this day
const worstStatus = getDayStockStatus(day.meds);
return (
<div
key={day.dateStr}
className={`day-block past ${isCollapsed ? "collapsed" : ""} ${allDayTaken ? "all-taken" : ""} stock-${worstStatus}`}
>
<div
className="day-divider clickable"
onClick={() => toggleDayCollapse(day.dateStr, true)}
title={isCollapsed ? t("common.expand") : t("common.collapse")}
>
<span className="day-collapse-icon">{isCollapsed ? "▶" : "▼"}</span>
<span className="day-date">{day.dateStr}</span>
<span className="day-summary">
{allDayTaken ? (
<span className="day-complete"> {t("dashboard.schedules.allTaken")}</span>
) : (
<>
<span className="day-warning" title={t("dashboard.schedules.missedDoses", { count: allDoseIds.length - takenCount })}>
</span>
<span className="day-progress">
{takenCount}/{allDoseIds.length}
</span>
</>
)}
</span>
</div>
{!isCollapsed &&
day.meds.map((item) => {
const med = data.medications.find((m) => m.name === item.medName);
const medCoverage = coverageByMed[item.medName];
const isEmpty = medCoverage ? medCoverage.medsLeft <= 0 : false;
const depletionTime = depletionByMed[item.medName];
const willBeOutOfStock = typeof depletionTime === "number" && item.lastWhen > depletionTime;
// Calculate status for this medication on this day
let status: { className: string; label: string } | null = null;
if (willBeOutOfStock) {
status = { className: "danger", label: "status.outOfStock" };
} else if (medCoverage) {
const { daysLeft, medsLeft } = medCoverage;
if (medsLeft <= 0 || daysLeft === 0) {
status = { className: "danger", label: "status.outOfStock" };
} else if (daysLeft !== null && daysLeft < lowStockDays) {
status = { className: "warning", label: "status.lowStock" };
} else {
status = { className: "success", label: "status.normal" };
}
}
const itemDoseIds = item.doses.flatMap((d) =>
(d.takenBy || []).length > 0 ? d.takenBy.map((p) => `${d.id}-${p}`) : [d.id]
);
const allTaken = itemDoseIds.every((id) => takenDoses.has(id));
return (
<div key={`${day.dateStr}-${item.medName}`} className={`time-row ${allTaken ? "taken" : ""}`}>
<div className="time-main">
<div className="med-name">
<span
className={med?.imageUrl ? "clickable" : ""}
onClick={() => med?.imageUrl && openLightbox(med.imageUrl, med.name)}
>
<MedicationAvatar name={item.medName} imageUrl={med?.imageUrl} size="sm" />
</span>
<span className="med-name-text">{item.medName}</span>
{med?.genericName && <span className="med-generic-inline">({med.genericName})</span>}
</div>
<div className="tag-row">
<span className="tag subtle">
{item.total} {t("common.pills")} {t("common.total")}
</span>
{status && <span className={`tag ${status.className}`}>{t(status.label)}</span>}
</div>
</div>
<div className="doses-col">
{item.doses.map((dose) => {
const people = (dose.takenBy || []).length > 0 ? dose.takenBy : [null];
const allTaken = people.every((person) => takenDoses.has(getDoseId(dose.id, person)));
return (
<div key={dose.id} className={`dose-item past ${allTaken ? "all-taken" : ""}`}>
<span className="dose-time">{dose.timeStr}</span>
<span className="dose-usage">
{dose.usage} {dose.usage !== 1 ? t("common.pills") : t("common.pill")}
{med?.pillWeightMg && ` (${dose.usage * med.pillWeightMg} mg)`}
</span>
<div className="dose-checks">
{people.map((person) => {
const doseId = getDoseId(dose.id, person);
const isTaken = takenDoses.has(doseId);
return (
<div key={doseId} className={`dose-person ${isTaken ? "taken" : ""}`}>
{person && <span className="person-name">{person}</span>}
{isTaken ? (
<button
className="dose-btn undo"
onClick={() => undoDoseTaken(doseId)}
title={t("common.undo")}
>
</button>
) : (
<button
className="dose-btn take"
onClick={() => markDoseTaken(doseId)}
disabled={isEmpty}
title={t("dose.markAsTaken")}
>
</button>
)}
</div>
);
})}
</div>
</div>
);
})}
</div>
</div>
);
})}
</div>
);
})}
{/* Current and future days */}
{futureDays.map((day) => {
// Check if all doses in this day are taken (auto-collapse)
const allDoseIds = day.meds.flatMap((item) =>
item.doses.flatMap((d) => ((d.takenBy || []).length > 0 ? d.takenBy.map((p) => `${d.id}-${p}`) : [d.id]))
);
const allDayTaken = allDoseIds.length > 0 && allDoseIds.every((id) => takenDoses.has(id));
const takenCount = allDoseIds.filter((id) => takenDoses.has(id)).length;
// Calculate stock status for this day
const worstStatus = getDayStockStatus(day.meds);
// Check if this is today
const today = new Date();
today.setHours(0, 0, 0, 0);
const dayDate = new Date(day.date);
dayDate.setHours(0, 0, 0, 0);
const isToday = dayDate.getTime() === today.getTime();
// Determine if day should be collapsed: only today is expanded by default
const isAutoCollapsed = allDayTaken || !isToday;
const isManuallyExpanded = manuallyExpandedDays.has(day.dateStr);
const isManuallyCollapsed = manuallyCollapsedDays.has(day.dateStr);
const isCollapsed = isAutoCollapsed ? !isManuallyExpanded : isManuallyCollapsed;
return (
<div
key={day.dateStr}
className={`day-block ${isCollapsed ? "collapsed" : ""} ${allDayTaken ? "all-taken" : ""} ${isToday ? "today" : ""} stock-${worstStatus}`}
>
<div
className="day-divider clickable"
onClick={() => toggleDayCollapse(day.dateStr, isAutoCollapsed)}
title={isCollapsed ? t("common.expand") : t("common.collapse")}
>
<span className="day-collapse-icon">{isCollapsed ? "▶" : "▼"}</span>
<span className="day-date">{day.dateStr}</span>
<span className="day-summary">
{allDayTaken ? (
<span className="day-complete"> {t("dashboard.schedules.allTaken")}</span>
) : (
<span className="day-progress">
{takenCount}/{allDoseIds.length}
</span>
)}
</span>
</div>
{!isCollapsed &&
day.meds.map((item) => {
const med = data.medications.find((m) => m.name === item.medName);
const medCoverage = coverageByMed[item.medName];
const isEmpty = medCoverage ? medCoverage.medsLeft <= 0 : false;
const depletionTime = depletionByMed[item.medName];
const willBeOutOfStock = typeof depletionTime === "number" && item.lastWhen > depletionTime;
// Calculate status for this medication on this day
let status: { className: string; label: string } | null = null;
if (willBeOutOfStock) {
status = { className: "danger", label: "status.outOfStock" };
} else if (medCoverage) {
const { daysLeft, medsLeft } = medCoverage;
if (medsLeft <= 0 || daysLeft === 0) {
status = { className: "danger", label: "status.outOfStock" };
} else if (daysLeft !== null && daysLeft < lowStockDays) {
status = { className: "warning", label: "status.lowStock" };
} else {
status = { className: "success", label: "status.normal" };
}
}
const itemDoseIds = item.doses.flatMap((d) =>
(d.takenBy || []).length > 0 ? d.takenBy.map((p) => `${d.id}-${p}`) : [d.id]
);
const allTaken = itemDoseIds.every((id) => takenDoses.has(id));
return (
<div key={`${day.dateStr}-${item.medName}`} className={`time-row ${allTaken ? "taken" : ""}`}>
<div className="time-main">
<div className="med-name">
<span
className={med?.imageUrl ? "clickable" : ""}
onClick={() => med?.imageUrl && openLightbox(med.imageUrl, med.name)}
>
<MedicationAvatar name={item.medName} imageUrl={med?.imageUrl} size="sm" />
</span>
<span className="med-name-text">{item.medName}</span>
{med?.genericName && <span className="med-generic-inline">({med.genericName})</span>}
</div>
<div className="tag-row">
<span className="tag subtle">
{item.total} {t("common.pills")} {t("common.total")}
</span>
{status && <span className={`tag ${status.className}`}>{t(status.label)}</span>}
</div>
</div>
<div className="doses-col">
{item.doses.map((dose) => {
const people = (dose.takenBy || []).length > 0 ? dose.takenBy : [null];
const allTaken = people.every((person) => takenDoses.has(getDoseId(dose.id, person)));
// Only disable doses on future DAYS, not later today
const doseDate = new Date(dose.when);
doseDate.setHours(0, 0, 0, 0);
const todayMidnight = new Date();
todayMidnight.setHours(0, 0, 0, 0);
const isFutureDose = doseDate.getTime() > todayMidnight.getTime();
return (
<div key={dose.id} className={`dose-item ${isFutureDose ? "future" : ""} ${allTaken ? "all-taken" : ""}`}>
<span className="dose-time">{dose.timeStr}</span>
<span className="dose-usage">
{dose.usage} {dose.usage !== 1 ? t("common.pills") : t("common.pill")}
{med?.pillWeightMg && ` (${dose.usage * med.pillWeightMg} mg)`}
</span>
<div className="dose-checks">
{people.map((person) => {
const doseId = getDoseId(dose.id, person);
const isTaken = takenDoses.has(doseId);
const isOverdue = dose.when < Date.now() && !isTaken && !isFutureDose;
return (
<div
key={doseId}
className={`dose-person ${isTaken ? "taken" : ""} ${isOverdue ? "overdue" : ""}`}
>
{person && <span className="person-name">{person}</span>}
{isTaken ? (
<button
className="dose-btn undo"
onClick={() => undoDoseTaken(doseId)}
title={t("common.undo")}
>
</button>
) : (
<button
className="dose-btn take"
onClick={() => markDoseTaken(doseId)}
title={t("dose.markAsTaken")}
disabled={isFutureDose || isEmpty}
>
</button>
)}
</div>
);
})}
</div>
</div>
);
})}
</div>
</div>
);
})}
</div>
);
})}
</>
)}
</div>
<footer className="shared-schedule-footer">
<p>
{t("share.generatedBy")}{" "}
{data?.sharedBy && (
<>
<strong>{data.sharedBy}</strong> ·{" "}
</>
)}
<a href="/">MedAssist</a>
</p>
</footer>
</div>
{/* Image Lightbox */}
{lightboxImage && (
<div className="lightbox-overlay" onClick={closeLightbox}>
<button className="lightbox-close" onClick={closeLightbox}>
×
</button>
<img
src={`/api/images/${lightboxImage.url}`}
alt={lightboxImage.name}
className="lightbox-image"
onClick={(e) => e.stopPropagation()}
/>
</div>
)}
</div>
);
}
+79
View File
@@ -0,0 +1,79 @@
// =============================================================================
// TagInput Component - Reusable tag input with suggestions
// =============================================================================
import { KeyboardEvent } from "react";
export interface TagInputProps {
tags: string[];
inputValue: string;
onInputChange: (value: string) => void;
onAddTag: (tag: string) => void;
onRemoveTag: (tag: string) => void;
suggestions?: string[];
placeholder?: string;
addPlaceholder?: string;
maxLength?: number;
error?: string;
datalistId?: string;
}
export function TagInput({
tags,
inputValue,
onInputChange,
onAddTag,
onRemoveTag,
suggestions = [],
placeholder = "",
addPlaceholder = "",
maxLength,
error,
datalistId = "tag-suggestions"
}: TagInputProps) {
function handleKeyDown(e: KeyboardEvent<HTMLInputElement>) {
if ((e.key === "Enter" || e.key === ",") && inputValue.trim()) {
e.preventDefault();
onAddTag(inputValue);
}
if (e.key === "Backspace" && !inputValue && tags.length > 0) {
onRemoveTag(tags[tags.length - 1]);
}
}
return (
<>
<div className="tag-input-container">
{tags.map((tag) => (
<span key={tag} className="tag">
{tag}
<button type="button" className="tag-remove" onClick={() => onRemoveTag(tag)}>
×
</button>
</span>
))}
<input
value={inputValue}
onChange={(e) => onInputChange(e.target.value)}
onKeyDown={handleKeyDown}
onBlur={() => {
if (inputValue.trim()) onAddTag(inputValue);
}}
placeholder={tags.length === 0 ? placeholder : addPlaceholder}
maxLength={maxLength}
list={datalistId}
/>
{suggestions.length > 0 && (
<datalist id={datalistId}>
{suggestions
.filter((s) => !tags.includes(s))
.map((suggestion) => (
<option key={suggestion} value={suggestion} />
))}
</datalist>
)}
</div>
{error && <span className="field-error">{error}</span>}
</>
);
}
@@ -0,0 +1,87 @@
/**
* UserFilterModal - Shows medications for a specific person (takenBy filter)
* Allows clicking through to medication details
*/
import { useTranslation } from "react-i18next";
import type { Medication, Coverage, StockThresholds } from "../types";
import { MedicationAvatar } from "../components";
import { getMedTotal, getPackageSize } from "../types";
import { formatNumber } from "../utils";
import { getStockStatus } from "../utils/schedule";
export interface UserFilterModalProps {
selectedUser: string | null;
meds: Medication[];
coverage: { all: Coverage[] };
settings: StockThresholds;
onClose: () => void;
onOpenMedDetail: (med: Medication) => void;
}
export function UserFilterModal({
selectedUser,
meds,
coverage,
settings,
onClose,
onOpenMedDetail,
}: UserFilterModalProps) {
const { t } = useTranslation();
if (!selectedUser) return null;
const userMeds = meds.filter((m) => (m.takenBy || []).includes(selectedUser));
return (
<div className="modal-overlay" onClick={onClose}>
<div className="modal-content user-meds-modal" onClick={(e) => e.stopPropagation()}>
<button className="modal-close" onClick={onClose}>
×
</button>
<div className="user-meds-header">
<div className="user-avatar">{selectedUser.charAt(0).toUpperCase()}</div>
<h2>{t("modal.userMedications", { name: selectedUser })}</h2>
</div>
<div className="user-meds-list">
{userMeds.map((med) => {
const medCoverage = coverage.all.find((c) => c.name === med.name);
const status = medCoverage ? getStockStatus(medCoverage.daysLeft, medCoverage.medsLeft, settings) : null;
const packageSize = getPackageSize(med);
const currentStock = medCoverage ? formatNumber(medCoverage.medsLeft) : formatNumber(getMedTotal(med));
return (
<div
key={med.id}
className="user-med-item clickable"
onClick={() => {
onClose();
onOpenMedDetail(med);
}}
>
<MedicationAvatar name={med.name} imageUrl={med.imageUrl} size="sm" />
<div className="user-med-info">
<span className="user-med-name">{med.name}</span>
{med.genericName && <span className="user-med-generic">{med.genericName}</span>}
</div>
<div className="user-med-stats">
<span className="user-med-pills">
{currentStock}/{formatNumber(packageSize)} {t("common.pills")}
</span>
{status && <span className={`status-chip ${status.className}`}>{t(status.label)}</span>}
</div>
</div>
);
})}
{userMeds.length === 0 && (
<div className="user-meds-empty">{t("modal.noMedsForUser", { name: selectedUser })}</div>
)}
</div>
<div className="user-meds-footer">
<button onClick={onClose}>{t("common.close")}</button>
</div>
</div>
</div>
);
}
+32
View File
@@ -0,0 +1,32 @@
// Components barrel export
export { MedicationAvatar } from "./MedicationAvatar";
export type { MedicationAvatarProps } from "./MedicationAvatar";
export { SharedSchedule } from "./SharedSchedule";
export { TagInput } from "./TagInput";
export type { TagInputProps } from "./TagInput";
export { Lightbox } from "./Lightbox";
export type { LightboxProps } from "./Lightbox";
export { ConfirmModal } from "./ConfirmModal";
export type { ConfirmModalProps } from "./ConfirmModal";
export { MedDetailModal } from "./MedDetailModal";
export type { MedDetailModalProps } from "./MedDetailModal";
export { UserFilterModal } from "./UserFilterModal";
export type { UserFilterModalProps } from "./UserFilterModal";
export { ShareDialog } from "./ShareDialog";
export type { ShareDialogProps } from "./ShareDialog";
export { MobileEditModal } from "./MobileEditModal";
export type { MobileEditModalProps } from "./MobileEditModal";
export { default as ProfileModal } from "./ProfileModal";
export { default as AboutModal } from "./AboutModal";
export { default as ExportModal } from "./ExportModal";
+726
View File
@@ -0,0 +1,726 @@
import React, { createContext, useContext, useMemo, useState, useEffect, useCallback } from "react";
import { useTranslation } from "react-i18next";
import { useAuth } from "../components/Auth";
import {
useDoses,
useCollapsedDays,
useSettings,
useShare,
useMedications,
useRefill,
} from "../hooks";
import type {
Medication,
Coverage,
ScheduleEvent,
} from "../types";
import { buildSchedulePreview, calculateCoverage } from "../utils/schedule";
// =============================================================================
// Types
// =============================================================================
export type DoseInfo = {
id: string;
timeStr: string;
when: number;
usage: number;
takenBy: string[];
};
export type DayMedEntry = {
medName: string;
total: number;
doses: DoseInfo[];
lastWhen: number;
};
export type GroupedDay = {
dateStr: string;
date: Date;
isPast: boolean;
meds: DayMedEntry[];
};
export interface AppContextValue {
// From useMedications
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>;
// From useSettings (selected fields)
settings: ReturnType<typeof useSettings>["settings"];
setSettings: ReturnType<typeof useSettings>["setSettings"];
savedSettings: ReturnType<typeof useSettings>["savedSettings"];
settingsLoading: boolean;
settingsSaving: boolean;
settingsSaved: boolean;
testingEmail: boolean;
testEmailResult: { success: boolean; message: string } | null;
testingShoutrrr: boolean;
testShoutrrrResult: { success: boolean; message: string } | null;
loadSettings: () => void;
saveSettings: (e: React.FormEvent) => Promise<void>;
testEmail: () => Promise<void>;
testShoutrrr: () => Promise<void>;
// From useDoses
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>;
// From useCollapsedDays
manuallyCollapsedDays: Set<string>;
manuallyExpandedDays: Set<string>;
toggleDayCollapse: (dateStr: string, isCurrentlyExpanded: boolean) => void;
// From useShare
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: () => void;
generateShareLink: () => Promise<void>;
copyShareLink: () => void;
closeShareDialog: () => void;
resetShareDialogState: () => void;
// From useRefill
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: ReturnType<typeof useRefill>["refillHistory"];
refillHistoryExpanded: boolean;
setRefillHistoryExpanded: React.Dispatch<React.SetStateAction<boolean>>;
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;
loadRefillHistory: (medId: number) => Promise<void>;
submitRefill: (medId: number, editingId: number | null, setForm: React.Dispatch<React.SetStateAction<any>>, 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;
// Computed values
schedule: { events: ScheduleEvent[] };
coverage: { all: Coverage[]; low: Coverage[] };
coverageByMed: Record<string, Coverage>;
depletionByMed: Record<string, number | null>;
existingPeople: string[];
groupedSchedule: GroupedDay[];
pastDays: GroupedDay[];
futureDays: GroupedDay[];
missedPastDoseIds: string[];
getDayStockStatus: (dayMeds: { medName: string; lastWhen: number }[]) => "success" | "warning" | "danger";
// Schedule UI state
scheduleDays: number;
setScheduleDays: React.Dispatch<React.SetStateAction<number>>;
showPastDays: boolean;
setShowPastDays: React.Dispatch<React.SetStateAction<boolean>>;
// Modal state
selectedMed: Medication | null;
setSelectedMed: React.Dispatch<React.SetStateAction<Medication | null>>;
showImageLightbox: boolean;
setShowImageLightbox: React.Dispatch<React.SetStateAction<boolean>>;
scheduleLightboxImage: string | null;
setScheduleLightboxImage: React.Dispatch<React.SetStateAction<string | null>>;
selectedUser: string | null;
setSelectedUser: React.Dispatch<React.SetStateAction<string | null>>;
// Export/Import state
exporting: boolean;
importing: boolean;
showExportModal: boolean;
setShowExportModal: React.Dispatch<React.SetStateAction<boolean>>;
showImportConfirm: boolean;
setShowImportConfirm: React.Dispatch<React.SetStateAction<boolean>>;
pendingImportData: unknown;
setPendingImportData: React.Dispatch<React.SetStateAction<unknown>>;
importResult: { medications: number; doses: number; shares: number } | null;
setImportResult: React.Dispatch<React.SetStateAction<{ medications: number; doses: number; shares: number } | null>>;
handleExport: (includeImages?: boolean) => Promise<void>;
handleImportFileSelect: (e: React.ChangeEvent<HTMLInputElement>) => void;
handleImportConfirm: () => Promise<void>;
settingsChanged: boolean;
// Modal helpers
openMedDetail: (med: Medication) => void;
closeMedDetail: () => void;
openImageLightbox: () => void;
closeImageLightbox: () => void;
openScheduleLightbox: (imageUrl: string) => void;
closeScheduleLightbox: () => void;
openUserFilter: (person: string) => void;
closeUserFilter: () => void;
}
// =============================================================================
// Context
// =============================================================================
const AppContext = createContext<AppContextValue | null>(null);
// Helper for user-specific localStorage keys
function userStorageKey(userId: number | undefined, key: string): string {
return userId ? `user_${userId}_${key}` : key;
}
// =============================================================================
// Provider
// =============================================================================
export function AppProvider({ children }: { children: React.ReactNode }) {
const { i18n } = useTranslation();
const { user } = useAuth();
// Compose hooks
const medications = useMedications();
const settingsHook = useSettings();
const doses = useDoses();
const collapsed = useCollapsedDays(user?.id);
const share = useShare();
const refill = useRefill();
// Schedule UI state
const [scheduleDays, setScheduleDays] = useState<number>(30);
const [showPastDays, setShowPastDays] = useState(false);
// Modal state
const [selectedMed, setSelectedMed] = useState<Medication | null>(null);
const [showImageLightbox, setShowImageLightbox] = useState(false);
const [scheduleLightboxImage, setScheduleLightboxImage] = useState<string | null>(null);
const [selectedUser, setSelectedUser] = useState<string | null>(null);
// Export/Import state
const [exporting, setExporting] = useState(false);
const [importing, setImporting] = useState(false);
const [showExportModal, setShowExportModal] = useState(false);
const [showImportConfirm, setShowImportConfirm] = useState(false);
const [pendingImportData, setPendingImportData] = useState<unknown>(null);
const [importResult, setImportResult] = useState<{ medications: number; doses: number; shares: number } | null>(null);
// 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);
}
}, [user?.id]);
// Load medications and settings when user changes
useEffect(() => {
medications.loadMeds();
settingsHook.loadSettings();
}, [user?.id]);
// Update selectedMed when meds change (e.g., after refill)
useEffect(() => {
if (selectedMed) {
const updated = medications.meds.find(m => m.id === selectedMed.id);
if (updated && (
updated.packCount !== selectedMed.packCount ||
updated.looseTablets !== selectedMed.looseTablets ||
updated.updatedAt !== selectedMed.updatedAt
)) {
setSelectedMed(updated);
}
}
}, [medications.meds, selectedMed]);
// Computed values
const schedule = useMemo(
() => buildSchedulePreview(medications.meds, i18n.language, true),
[medications.meds, i18n.language]
);
const coverage = useMemo(
() => calculateCoverage(
medications.meds,
schedule.events,
i18n.language,
settingsHook.settings.reminderDaysBefore,
settingsHook.settings.stockCalculationMode,
doses.takenDoses
),
[medications.meds, schedule.events, i18n.language, settingsHook.settings.reminderDaysBefore, settingsHook.settings.stockCalculationMode, doses.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]
);
const existingPeople = useMemo(() => {
const allPeople = medications.meds.flatMap(m => m.takenBy || []);
return [...new Set(allPeople)].filter(Boolean).sort();
}, [medications.meds]);
// Get worst stock status for a day's medications
const getDayStockStatus = useCallback((dayMeds: { medName: string; lastWhen: number }[]): "success" | "warning" | "danger" => {
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 < settingsHook.settings.lowStockDays) return "warning";
// Normal/High stock
return "success";
});
return statuses.includes("danger") ? "danger" : statuses.includes("warning") ? "warning" : "success";
}, [coverageByMed, depletionByMed, settingsHook.settings.lowStockDays]);
const groupedSchedule = useMemo(() => {
const days = new Map<string, { dateStr: string; date: Date; isPast: boolean; meds: Map<string, DayMedEntry> }>();
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]);
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 => !doses.takenDoses.has(id) && !doses.dismissedDoses.has(id));
}, [pastDays, doses.takenDoses, doses.dismissedDoses]);
// Modal helpers with browser history support
const openMedDetail = useCallback((med: Medication) => {
setSelectedMed(med);
refill.setRefillHistoryExpanded(false);
refill.loadRefillHistory(med.id);
window.history.pushState({ modal: 'medDetail', medId: med.id }, '');
}, [refill]);
const closeMedDetail = useCallback(() => {
if (selectedMed) {
window.history.back();
}
}, [selectedMed]);
const openImageLightbox = useCallback(() => {
setShowImageLightbox(true);
window.history.pushState({ modal: 'imageLightbox' }, '');
}, []);
const closeImageLightbox = useCallback(() => {
if (showImageLightbox) {
window.history.back();
}
}, [showImageLightbox]);
const openScheduleLightbox = useCallback((imageUrl: string) => {
setScheduleLightboxImage(imageUrl);
window.history.pushState({ modal: 'scheduleLightbox' }, '');
}, []);
const closeScheduleLightbox = useCallback(() => {
if (scheduleLightboxImage) {
window.history.back();
}
}, [scheduleLightboxImage]);
const openUserFilter = useCallback((person: string) => {
setSelectedUser(person);
window.history.pushState({ modal: 'userFilter', person }, '');
}, []);
const closeUserFilter = useCallback(() => {
if (selectedUser) {
window.history.back();
}
}, [selectedUser]);
// Wrapper to pass meds to openShareDialog
const openShareDialog = useCallback(() => {
share.openShareDialog(medications.meds);
}, [share, medications.meds]);
// Get t function for translations
const { t } = useTranslation();
// Export data to JSON file
const handleExport = useCallback(async (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);
}, [t]);
// Handle file selection for import
const handleImportFileSelect = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
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 = "";
}, [t]);
// Confirm and execute import
const handleImportConfirm = useCallback(async () => {
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
medications.loadMeds();
settingsHook.loadSettings();
doses.loadTakenDoses();
} catch (err) {
console.error("Import error:", err);
alert(t('exportImport.importError'));
}
setPendingImportData(null);
setImporting(false);
}, [pendingImportData, t, medications, settingsHook, doses]);
// Compute settingsChanged
const settingsChanged = useMemo(() => {
const settings = settingsHook.settings;
const savedSettings = settingsHook.savedSettings;
return settings.emailEnabled !== savedSettings.emailEnabled ||
settings.notificationEmail !== savedSettings.notificationEmail ||
settings.emailStockReminders !== savedSettings.emailStockReminders ||
settings.emailIntakeReminders !== savedSettings.emailIntakeReminders ||
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 ||
settings.shoutrrrStockReminders !== savedSettings.shoutrrrStockReminders ||
settings.shoutrrrIntakeReminders !== savedSettings.shoutrrrIntakeReminders ||
settings.skipRemindersForTakenDoses !== savedSettings.skipRemindersForTakenDoses ||
settings.repeatRemindersEnabled !== savedSettings.repeatRemindersEnabled ||
settings.reminderRepeatIntervalMinutes !== savedSettings.reminderRepeatIntervalMinutes ||
settings.maxNaggingReminders !== savedSettings.maxNaggingReminders ||
settings.stockCalculationMode !== savedSettings.stockCalculationMode;
}, [settingsHook.settings, settingsHook.savedSettings]);
// Build context value
const value: AppContextValue = useMemo(() => ({
// From useMedications
...medications,
// From useSettings
settings: settingsHook.settings,
setSettings: settingsHook.setSettings,
savedSettings: settingsHook.savedSettings,
settingsLoading: settingsHook.settingsLoading,
settingsSaving: settingsHook.settingsSaving,
settingsSaved: settingsHook.settingsSaved,
testingEmail: settingsHook.testingEmail,
testEmailResult: settingsHook.testEmailResult,
testingShoutrrr: settingsHook.testingShoutrrr,
testShoutrrrResult: settingsHook.testShoutrrrResult,
loadSettings: settingsHook.loadSettings,
saveSettings: settingsHook.saveSettings,
testEmail: settingsHook.testEmail,
testShoutrrr: settingsHook.testShoutrrr,
// From useDoses
takenDoses: doses.takenDoses,
setTakenDoses: doses.setTakenDoses,
dismissedDoses: doses.dismissedDoses,
clearingMissed: doses.clearingMissed,
showClearMissedConfirm: doses.showClearMissedConfirm,
setShowClearMissedConfirm: doses.setShowClearMissedConfirm,
getDoseId: doses.getDoseId,
countTakenDoses: doses.countTakenDoses,
markDoseTaken: doses.markDoseTaken,
undoDoseTaken: doses.undoDoseTaken,
dismissMissedDoses: doses.dismissMissedDoses,
// From useCollapsedDays
manuallyCollapsedDays: collapsed.manuallyCollapsedDays,
manuallyExpandedDays: collapsed.manuallyExpandedDays,
toggleDayCollapse: collapsed.toggleDayCollapse,
// From useShare
showShareDialog: share.showShareDialog,
sharePeople: share.sharePeople,
shareSelectedPerson: share.shareSelectedPerson,
setShareSelectedPerson: share.setShareSelectedPerson,
shareSelectedDays: share.shareSelectedDays,
setShareSelectedDays: share.setShareSelectedDays,
shareGenerating: share.shareGenerating,
shareLink: share.shareLink,
setShareLink: share.setShareLink,
shareCopied: share.shareCopied,
setShareCopied: share.setShareCopied,
openShareDialog,
generateShareLink: share.generateShareLink,
copyShareLink: share.copyShareLink,
closeShareDialog: share.closeShareDialog,
resetShareDialogState: share.resetShareDialogState,
// From useRefill
showRefillModal: refill.showRefillModal,
setShowRefillModal: refill.setShowRefillModal,
refillPacks: refill.refillPacks,
setRefillPacks: refill.setRefillPacks,
refillLoose: refill.refillLoose,
setRefillLoose: refill.setRefillLoose,
refillSaving: refill.refillSaving,
refillHistory: refill.refillHistory,
refillHistoryExpanded: refill.refillHistoryExpanded,
setRefillHistoryExpanded: refill.setRefillHistoryExpanded,
showEditStockModal: refill.showEditStockModal,
setShowEditStockModal: refill.setShowEditStockModal,
editStockFullBlisters: refill.editStockFullBlisters,
setEditStockFullBlisters: refill.setEditStockFullBlisters,
editStockPartialBlisterPills: refill.editStockPartialBlisterPills,
setEditStockPartialBlisterPills: refill.setEditStockPartialBlisterPills,
editStockSaving: refill.editStockSaving,
loadRefillHistory: refill.loadRefillHistory,
submitRefill: refill.submitRefill,
submitStockCorrection: refill.submitStockCorrection,
openRefillModal: refill.openRefillModal,
closeRefillModal: refill.closeRefillModal,
openEditStockModal: refill.openEditStockModal,
closeEditStockModal: refill.closeEditStockModal,
// Computed values
schedule,
coverage,
coverageByMed,
depletionByMed,
existingPeople,
groupedSchedule,
pastDays,
futureDays,
missedPastDoseIds,
getDayStockStatus,
// Schedule UI state
scheduleDays,
setScheduleDays,
showPastDays,
setShowPastDays,
// Modal state
selectedMed,
setSelectedMed,
showImageLightbox,
setShowImageLightbox,
scheduleLightboxImage,
setScheduleLightboxImage,
selectedUser,
setSelectedUser,
// Modal helpers
openMedDetail,
closeMedDetail,
openImageLightbox,
closeImageLightbox,
openScheduleLightbox,
closeScheduleLightbox,
openUserFilter,
closeUserFilter,
// Export/Import
exporting,
importing,
showExportModal,
setShowExportModal,
showImportConfirm,
setShowImportConfirm,
pendingImportData,
setPendingImportData,
importResult,
setImportResult,
handleExport,
handleImportFileSelect,
handleImportConfirm,
settingsChanged,
}), [
medications,
settingsHook,
doses,
collapsed,
share,
refill,
schedule,
coverage,
coverageByMed,
depletionByMed,
existingPeople,
groupedSchedule,
pastDays,
futureDays,
missedPastDoseIds,
getDayStockStatus,
scheduleDays,
showPastDays,
selectedMed,
showImageLightbox,
scheduleLightboxImage,
selectedUser,
openMedDetail,
closeMedDetail,
openImageLightbox,
closeImageLightbox,
openScheduleLightbox,
closeScheduleLightbox,
openUserFilter,
closeUserFilter,
openShareDialog,
exporting,
importing,
showExportModal,
showImportConfirm,
pendingImportData,
importResult,
handleExport,
handleImportFileSelect,
handleImportConfirm,
settingsChanged,
]);
return <AppContext.Provider value={value}>{children}</AppContext.Provider>;
}
// =============================================================================
// Hook
// =============================================================================
export function useAppContext(): AppContextValue {
const context = useContext(AppContext);
if (!context) {
throw new Error("useAppContext must be used within an AppProvider");
}
return context;
}
+3
View File
@@ -0,0 +1,3 @@
// Context barrel export
export { AppProvider, useAppContext } from "./AppContext";
export type { AppContextValue, DoseInfo, DayMedEntry, GroupedDay } from "./AppContext";
+17
View File
@@ -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";
+67
View File
@@ -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
};
}
+176
View File
@@ -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
};
}
+233
View File
@@ -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,
};
}
+74
View File
@@ -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,
};
}
+240
View File
@@ -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,
};
}
+255
View File
@@ -0,0 +1,255 @@
// =============================================================================
// useSettings Hook - Settings state and operations
// =============================================================================
import { useCallback, useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
export interface Settings {
emailEnabled: boolean;
notificationEmail: string;
reminderDaysBefore: number;
repeatDailyReminders: boolean;
skipRemindersForTakenDoses: boolean;
repeatRemindersEnabled: boolean;
reminderRepeatIntervalMinutes: number;
maxNaggingReminders: number;
lowStockDays: number;
normalStockDays: number;
highStockDays: number;
smtpHost: string;
smtpPort: number;
smtpUser: string;
smtpPass: string;
smtpFrom: string;
smtpSecure: boolean;
hasSmtpPassword: boolean;
lastAutoEmailSent: string | null;
nextScheduledCheck: string | null;
lastNotificationType: "stock" | "intake" | null;
lastNotificationChannel: "email" | "push" | "both" | null;
shoutrrrEnabled: boolean;
shoutrrrUrl: string;
emailStockReminders: boolean;
emailIntakeReminders: boolean;
shoutrrrStockReminders: boolean;
shoutrrrIntakeReminders: boolean;
stockCalculationMode: "automatic" | "manual";
expiryWarningDays: number;
}
const defaultSettings: Settings = {
emailEnabled: false,
notificationEmail: "",
reminderDaysBefore: 7,
repeatDailyReminders: false,
skipRemindersForTakenDoses: false,
repeatRemindersEnabled: false,
reminderRepeatIntervalMinutes: 30,
maxNaggingReminders: 5,
lowStockDays: 30,
normalStockDays: 90,
highStockDays: 180,
smtpHost: "",
smtpPort: 587,
smtpUser: "",
smtpPass: "",
smtpFrom: "",
smtpSecure: false,
hasSmtpPassword: false,
lastAutoEmailSent: null,
nextScheduledCheck: null,
lastNotificationType: null,
lastNotificationChannel: null,
shoutrrrEnabled: false,
shoutrrrUrl: "",
emailStockReminders: true,
emailIntakeReminders: true,
shoutrrrStockReminders: true,
shoutrrrIntakeReminders: true,
stockCalculationMode: "automatic",
expiryWarningDays: 30
};
export interface UseSettingsReturn {
settings: Settings;
setSettings: React.Dispatch<React.SetStateAction<Settings>>;
savedSettings: Settings;
settingsLoading: boolean;
settingsSaving: boolean;
settingsSaved: boolean;
testingEmail: boolean;
testEmailResult: { success: boolean; message: string } | null;
setTestEmailResult: React.Dispatch<React.SetStateAction<{ success: boolean; message: string } | null>>;
testingShoutrrr: boolean;
testShoutrrrResult: { success: boolean; message: string } | null;
setTestShoutrrrResult: React.Dispatch<React.SetStateAction<{ success: boolean; message: string } | null>>;
loadSettings: () => void;
saveSettings: (e: React.FormEvent) => Promise<void>;
testEmail: () => Promise<void>;
testShoutrrr: () => Promise<void>;
hasUnsavedChanges: boolean;
}
export function useSettings(): UseSettingsReturn {
const { i18n } = useTranslation();
const [settings, setSettings] = useState<Settings>(defaultSettings);
const [savedSettings, setSavedSettings] = useState<Settings>(defaultSettings);
const [settingsLoading, setSettingsLoading] = useState(false);
const [settingsSaving, setSettingsSaving] = useState(false);
const [settingsSaved, setSettingsSaved] = useState(false);
const [testingEmail, setTestingEmail] = useState(false);
const [testEmailResult, setTestEmailResult] = useState<{ success: boolean; message: string } | null>(null);
const [testingShoutrrr, setTestingShoutrrr] = useState(false);
const [testShoutrrrResult, setTestShoutrrrResult] = useState<{ success: boolean; message: string } | null>(null);
// Load settings function - exposed for manual refresh (e.g., after auth)
const loadSettings = useCallback(() => {
setSettingsLoading(true);
fetch("/api/settings", { credentials: "include" })
.then((res) => (res.ok ? res.json() : Promise.reject()))
.then((data) => {
const newSettings = { ...defaultSettings, ...data, smtpPass: "" };
setSettings(newSettings);
setSavedSettings(newSettings);
setSettingsSaved(false);
})
.catch(() => {})
.finally(() => setSettingsLoading(false));
}, []);
// Load settings on mount
useEffect(() => {
loadSettings();
}, [loadSettings]);
const saveSettings = useCallback(
async (e: React.FormEvent) => {
e.preventDefault();
// Auto-disable email if no recipient is set
const effectiveEmailEnabled = settings.emailEnabled && !!settings.notificationEmail?.trim();
// Auto-disable push if no URL is set
const effectiveShoutrrrEnabled = settings.shoutrrrEnabled && !!settings.shoutrrrUrl?.trim();
// Validate email if email notifications are enabled
if (effectiveEmailEnabled && settings.notificationEmail) {
const emailRegex = /^[a-z0-9._%+\-]+@[a-z0-9.\-]+\.[a-z]{2,}$/i;
if (!emailRegex.test(settings.notificationEmail)) {
setTestEmailResult({ success: false, message: "Invalid email address" });
return;
}
}
setSettingsSaving(true);
setTestEmailResult(null);
const payload = {
emailEnabled: effectiveEmailEnabled,
notificationEmail: settings.notificationEmail,
reminderDaysBefore: settings.reminderDaysBefore,
repeatDailyReminders: settings.repeatDailyReminders,
skipRemindersForTakenDoses: settings.skipRemindersForTakenDoses,
repeatRemindersEnabled: settings.repeatRemindersEnabled,
reminderRepeatIntervalMinutes: settings.reminderRepeatIntervalMinutes,
maxNaggingReminders: settings.maxNaggingReminders ?? 5,
lowStockDays: settings.lowStockDays,
normalStockDays: settings.normalStockDays,
highStockDays: settings.highStockDays,
shoutrrrEnabled: effectiveShoutrrrEnabled,
shoutrrrUrl: settings.shoutrrrUrl,
emailStockReminders: settings.emailStockReminders,
emailIntakeReminders: settings.emailIntakeReminders,
shoutrrrStockReminders: settings.shoutrrrStockReminders,
shoutrrrIntakeReminders: settings.shoutrrrIntakeReminders,
stockCalculationMode: settings.stockCalculationMode,
language: i18n.language,
smtpHost: settings.smtpHost,
smtpPort: settings.smtpPort,
smtpUser: settings.smtpUser,
smtpPass: settings.smtpPass || undefined,
smtpFrom: settings.smtpFrom,
smtpSecure: settings.smtpSecure
};
await fetch("/api/settings", {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload)
}).catch(() => null);
const updatedSettings = {
...settings,
emailEnabled: effectiveEmailEnabled,
shoutrrrEnabled: effectiveShoutrrrEnabled
};
setSettings(updatedSettings);
setSettingsSaving(false);
setSavedSettings(updatedSettings);
setSettingsSaved(true);
},
[settings, i18n.language]
);
const testEmail = useCallback(async () => {
setTestingEmail(true);
setTestEmailResult(null);
try {
const res = await fetch("/api/settings/test-email", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ email: settings.notificationEmail })
});
const data = await res.json();
setTestEmailResult({ success: res.ok, message: data.message || (res.ok ? "Email sent!" : "Failed to send email") });
} catch {
setTestEmailResult({ success: false, message: "Failed to send test email" });
} finally {
setTestingEmail(false);
}
}, [settings.notificationEmail]);
const testShoutrrr = useCallback(async () => {
setTestingShoutrrr(true);
setTestShoutrrrResult(null);
try {
const res = await fetch("/api/settings/test-shoutrrr", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ url: settings.shoutrrrUrl })
});
const data = await res.json();
setTestShoutrrrResult({
success: res.ok,
message: data.message || (res.ok ? "Notification sent!" : "Failed to send notification")
});
} catch {
setTestShoutrrrResult({ success: false, message: "Failed to send test notification" });
} finally {
setTestingShoutrrr(false);
}
}, [settings.shoutrrrUrl]);
// Check for unsaved changes
const hasUnsavedChanges = JSON.stringify(settings) !== JSON.stringify(savedSettings);
return {
settings,
setSettings,
savedSettings,
settingsLoading,
settingsSaving,
settingsSaved,
testingEmail,
testEmailResult,
setTestEmailResult,
testingShoutrrr,
testShoutrrrResult,
setTestShoutrrrResult,
loadSettings,
saveSettings,
testEmail,
testShoutrrr,
hasUnsavedChanges
};
}
+122
View File
@@ -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
};
}
+32
View File
@@ -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 };
}
+4 -3
View File
@@ -50,8 +50,9 @@
"reminders": {
"active": "Automatische Erinnerungen aktiv",
"allStockOk": "Bestand OK",
"allOk": "Alles OK",
"allOk": "Alles OK",
"lastReminder": "Letzte Erinnerung",
"next": "Nächste",
"nextIn": "Nächste",
"inDays": "in {{days}} Tagen",
"noRemindersNeeded": "keine Erinnerungen nötig",
@@ -243,8 +244,8 @@
"highStock": "Hoch",
"noSchedule": "Kein Zeitplan",
"enough": "Ausreichend",
"noPillsLeft": "Keine Tabletten mehr",
"stockOk": "Bestand OK"
"noPillsLeft": "Keine Tabletten mehr",
"stockOk": "Bestand OK"
},
"tooltips": {
"intakeReminders": "Einnahme-Erinnerungen aktiviert",
+4 -3
View File
@@ -52,8 +52,9 @@
"reminders": {
"active": "Automatic reminders active",
"allStockOk": "All stock OK",
"allOk": "All OK",
"allOk": "All OK",
"lastReminder": "Last reminder",
"next": "Next",
"nextIn": "Next",
"inDays": "in {{days}} days",
"noRemindersNeeded": "no reminders needed",
@@ -245,8 +246,8 @@
"highStock": "High",
"noSchedule": "No Schedule",
"enough": "Enough",
"noPillsLeft": "No pills left",
"stockOk": "Stock OK"
"noPillsLeft": "No pills left",
"stockOk": "Stock OK"
},
"tooltips": {
"intakeReminders": "Intake reminders enabled",
+627
View File
@@ -0,0 +1,627 @@
import { useState } from "react";
import { useTranslation } from "react-i18next";
import { useAuth } from "../components/Auth";
import { useAppContext } from "../context";
import { MedicationAvatar, ConfirmModal } from "../components";
import { formatNumber, getExpiryClass } from "../utils/formatters";
import type { Coverage } from "../types";
// Helper for user-specific localStorage keys
function userStorageKey(userId: number | undefined, key: string): string {
return userId ? `user_${userId}_${key}` : key;
}
// Helper function to get stock status
function getStockStatus(daysLeft: number | null, medsLeft: number, settings: { lowStockDays: number; normalStockDays: number; highStockDays: number }) {
if (medsLeft <= 0 || daysLeft === null || daysLeft <= 0) return { className: "danger", label: "status.outOfStock" };
if (daysLeft <= settings.lowStockDays) return { className: "danger", label: "status.lowStock" };
if (daysLeft >= settings.highStockDays) return { className: "success", label: "status.highStock" };
return { className: "success", label: "status.normal" };
}
// Helper function to calculate blister stock
function getBlisterStock(totalPills: number, pillsPerBlister: number, _looseTablets: number, _originalTotal: number) {
const fullBlisters = Math.floor(totalPills / pillsPerBlister);
const openBlisterPills = totalPills % pillsPerBlister;
return { fullBlisters, openBlisterPills, loosePills: openBlisterPills };
}
// Helper to format full blisters
function formatFullBlisters(count: number, t: (key: string) => string): string {
return `${count} ${t('common.blisters')}`;
}
// Helper to format open blister and loose pills
function formatOpenBlisterAndLoose(openBlisterPills: number, loosePills: number, _pillsPerBlister: number, t: (key: string) => string): string {
if (openBlisterPills === 0 && loosePills === 0) return "-";
return `${openBlisterPills} ${t('common.pills')}`;
}
// Get total pills for a medication
function getMedTotal(med: { packCount: number; blistersPerPack: number; pillsPerBlister: number; looseTablets: number; stockAdjustment?: number | null }): number {
return med.packCount * med.blistersPerPack * med.pillsPerBlister + med.looseTablets + (med.stockAdjustment ?? 0);
}
// Get next reminder date for a medication
function getNextReminderForMed(row: Coverage, reminderDaysBefore: number, locale: string): string {
if (!row.depletionDate) return "-";
const depletionDate = new Date(row.depletionDate);
const reminderDate = new Date(depletionDate);
reminderDate.setDate(reminderDate.getDate() - reminderDaysBefore);
const now = new Date();
if (reminderDate <= now) return "-";
return reminderDate.toLocaleDateString(locale, { day: "2-digit", month: "short" });
}
// Get reminder status as JSX with proper styling
function getReminderStatusContent(
reminderDaysBefore: number,
lowStockDays: number,
lowCoverage: Coverage[],
allCoverage: Coverage[],
lastAutoEmailSent: string | null,
lastNotificationType: string | null,
lastNotificationChannel: string | null,
t: (key: string, options?: Record<string, unknown>) => string,
locale: string
): React.ReactNode {
const criticalCount = lowCoverage.length;
const lowCount = allCoverage.filter(c => {
if (c.medsLeft <= 0) return false;
if (c.daysLeft === null) return false;
return c.daysLeft < lowStockDays && c.daysLeft > 3;
}).length;
let statusElement: React.ReactNode;
if (criticalCount > 0) {
statusElement = <span className="danger-text">{t('dashboard.reminders.criticalMeds', { count: criticalCount })}</span>;
} else if (lowCount > 0) {
statusElement = <span className="warning-text">{t('dashboard.reminders.lowMeds', { count: lowCount })}</span>;
} else {
statusElement = <span className="success-text">{t('dashboard.reminders.allOk')}</span>;
}
// Find next medication to hit reminder threshold (lowest daysLeft > reminderDaysBefore)
const nextToRunOut = allCoverage
.filter(c => c.daysLeft !== null && c.daysLeft > reminderDaysBefore)
.sort((a, b) => (a.daysLeft ?? Infinity) - (b.daysLeft ?? Infinity))[0];
let nextText = "";
if (nextToRunOut && nextToRunOut.daysLeft !== null) {
// Show days until it hits the reminder threshold, not until empty
const daysUntilReminder = Math.round(nextToRunOut.daysLeft - reminderDaysBefore);
nextText = `${t('dashboard.reminders.next')}: ${nextToRunOut.name} ${t('dashboard.reminders.inDays', { days: daysUntilReminder })}`;
}
let lastSentText = "";
if (lastAutoEmailSent) {
const lastSent = new Date(lastAutoEmailSent);
const formattedDate = lastSent.toLocaleDateString(locale, { day: "2-digit", month: "short", hour: "2-digit", minute: "2-digit" });
const channelIcon = lastNotificationChannel === "shoutrrr" ? "🔔" : "📧";
lastSentText = `${t('dashboard.reminders.lastSent')}: ${channelIcon} ${formattedDate}`;
}
return (
<>
{statusElement}
{nextText && <span className="next-reminder"> {nextText}</span>}
{lastSentText && <span className="last-sent"> · {lastSentText}</span>}
</>
);
}
export function DashboardPage() {
const { t, i18n } = useTranslation();
const { user } = useAuth();
const {
meds,
settings,
coverage,
coverageByMed,
depletionByMed,
scheduleDays,
setScheduleDays,
showPastDays,
setShowPastDays,
pastDays,
futureDays,
takenDoses,
dismissedDoses,
markDoseTaken,
undoDoseTaken,
manuallyCollapsedDays,
manuallyExpandedDays,
toggleDayCollapse,
missedPastDoseIds,
getDayStockStatus,
getDoseId,
showClearMissedConfirm,
setShowClearMissedConfirm,
clearingMissed,
dismissMissedDoses,
openMedDetail,
openUserFilter,
openShareDialog,
openScheduleLightbox,
} = useAppContext();
// Local state for reminder email
const [sendingReminderEmail, setSendingReminderEmail] = useState(false);
const [reminderEmailResult, setReminderEmailResult] = useState<{ success: boolean; message: string } | null>(null);
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!" });
} else {
setReminderEmailResult({ success: false, message: data.error || "Failed to send" });
}
} catch {
setReminderEmailResult({ success: false, message: "Network error" });
}
setSendingReminderEmail(false);
}
return (
<>
{(settings.emailEnabled || settings.shoutrrrEnabled) && (
<section className="email-status-bar">
<span className="email-status-icon">{settings.emailEnabled && settings.shoutrrrEnabled ? "🔔" : settings.emailEnabled ? "📧" : "🔔"}</span>
<span className="email-status-text">
<span className="email-status-line">{t('dashboard.reminders.active')}</span>
{getReminderStatusContent(settings.reminderDaysBefore, settings.lowStockDays, coverage.low, coverage.all, settings.lastAutoEmailSent, settings.lastNotificationType, settings.lastNotificationChannel, t, i18n.language)}
</span>
{settings.emailEnabled && settings.notificationEmail && <span className="email-status-recipient"> {settings.notificationEmail}</span>}
</section>
)}
<section className="grid">
<article className="card">
<div className="card-head">
<h2>{t('dashboard.reorder.title')}</h2>
</div>
{(() => {
if (meds.length === 0) {
return <p className="muted">{t('dashboard.reorder.noMeds')}</p>;
}
// 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 <p className="success-text">{t('dashboard.reorder.allGood')}</p>;
} else {
// Some meds are Low but not critical
return <p className="warning-text">{t('dashboard.reorder.lowWarning', { count: lowStockCount })}</p>;
}
}
return (
<>
<div className="table table-7">
<div className="table-head">
<span>{t('table.name')}</span>
<span>{t('table.fullBlisters')}</span>
<span>{t('table.openBlister')}</span>
<span>{t('table.daysLeft')}</span>
<span>{t('table.status')}</span>
<span>{t('table.runsOut')}</span>
<span>{t('table.autoRemind')}</span>
</div>
{coverage.low.map((row) => {
const status = getStockStatus(row.daysLeft, row.medsLeft, settings);
const med = meds.find(m => m.name === row.name);
const textClass = status.className === "danger" ? "danger-text" : status.className === "warning" ? "warning-text" : "success-text";
const stock = getBlisterStock(
Math.round(row.medsLeft),
med?.pillsPerBlister ?? 1,
med?.looseTablets ?? 0,
med ? getMedTotal(med) : Math.round(row.medsLeft)
);
return (
<div key={row.name} className="table-row clickable" onClick={() => med && openMedDetail(med)}>
<span data-label={t('table.name')} className="cell-with-avatar">
<MedicationAvatar name={row.name} imageUrl={med?.imageUrl} />
<span className="med-name-text">{row.name}</span>
{med?.takenBy && med.takenBy.length > 0 && med.takenBy.map((person) => (
<span key={person} className="taken-by-badge clickable" onClick={(e) => { e.stopPropagation(); openUserFilter(person); }}>{person}</span>
))}
{(med?.intakeRemindersEnabled || med?.notes) && (
<span className="med-icons">
{med?.intakeRemindersEnabled && <span className="reminder-icon info-tooltip" data-tooltip={t('tooltips.intakeReminders')}>🔔</span>}
{med?.notes && <span className="notes-icon info-tooltip" data-tooltip={t('tooltips.hasNotes')}>📝</span>}
</span>
)}
</span>
<span data-label={t('table.fullBlisters')} className={textClass}>{formatFullBlisters(stock.fullBlisters, t)}</span>
<span data-label={t('table.openBlister')} className={textClass}>{formatOpenBlisterAndLoose(stock.openBlisterPills, stock.loosePills, med?.pillsPerBlister ?? 1, t)}</span>
<span data-label={t('table.days')} className={textClass}>{formatNumber(row.daysLeft)}</span>
<span data-label={t('table.status')} className={`status-chip ${status.className}`}>{t(status.label)}</span>
<span data-label={t('table.runsOut')}>{row.depletionDate ?? "-"}</span>
<span data-label={t('table.autoRemind')} className="next-reminder-date">{getNextReminderForMed(row, settings.reminderDaysBefore, i18n.language)}</span>
</div>
);
})}
</div>
{(settings.emailEnabled || settings.shoutrrrEnabled) && (
<div className="email-send-action">
<button type="button" className="ghost" onClick={sendReminderEmail} disabled={sendingReminderEmail}>
{sendingReminderEmail ? t('common.sending') : t('dashboard.reorder.sendReminder')}
</button>
{reminderEmailResult && (
<span className={reminderEmailResult.success ? "success-text" : "danger-text"}>
{reminderEmailResult.message}
</span>
)}
</div>
)}
</>
);
})()}
</article>
</section>
<section className="grid">
<article className="card">
<div className="card-head">
<h2>{t('dashboard.overview.title')}</h2>
</div>
<div className="table table-7">
<div className="table-head">
<span>{t('table.name')}</span>
<span>{t('table.fullBlisters')}</span>
<span>{t('table.openBlister')}</span>
<span>{t('table.daysLeft')}</span>
<span>{t('table.runsOut')}</span>
<span>{t('table.expiry')}</span>
<span>{t('table.status')}</span>
</div>
{coverage.all.map((row) => {
const status = getStockStatus(row.daysLeft, row.medsLeft, settings);
const med = meds.find(m => m.name === row.name);
const expiryClass = getExpiryClass(med?.expiryDate, settings.expiryWarningDays);
const textClass = status.className === "danger" ? "danger-text" : status.className === "warning" ? "warning-text" : "success-text";
const stock = getBlisterStock(
Math.round(row.medsLeft),
med?.pillsPerBlister ?? 1,
med?.looseTablets ?? 0,
med ? getMedTotal(med) : Math.round(row.medsLeft)
);
return (
<div key={row.name} className="table-row clickable" onClick={() => med && openMedDetail(med)}>
<span data-label={t('table.name')} className="cell-with-avatar">
<span className="med-name-line">
<MedicationAvatar name={row.name} imageUrl={med?.imageUrl} />
<span className="med-name-text">{row.name}</span>
{med?.takenBy && med.takenBy.length > 0 && med.takenBy.map((person) => (
<span key={person} className="taken-by-badge clickable" onClick={(e) => { e.stopPropagation(); openUserFilter(person); }}>{person}</span>
))}
</span>
{(med?.intakeRemindersEnabled || med?.notes) && (
<span className="med-icons">
{med?.intakeRemindersEnabled && <span className="reminder-icon info-tooltip" data-tooltip={t('tooltips.intakeReminders')}>🔔</span>}
{med?.notes && <span className="notes-icon info-tooltip" data-tooltip={t('tooltips.hasNotes')}>📝</span>}
</span>
)}
</span>
<span data-label={t('table.fullBlisters')} className={textClass}>{formatFullBlisters(stock.fullBlisters, t)}</span>
<span data-label={t('table.openBlister')} className={textClass}>{formatOpenBlisterAndLoose(stock.openBlisterPills, stock.loosePills, med?.pillsPerBlister ?? 1, t)}</span>
<span data-label={t('table.daysLeft')} className={textClass}>{formatNumber(row.daysLeft)}</span>
<span data-label={t('table.runsOut')}>{row.depletionDate ?? "-"}</span>
<span data-label={t('table.expiry')} className={expiryClass}>{med?.expiryDate ? new Date(med.expiryDate).toLocaleDateString(i18n.language, { day: "2-digit", month: "short", year: "2-digit" }) : "-"}</span>
<span data-label={t('table.status')} className={`status-chip ${status.className}`}>{t(status.label)}</span>
</div>
);
})}
</div>
</article>
</section>
<section className="grid">
<article className="card">
<div className="card-head">
<h2>{t('dashboard.schedules.title')}</h2>
<div className="card-head-actions">
{meds.some(m => m.takenBy && m.takenBy.length > 0) && (
<button className="ghost share-btn" onClick={openShareDialog} title={t('share.button')}>
🔗 {t('share.button')}
</button>
)}
<select
className="schedule-days-select"
value={scheduleDays}
onChange={(e) => {
const val = Number(e.target.value);
setScheduleDays(val);
if (user?.id) localStorage.setItem(userStorageKey(user.id, "scheduleDays"), String(val));
}}
>
<option value={30}>{t('dashboard.schedules.1month')}</option>
<option value={90}>{t('dashboard.schedules.3months')}</option>
<option value={180}>{t('dashboard.schedules.6months')}</option>
</select>
</div>
</div>
<div className="timeline">
{/* Past days toggle */}
{pastDays.length > 0 && (() => {
const missedCount = missedPastDoseIds.length;
const totalPastDoses = pastDays.flatMap(d => d.meds.flatMap(m => m.doses.flatMap(dose => (dose.takenBy || []).length > 0 ? dose.takenBy.map(p => `${dose.id}-${p}`) : [dose.id])));
return (
<div className="past-days-header">
<div
className={`past-days-toggle ${showPastDays ? 'expanded' : ''} ${missedCount > 0 ? 'has-missed' : ''}`}
onClick={() => setShowPastDays(!showPastDays)}
>
<span className="past-days-icon">{showPastDays ? '▼' : '▶'}</span>
<span className="past-days-label">
{showPastDays ? t('dashboard.schedules.hidePastDays') : t('dashboard.schedules.showPastDays')}
</span>
<span className="past-days-count">({t('dashboard.schedules.pastDaysCount', { count: pastDays.length })})</span>
{missedCount > 0 ? (
<span className="past-days-warning" title={t('dashboard.schedules.missedDoses', { count: missedCount })}> {missedCount}</span>
) : totalPastDoses.length > 0 ? (
<span className="past-days-complete" title={t('dashboard.schedules.allTaken')}></span>
) : null}
</div>
{missedCount > 0 && (
<button
type="button"
className="clear-missed-btn"
onClick={(e) => {
e.stopPropagation();
setShowClearMissedConfirm(true);
}}
title={t('dashboard.schedules.clearMissed')}
>
{t('dashboard.schedules.clearMissed')}
</button>
)}
</div>
);
})()}
{/* Past days (when expanded) */}
{showPastDays && pastDays.map((day) => {
const allDoseIds = day.meds.flatMap((item) => item.doses.flatMap((d) => (d.takenBy || []).length > 0 ? d.takenBy.map((p) => `${d.id}-${p}`) : [d.id]));
const allDayTaken = allDoseIds.length > 0 && allDoseIds.every((id) => takenDoses.has(id) || dismissedDoses.has(id));
const takenCount = allDoseIds.filter((id) => takenDoses.has(id) || dismissedDoses.has(id)).length;
const isAutoCollapsed = true; // Past days are always auto-collapsed
const isManuallyExpanded = manuallyExpandedDays.has(day.dateStr);
const isCollapsed = !isManuallyExpanded;
const worstStatus = getDayStockStatus(day.meds);
return (
<div key={day.dateStr} className={`day-block past ${isCollapsed ? "collapsed" : ""} ${allDayTaken ? "all-taken" : ""} stock-${worstStatus}`}>
<div
className="day-divider clickable"
onClick={() => toggleDayCollapse(day.dateStr, isAutoCollapsed)}
title={isCollapsed ? t('common.expand') : t('common.collapse')}
>
<span className="day-collapse-icon">{isCollapsed ? "▶" : "▼"}</span>
<span className="day-date">{day.dateStr}</span>
<span className="day-summary">
{allDayTaken ? (
<span className="day-complete"> {t('dashboard.schedules.allTaken')}</span>
) : (
<><span className="day-warning" title={t('dashboard.schedules.missedDoses', { count: allDoseIds.length - takenCount })}></span><span className="day-progress">{takenCount}/{allDoseIds.length}</span></>
)}
</span>
</div>
{!isCollapsed && day.meds.map((item) => {
const med = meds.find(m => m.name === item.medName);
const medCov = coverageByMed[item.medName];
const isEmpty = medCov ? medCov.medsLeft <= 0 : false;
const itemDoseIds = item.doses.flatMap((d) => (d.takenBy || []).length > 0 ? d.takenBy.map((p) => `${d.id}-${p}`) : [d.id]);
const allTaken = itemDoseIds.every((id) => takenDoses.has(id));
return (
<div key={`${day.dateStr}-${item.medName}`} className={`time-row ${allTaken ? "taken" : ""}`}>
<div className="time-main">
<div className="med-name">
<div
className={med?.imageUrl ? "med-avatar clickable" : ""}
onClick={() => med?.imageUrl && openScheduleLightbox(`/api/images/${med.imageUrl}`)}
>
<MedicationAvatar name={item.medName} imageUrl={med?.imageUrl} size="sm" />
</div>
<span className="med-name-text">{item.medName}</span>{med?.intakeRemindersEnabled && <span className="reminder-icon info-tooltip" data-tooltip={t('tooltips.intakeReminders')}>🔔</span>}
</div>
<div className="tag-row">
<span className="tag subtle">{item.total} {t('common.pills')} {t('common.total')}</span>
</div>
</div>
<div className="doses-col">
{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 (
<div key={dose.id} className="dose-item past">
<span className="dose-time">{dose.timeStr}</span>
<span className="dose-usage">{dose.usage} {dose.usage !== 1 ? t('common.pills') : t('common.pill')}{med?.pillWeightMg && ` (${dose.usage * med.pillWeightMg} mg)`}</span>
<div className="dose-checks">
{people.map((person) => {
const doseId = getDoseId(dose.id, person);
const isTaken = takenDoses.has(doseId);
return (
<div key={doseId} className={`dose-person ${isTaken ? "taken" : ""}`}>
{person && <span className="person-name clickable" onClick={() => openUserFilter(person)}>{person}</span>}
{isTaken ? (
<button className="dose-btn undo" onClick={() => undoDoseTaken(doseId)} title={t('common.undo')}></button>
) : (
<button className="dose-btn take" onClick={() => markDoseTaken(doseId)} title={t('dose.markAsTaken')} disabled={isEmpty}></button>
)}
</div>
);
})}
</div>
</div>
);
})}
</div>
</div>
);
})}
</div>
);
})}
{/* Current and future days */}
{futureDays.map((day) => {
// Check if all doses in this day are taken (auto-collapse)
const allDoseIds = day.meds.flatMap((item) => item.doses.flatMap((d) => (d.takenBy || []).length > 0 ? d.takenBy.map((p) => `${d.id}-${p}`) : [d.id]));
const allDayTaken = allDoseIds.length > 0 && allDoseIds.every((id) => takenDoses.has(id));
const takenCount = allDoseIds.filter((id) => takenDoses.has(id)).length;
// Calculate worst stock status for this day
const dayStockStatuses = day.meds.map((item) => {
const medCoverage = coverageByMed[item.medName];
const depletionTime = depletionByMed[item.medName];
const willBeOutOfStock = typeof depletionTime === "number" && item.lastWhen > depletionTime;
if (willBeOutOfStock) return "danger";
if (!medCoverage) return "success";
const status = getStockStatus(medCoverage.daysLeft, medCoverage.medsLeft, settings);
return status.className;
});
const worstStatus = dayStockStatuses.includes("danger") ? "danger" : dayStockStatuses.includes("warning") ? "warning" : "success";
// Check if this is today, past, or future
const today = new Date();
today.setHours(0, 0, 0, 0);
const dayDate = new Date(day.date);
dayDate.setHours(0, 0, 0, 0);
const isToday = dayDate.getTime() === today.getTime();
// Determine if day should be collapsed: only today is expanded by default
const isAutoCollapsed = allDayTaken || !isToday;
const isManuallyExpanded = manuallyExpandedDays.has(day.dateStr);
const isManuallyCollapsed = manuallyCollapsedDays.has(day.dateStr);
const isCollapsed = isAutoCollapsed ? !isManuallyExpanded : isManuallyCollapsed;
return (
<div key={day.dateStr} className={`day-block ${isCollapsed ? "collapsed" : ""} ${allDayTaken ? "all-taken" : ""} ${isToday ? "today" : ""} ${worstStatus ? `stock-${worstStatus}` : ""}`}>
<div
className="day-divider clickable"
onClick={() => toggleDayCollapse(day.dateStr, isAutoCollapsed)}
title={isCollapsed ? t('common.expand') : t('common.collapse')}
>
<span className="day-collapse-icon">{isCollapsed ? "▶" : "▼"}</span>
<span className="day-date">{day.dateStr}</span>
<span className="day-summary">
{allDayTaken ? (
<span className="day-complete"> {t('dashboard.schedules.allTaken')}</span>
) : (
<span className="day-progress">{takenCount}/{allDoseIds.length}</span>
)}
</span>
</div>
{!isCollapsed && day.meds.map((item) => {
const medCoverage = coverageByMed[item.medName];
const med = meds.find(m => m.name === item.medName);
const depletionTime = depletionByMed[item.medName];
const isEmpty = medCoverage ? medCoverage.medsLeft <= 0 : false;
// Check if this dose is scheduled after medication runs out
const willBeOutOfStock = typeof depletionTime === "number" && item.lastWhen > depletionTime;
const status = willBeOutOfStock
? { className: "danger", label: "status.outOfStock" }
: medCoverage ? getStockStatus(medCoverage.daysLeft, medCoverage.medsLeft, settings) : null;
const itemDoseIds = item.doses.flatMap((d) => (d.takenBy || []).length > 0 ? d.takenBy.map((p) => `${d.id}-${p}`) : [d.id]);
const allTaken = itemDoseIds.every((id) => takenDoses.has(id));
return (
<div key={`${day.dateStr}-${item.medName}`} className={`time-row ${allTaken ? "taken" : ""}`}>
<div className="time-main">
<div className="med-name">
<div
className={med?.imageUrl ? "med-avatar clickable" : ""}
onClick={() => med?.imageUrl && openScheduleLightbox(`/api/images/${med.imageUrl}`)}
>
<MedicationAvatar name={item.medName} imageUrl={med?.imageUrl} size="sm" />
</div>
<span className="med-name-text">{item.medName}</span>{med?.intakeRemindersEnabled && <span className="reminder-icon info-tooltip" data-tooltip={t('tooltips.intakeReminders')}>🔔</span>}
</div>
<div className="tag-row">
<span className="tag subtle">{item.total} {t('common.pills')} {t('common.total')}</span>
{status && <span className={`tag ${status.className}`}>
{t(status.label)}
</span>}
</div>
</div>
<div className="doses-col">
{item.doses.map((dose) => {
const isOverdue = dose.when < Date.now();
// Only disable doses on future DAYS, not later today
const doseDate = new Date(dose.when);
doseDate.setHours(0, 0, 0, 0);
const todayMidnight = new Date();
todayMidnight.setHours(0, 0, 0, 0);
const isFutureDose = doseDate.getTime() > todayMidnight.getTime();
// If no takenBy, show single checkbox; otherwise show one per person
const people = (dose.takenBy || []).length > 0 ? dose.takenBy : [null];
const allTaken = people.every((person) => takenDoses.has(getDoseId(dose.id, person)));
return (
<div key={dose.id} className={`dose-item ${isOverdue ? "overdue" : ""} ${isFutureDose ? "future" : ""} ${allTaken ? "all-taken" : ""}`}>
<span className="dose-time">{dose.timeStr}</span>
<span className="dose-usage">{dose.usage} {dose.usage !== 1 ? t('common.pills') : t('common.pill')}{med?.pillWeightMg && ` (${dose.usage * med.pillWeightMg} mg)`}</span>
<div className="dose-checks">
{people.map((person) => {
const doseId = getDoseId(dose.id, person);
const isTaken = takenDoses.has(doseId);
return (
<div key={doseId} className={`dose-person ${isTaken ? "taken" : ""}`}>
{person && <span className="person-name clickable" onClick={() => openUserFilter(person)}>{person}</span>}
{isTaken ? (
<button className="dose-btn undo" onClick={() => undoDoseTaken(doseId)} title={t('common.undo')}></button>
) : (
<button className="dose-btn take" onClick={() => markDoseTaken(doseId)} title={t('dose.markAsTaken')} disabled={isFutureDose || isEmpty}></button>
)}
</div>
);
})}
</div>
</div>
);
})}
</div>
</div>
);
})}
</div>
);
})}
</div>
</article>
</section>
{/* Clear Missed Doses Confirmation Modal */}
{showClearMissedConfirm && (
<ConfirmModal
title={t('dashboard.schedules.clearMissedConfirmTitle')}
message={t('dashboard.schedules.clearMissedConfirmMessage', { count: missedPastDoseIds.length })}
confirmLabel={clearingMissed ? t('common.loading') : t('dashboard.schedules.clearMissedConfirm')}
cancelLabel={t('dashboard.schedules.clearMissedCancel')}
onConfirm={() => dismissMissedDoses(missedPastDoseIds)}
onCancel={() => setShowClearMissedConfirm(false)}
isLoading={clearingMissed}
/>
)}
</>
);
}
+530
View File
@@ -0,0 +1,530 @@
import { useState, useMemo, useEffect } from "react";
import { useTranslation } from "react-i18next";
import { useAppContext } from "../context";
import { MedicationAvatar, MobileEditModal } from "../components";
import { useMedicationForm } from "../hooks";
import { formatNumber, formatDateTime } from "../utils/formatters";
import { getPackageSize, FIELD_LIMITS } from "../types";
import type { Medication } from "../types";
export function MedicationsPage() {
const { t, i18n } = useTranslation();
const {
meds,
loading,
saving,
setSaving,
loadMeds,
deleteMed,
uploadMedImage,
deleteMedImage,
uploadingImage,
existingPeople,
refillPacks,
setRefillPacks,
refillLoose,
setRefillLoose,
refillSaving,
submitRefill,
} = useAppContext();
// Use the medication form hook
const {
form,
setForm,
editingId,
setEditingId,
formSaved,
setFormSaved,
formChanged,
fieldErrors,
hasValidationErrors,
takenByInput,
setTakenByInput,
addTakenByPerson,
removeTakenByPerson,
handleTakenByKeyDown,
handleValueChange,
addBlister,
removeBlister,
setBlisterValue,
resetForm,
startEdit,
} = useMedicationForm();
// Image state for new medications
const [pendingImage, setPendingImage] = useState<File | null>(null);
const [pendingImagePreview, setPendingImagePreview] = useState<string | null>(null);
// Mobile modal state
const [showEditModal, setShowEditModal] = useState(false);
// Calculate total tablets
const totalTablets = useMemo(() => {
const packCount = Number(form.packCount) || 0;
const blistersPerPack = Number(form.blistersPerPack) || 0;
const pillsPerBlister = Number(form.pillsPerBlister) || 1;
const looseTablets = Number(form.looseTablets) || 0;
return packCount * blistersPerPack * pillsPerBlister + looseTablets;
}, [form.packCount, form.blistersPerPack, form.pillsPerBlister, form.looseTablets]);
// Open mobile edit modal
function openEditModal() {
setShowEditModal(true);
window.history.pushState({ modal: 'edit' }, '');
}
// Close mobile edit modal
function closeEditModal() {
if (showEditModal) {
window.history.back();
}
}
// Handle delete medication
async function handleDeleteMed(id: number) {
if (!confirm(t('medications.deleteConfirm'))) return;
await deleteMed(id, editingId, resetForm);
}
// Handle submit refill
async function handleSubmitRefill(medId: number) {
await submitRefill(medId, editingId, setForm, loadMeds);
}
// Save medication
async function saveMedication(e: React.FormEvent) {
e.preventDefault();
if (saving) return;
setSaving(true);
// Prepare medication data
const blisters = form.blisters.map(b => ({
usage: Number(b.usage) || 1,
every: Number(b.every) || 1,
start: combineDateAndTime(b.startDate, b.startTime),
}));
const body = {
name: form.name.trim(),
genericName: form.genericName.trim() || null,
takenBy: form.takenBy.length > 0 ? form.takenBy : [],
packCount: Number(form.packCount) || 0,
blistersPerPack: Number(form.blistersPerPack) || 1,
pillsPerBlister: Number(form.pillsPerBlister) || 1,
looseTablets: Number(form.looseTablets) || 0,
pillWeightMg: Number(form.pillWeightMg) || null,
expiryDate: form.expiryDate || null,
notes: form.notes.trim() || null,
intakeRemindersEnabled: form.intakeRemindersEnabled,
blisters,
};
try {
let url = "/api/medications";
let method = "POST";
if (editingId) {
url = `/api/medications/${editingId}`;
method = "PUT";
}
const res = await fetch(url, {
method,
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
});
if (!res.ok) {
throw new Error("Failed to save");
}
const saved = await res.json();
// Upload image if pending (for new medications)
if (!editingId && pendingImage && saved.id) {
await uploadMedImage(saved.id, pendingImage);
setPendingImage(null);
setPendingImagePreview(null);
}
setFormSaved(true);
loadMeds();
// Reset form after successful save
if (!editingId) {
resetForm();
}
} catch (err) {
console.error("Save error:", err);
alert(t('common.saveFailed'));
}
setSaving(false);
}
// Handle browser back button for modals
useEffect(() => {
const handlePopState = () => {
if (showEditModal) {
setShowEditModal(false);
}
};
window.addEventListener('popstate', handlePopState);
return () => window.removeEventListener('popstate', handlePopState);
}, [showEditModal]);
// Close modal on Escape key
useEffect(() => {
const handleEscape = (e: KeyboardEvent) => {
if (e.key === "Escape" && showEditModal) {
closeEditModal();
resetForm();
}
};
document.addEventListener("keydown", handleEscape);
return () => document.removeEventListener("keydown", handleEscape);
}, [showEditModal]);
// Handle edit button click - open modal on mobile
function handleEditClick(med: Medication) {
startEdit(med, openEditModal);
}
return (
<section className="grid">
<article className="card meds">
<div className="card-head">
<h2>{t('medications.list.title')}</h2>
<button
type="button"
className="btn primary small"
onClick={() => {
resetForm();
// On mobile, open the edit modal
if (window.innerWidth <= 768) {
openEditModal();
}
}}
>
+ {t('form.newEntry')}
</button>
</div>
<div className="med-list">
{meds.map((med) => (
<div key={med.id} className="med-row">
<div className="med-header">
<div className="med-info">
<div className="med-name-row">
<MedicationAvatar name={med.name} imageUrl={med.imageUrl} size="lg" />
<div className="med-name">{med.name}</div>
</div>
<div className="med-details">
<span>{t('medications.details.packs')}: <strong>{med.packCount}</strong></span>
<span>{t('medications.details.blisters')}: <strong>{med.blistersPerPack}</strong></span>
<span>{t('medications.details.pillsPerBlister')}: <strong>{med.pillsPerBlister}</strong></span>
<span>{t('medications.details.loose')}: <strong>{med.looseTablets}</strong></span>
</div>
<div className="med-total">{t('medications.details.total')}: {getPackageSize(med)} {t('common.pills')}</div>
</div>
<div className="med-actions">
<button className="info" onClick={() => handleEditClick(med)}>{t('common.edit')}</button>
<button className="danger" onClick={() => handleDeleteMed(med.id)}>{t('common.delete')}</button>
</div>
</div>
<div className="blister-list">
{med.blisters.map((s, idx) => (
<div key={`${med.id}-${idx}`} className="blister-row-simple">
{s.usage} {s.usage === 1 ? t('common.pill') : t('common.pills')} · {t('form.blisters.every')} {s.every} {s.every === 1 ? t('common.day') : t('common.days')} · {t('form.blisters.from')} {formatDateTime(s.start, i18n.language)}
</div>
))}
</div>
</div>
))}
</div>
</article>
<article className="card form desktop-only">
<div className="card-head">
<h2>{editingId ? t('form.editEntry') : t('form.newEntry')}</h2>
</div>
<form className="form-grid" onSubmit={saveMedication}>
<label className={fieldErrors.name ? 'has-error' : ''}>
{t('form.commercialName')}
<input
value={form.name}
onChange={(e) => setForm({ ...form, name: e.target.value })}
placeholder={t('form.placeholders.commercial')}
maxLength={FIELD_LIMITS.name.max}
required
/>
{fieldErrors.name && <span className="field-error">{fieldErrors.name}</span>}
</label>
<label className={fieldErrors.genericName ? 'has-error' : ''}>
{t('form.genericName')}
<input
value={form.genericName}
onChange={(e) => setForm({ ...form, genericName: e.target.value })}
placeholder={t('form.placeholders.generic')}
maxLength={FIELD_LIMITS.genericName.max}
/>
{fieldErrors.genericName && <span className="field-error">{fieldErrors.genericName}</span>}
</label>
<label className={fieldErrors.takenBy ? 'has-error' : ''}>
{t('form.takenBy')}
<div className="tag-input-container">
{form.takenBy.map((person) => (
<span key={person} className="tag">
{person}
<button type="button" className="tag-remove" onClick={() => removeTakenByPerson(person)}>×</button>
</span>
))}
<input
value={takenByInput}
onChange={(e) => setTakenByInput(e.target.value)}
onKeyDown={handleTakenByKeyDown}
onBlur={() => { if (takenByInput.trim()) addTakenByPerson(takenByInput); }}
placeholder={form.takenBy.length === 0 ? t('form.placeholders.takenBy') : t('form.placeholders.addPerson')}
maxLength={FIELD_LIMITS.takenBy.max}
list="takenby-suggestions"
/>
<datalist id="takenby-suggestions">
{existingPeople.filter(p => !form.takenBy.includes(p)).map(person => (
<option key={person} value={person} />
))}
</datalist>
</div>
{fieldErrors.takenBy && <span className="field-error">{fieldErrors.takenBy}</span>}
</label>
<label>
{t('form.packs')}
<input type="number" min="0" value={form.packCount} onChange={(e) => handleValueChange("packCount", e.target.value)} />
</label>
<label>
{t('form.blistersPerPack')}
<input type="number" min="1" value={form.blistersPerPack} onChange={(e) => handleValueChange("blistersPerPack", e.target.value)} />
</label>
<label>
{t('form.pillsPerBlister')}
<input type="number" min="1" value={form.pillsPerBlister} onChange={(e) => handleValueChange("pillsPerBlister", e.target.value)} />
</label>
<label>
{t('form.loosePills')}
<input type="number" min="0" value={form.looseTablets} onChange={(e) => handleValueChange("looseTablets", e.target.value)} />
</label>
<label>
{t('form.pillWeight')}
<input type="number" min="1" value={form.pillWeightMg} onChange={(e) => handleValueChange("pillWeightMg", e.target.value)} placeholder={t('form.placeholders.weight')} />
</label>
<label>
{t('form.total')}
<div className="static-value">{formatNumber(totalTablets)}</div>
</label>
<label>
{t('form.expiryDate')}
<input type="date" value={form.expiryDate} onChange={(e) => handleValueChange("expiryDate", e.target.value)} placeholder={t('common.optional')} />
</label>
{/* Refill section - only shown when editing */}
{editingId && (
<div className="full refill-section">
<h4 className="refill-title">{t('refill.title')}</h4>
<div className="refill-form-inline">
<label>
{t('refill.packs')}
<input
type="number"
min="0"
value={refillPacks}
onChange={(e) => setRefillPacks(parseInt(e.target.value) || 0)}
/>
</label>
<label>
{t('refill.loosePills')}
<input
type="number"
min="0"
value={refillLoose}
onChange={(e) => setRefillLoose(parseInt(e.target.value) || 0)}
/>
</label>
<button
type="button"
className="success"
onClick={() => handleSubmitRefill(editingId!)}
disabled={(refillPacks < 1 && refillLoose < 1) || refillSaving}
>
{refillSaving ? t('refill.adding') : t('refill.button')}
</button>
{(refillPacks > 0 || refillLoose > 0) && (
<span className="refill-preview">+{refillPacks * Number(form.blistersPerPack || 0) * Number(form.pillsPerBlister || 1) + refillLoose} {t('common.pills')}</span>
)}
</div>
</div>
)}
<label className={`full ${fieldErrors.notes ? 'has-error' : ''}`}>
{t('form.notes')}
<textarea
value={form.notes}
onChange={(e) => handleValueChange("notes", e.target.value)}
placeholder={t('form.placeholders.notes')}
rows={2}
maxLength={FIELD_LIMITS.notes.max}
className="auto-resize"
onInput={(e) => { const t = e.target as HTMLTextAreaElement; t.style.height = 'auto'; t.style.height = t.scrollHeight + 'px'; }}
/>
{form.notes.length > 0 && (
<span className={`char-count ${form.notes.length > FIELD_LIMITS.notes.max * 0.9 ? 'warning' : ''}`}>
{t('common.validation.tooLong', { current: form.notes.length, max: FIELD_LIMITS.notes.max })}
</span>
)}
{fieldErrors.notes && <span className="field-error">{fieldErrors.notes}</span>}
</label>
<div className="full blisters">
<div className="card-head">
<h3>{t('form.blisters.title')}</h3>
<div className="blisters-actions">
<label className="inline-checkbox" title={t('form.blisters.remindTooltip')}>
<input
type="checkbox"
checked={form.intakeRemindersEnabled}
onChange={(e) => setForm(prev => ({ ...prev, intakeRemindersEnabled: e.target.checked }))}
/>
<span>🔔 {t('form.blisters.remind')}</span>
</label>
<button type="button" className="primary" onClick={addBlister}>+ {t('form.blisters.addIntake')}</button>
</div>
</div>
{form.blisters.map((s, idx) => (
<div key={idx} className="blister-row">
<div className="blister-inputs">
<label>
{t('form.blisters.usage')}
<input type="number" min="0" step="0.1" value={s.usage} onChange={(e) => setBlisterValue(idx, "usage", e.target.value)} />
</label>
<label>
{t('form.blisters.everyDays')}
<input type="number" min="1" value={s.every} onChange={(e) => setBlisterValue(idx, "every", e.target.value)} />
</label>
<label>
{t('form.blisters.startDate')}
<input type="date" value={s.startDate} onChange={(e) => setBlisterValue(idx, "startDate", e.target.value)} />
</label>
<label>
{t('form.blisters.startTime')}
<input type="time" value={s.startTime} onChange={(e) => setBlisterValue(idx, "startTime", e.target.value)} />
</label>
</div>
{form.blisters.length > 1 && (
<button type="button" className="danger" onClick={() => removeBlister(idx)}>{t('common.remove')}</button>
)}
</div>
))}
</div>
<div className="full image-upload-section">
<label className="setting-label">{t('form.medicationImage')}</label>
{(() => {
// When editing an existing medication
if (editingId) {
const currentMed = meds.find(m => m.id === editingId);
if (currentMed?.imageUrl) {
return (
<div className="image-preview">
<img src={`/api/images/${currentMed.imageUrl}`} alt={currentMed.name} />
<button type="button" className="danger" onClick={() => deleteMedImage(editingId)}>{t('form.removeImage')}</button>
</div>
);
}
return (
<input
type="file"
accept="image/jpeg,image/png,image/webp,image/gif"
onChange={(e) => e.target.files?.[0] && uploadMedImage(editingId, e.target.files[0])}
disabled={uploadingImage}
/>
);
}
// When creating a new medication
if (pendingImagePreview) {
return (
<div className="image-preview">
<img src={pendingImagePreview} alt="Preview" />
<button type="button" className="danger" onClick={() => { setPendingImage(null); setPendingImagePreview(null); }}>{t('form.removeImage')}</button>
</div>
);
}
return (
<input
type="file"
accept="image/jpeg,image/png,image/webp,image/gif"
onChange={(e) => {
const file = e.target.files?.[0];
if (file) {
setPendingImage(file);
const reader = new FileReader();
reader.onload = (ev) => setPendingImagePreview(ev.target?.result as string);
reader.readAsDataURL(file);
}
}}
/>
);
})()}
</div>
<div className="full align-end gap">
{editingId && (
<button type="button" className="ghost" onClick={resetForm}>
{t('common.cancel')}
</button>
)}
<button type="submit" disabled={saving || hasValidationErrors || (!formChanged && (formSaved || !!editingId))}>
{saving ? t('common.saving') : formSaved && !formChanged ? t('common.saved') : t('common.save')}
</button>
</div>
</form>
</article>
{/* Mobile Edit Modal */}
<MobileEditModal
show={showEditModal}
editingId={editingId}
form={form}
onFormChange={setForm}
fieldErrors={fieldErrors}
saving={saving}
formSaved={formSaved}
formChanged={formChanged}
hasValidationErrors={hasValidationErrors}
takenByInput={takenByInput}
onTakenByInputChange={setTakenByInput}
existingPeople={existingPeople}
onAddTakenByPerson={addTakenByPerson}
onRemoveTakenByPerson={removeTakenByPerson}
onTakenByKeyDown={handleTakenByKeyDown}
onSetBlisterValue={setBlisterValue}
onAddBlister={addBlister}
onRemoveBlister={removeBlister}
onHandleValueChange={handleValueChange}
refillPacks={refillPacks}
onRefillPacksChange={setRefillPacks}
refillLoose={refillLoose}
onRefillLooseChange={setRefillLoose}
refillSaving={refillSaving}
onSubmitRefill={handleSubmitRefill}
meds={meds}
onUploadMedImage={uploadMedImage}
onDeleteMedImage={deleteMedImage}
onClose={() => { closeEditModal(); }}
onResetForm={resetForm}
onSaveMedication={saveMedication}
/>
</section>
);
}
// Helper function to combine date and time into ISO datetime with Z suffix
function combineDateAndTime(date: string, time: string): string {
return `${date}T${time}:00.000Z`;
}
+185
View File
@@ -0,0 +1,185 @@
import { useState, useEffect } from "react";
import { useTranslation } from "react-i18next";
import { useAuth } from "../components/Auth";
import { useAppContext } from "../context";
import { MedicationAvatar } from "../components";
import type { PlannerRow } from "../types";
import { toInputValue } from "../utils/formatters";
// Date helpers
function todayIso(): string {
return new Date().toISOString().slice(0, 10);
}
function plusDaysIso(days: number): string {
const d = new Date();
d.setDate(d.getDate() + days);
return d.toISOString().slice(0, 10);
}
// Convert datetime-local value to ISO string
function toIsoString(value: string): string {
if (!value) return new Date().toISOString();
const date = new Date(value);
return Number.isNaN(date.getTime()) ? new Date().toISOString() : date.toISOString();
}
// Helper for user-specific localStorage keys
function userStorageKey(userId: number | undefined, key: string): string {
return userId ? `user_${userId}_${key}` : key;
}
export function PlannerPage() {
const { t } = useTranslation();
const { user } = useAuth();
const { meds, settings, openMedDetail } = useAppContext();
// Local state for planner
const [plannerRows, setPlannerRows] = useState<PlannerRow[]>([]);
const [plannerLoading, setPlannerLoading] = useState(false);
const [range, setRange] = useState<{ start: string; end: string }>({
start: toInputValue(todayIso()),
end: toInputValue(plusDaysIso(3))
});
const [sendingPlannerEmail, setSendingPlannerEmail] = useState(false);
const [plannerEmailResult, setPlannerEmailResult] = useState<{ success: boolean; message: string } | null>(null);
// 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]);
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"));
}
}
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);
}
return (
<section className="grid">
<article className="card">
<div className="card-head">
<h2>{t('planner.title')}</h2>
</div>
<form className="planner" onSubmit={runPlanner}>
<label>
{t('planner.from')}
<input type="datetime-local" step="60" value={range.start} onChange={(e) => setRange({ ...range, start: e.target.value })} />
</label>
<label>
{t('planner.until')}
<input type="datetime-local" step="60" value={range.end} onChange={(e) => setRange({ ...range, end: e.target.value })} />
</label>
<div className="planner-actions">
<button type="button" className="ghost" onClick={resetRange}>{t('common.reset')}</button>
<button type="submit" disabled={plannerLoading}>{plannerLoading ? t('planner.calculating') : t('planner.calculate')}</button>
</div>
</form>
{plannerRows.length > 0 && (
<>
<div className="table">
<div className="table-head">
<span>{t('planner.table.medication')}</span>
<span>{t('planner.table.usage')}</span>
<span>{t('planner.table.blistersNeeded')}</span>
<span>{t('planner.table.available')}</span>
<span>{t('table.status')}</span>
</div>
{plannerRows.map((row) => {
const med = meds.find(m => m.name === row.medicationName);
return (
<div key={row.medicationId} className="table-row clickable" onClick={() => med && openMedDetail(med)}>
<span data-label={t('planner.table.medication')} className="cell-with-avatar"><MedicationAvatar name={row.medicationName} imageUrl={med?.imageUrl} />{row.medicationName}</span>
<span data-label={t('planner.table.usage')}><strong>{row.plannerUsage}</strong>&nbsp;{t('common.pills')}</span>
<span data-label={t('planner.table.blisters')}>{row.blistersNeeded} × {row.blisterSize}</span>
<span data-label={t('planner.table.available')}>
{row.fullBlisters} {t('common.blisters')}{row.loosePills > 0 && ` + ${row.loosePills} ${t('common.pills')}`}
</span>
<span data-label={t('table.status')} className={row.enough ? "status-chip success" : "status-chip danger"}>{row.enough ? t('status.enough') : t('status.outOfStock')}</span>
</div>
);
})}
</div>
{settings.emailEnabled && settings.notificationEmail && (
<div className="planner-email-action">
<button type="button" className="ghost" onClick={sendPlannerEmail} disabled={sendingPlannerEmail}>
{sendingPlannerEmail ? t('common.sending') : t('planner.sendEmail')}
</button>
{plannerEmailResult && (
<span className={plannerEmailResult.success ? "success-text" : "danger-text"}>
{plannerEmailResult.message}
</span>
)}
</div>
)}
</>
)}
</article>
</section>
);
}
+245
View File
@@ -0,0 +1,245 @@
import { useTranslation } from "react-i18next";
import { useAuth } from "../components/Auth";
import { useAppContext } from "../context";
import { MedicationAvatar } from "../components";
import type { Coverage } from "../types";
// Helper for user-specific localStorage keys
function userStorageKey(userId: number | undefined, key: string): string {
return userId ? `user_${userId}_${key}` : key;
}
// Helper function to get stock status
function getStockStatus(daysLeft: number | null, medsLeft: number, settings: { lowStockDays: number; normalStockDays: number; highStockDays: number }) {
if (medsLeft <= 0 || daysLeft === null || daysLeft <= 0) return { className: "danger", label: "status.outOfStock" };
if (daysLeft <= settings.lowStockDays) return { className: "danger", label: "status.lowStock" };
if (daysLeft >= settings.highStockDays) return { className: "success", label: "status.highStock" };
return { className: "success", label: "status.normal" };
}
// Helper function to get worst stock status for a day
function getDayStockStatus(dayMeds: Array<{ medName: string }>, coverageByMed: Record<string, Coverage>, settings: { lowStockDays: number; normalStockDays: number; highStockDays: number }): string {
let worstLevel = 3; // 3=success, 2=warning, 1=danger
for (const item of dayMeds) {
const cov = coverageByMed[item.medName];
if (!cov) continue;
const status = getStockStatus(cov.daysLeft, cov.medsLeft, settings);
if (status.className === "danger") worstLevel = Math.min(worstLevel, 1);
else if (status.className === "warning") worstLevel = Math.min(worstLevel, 2);
}
return worstLevel === 1 ? "danger" : worstLevel === 2 ? "warning" : "success";
}
// Helper to get dose ID (with or without person)
function getDoseId(baseId: string, person: string | null): string {
return person ? `${baseId}-${person}` : baseId;
}
export function SchedulePage() {
const { t } = useTranslation();
const { user } = useAuth();
const {
meds,
settings,
scheduleDays,
setScheduleDays,
showPastDays,
setShowPastDays,
pastDays,
futureDays,
takenDoses,
markDoseTaken,
undoDoseTaken,
coverageByMed,
depletionByMed,
manuallyExpandedDays,
toggleDayCollapse,
openUserFilter,
} = useAppContext();
return (
<section className="grid">
<article className="card schedule-full">
<div className="card-head">
<h2>{t('dashboard.schedules.title')}</h2>
<select
className="schedule-days-select"
value={scheduleDays}
onChange={(e) => {
const val = Number(e.target.value);
setScheduleDays(val);
if (user?.id) localStorage.setItem(userStorageKey(user.id, "scheduleDays"), String(val));
}}
>
<option value={30}>{t('dashboard.schedules.1month')}</option>
<option value={90}>{t('dashboard.schedules.3months')}</option>
<option value={180}>{t('dashboard.schedules.6months')}</option>
</select>
</div>
<div className="timeline">
{/* Past days toggle */}
{pastDays.length > 0 && (() => {
const totalPastDoses = pastDays.flatMap(d => d.meds.flatMap(m => m.doses.flatMap(dose => (dose.takenBy || []).length > 0 ? dose.takenBy.map(p => `${dose.id}-${p}`) : [dose.id])));
const missedPastDoses = totalPastDoses.filter(id => !takenDoses.has(id)).length;
return (
<div
className={`past-days-toggle ${showPastDays ? 'expanded' : ''} ${missedPastDoses > 0 ? 'has-missed' : ''}`}
onClick={() => setShowPastDays(!showPastDays)}
>
<span className="past-days-icon">{showPastDays ? '▼' : '▶'}</span>
<span className="past-days-label">
{showPastDays ? t('dashboard.schedules.hidePastDays') : t('dashboard.schedules.showPastDays')}
</span>
<span className="past-days-count">({t('dashboard.schedules.pastDaysCount', { count: pastDays.length })})</span>
{missedPastDoses > 0 && <span className="past-days-warning" title={t('dashboard.schedules.missedDoses', { count: missedPastDoses })}> {missedPastDoses}</span>}
</div>
);
})()}
{/* Past days (when expanded) */}
{showPastDays && pastDays.map((day) => {
const allDoseIds = day.meds.flatMap((item) => item.doses.flatMap((d) => (d.takenBy || []).length > 0 ? d.takenBy.map((p) => `${d.id}-${p}`) : [d.id]));
const allDayTaken = allDoseIds.length > 0 && allDoseIds.every((id) => takenDoses.has(id));
const takenCount = allDoseIds.filter((id) => takenDoses.has(id)).length;
const isManuallyExpanded = manuallyExpandedDays.has(day.dateStr);
const isCollapsed = !isManuallyExpanded;
const worstStatus = getDayStockStatus(day.meds, coverageByMed, settings);
return (
<div key={day.dateStr} className={`day-block past ${isCollapsed ? "collapsed" : ""} ${allDayTaken ? "all-taken" : ""} stock-${worstStatus}`}>
<div
className="day-divider clickable"
onClick={() => toggleDayCollapse(day.dateStr, true)}
title={isCollapsed ? t('common.expand') : t('common.collapse')}
>
<span className="day-collapse-icon">{isCollapsed ? "▶" : "▼"}</span>
<span className="day-date">{day.dateStr}</span>
<span className="day-summary">
{allDayTaken ? (
<span className="day-complete"> {t('dashboard.schedules.allTaken')}</span>
) : (
<><span className="day-warning" title={t('dashboard.schedules.missedDoses', { count: allDoseIds.length - takenCount })}></span><span className="day-progress">{takenCount}/{allDoseIds.length}</span></>
)}
</span>
</div>
{!isCollapsed && day.meds.map((item) => {
const med = meds.find(m => m.name === item.medName);
const medCov = coverageByMed[item.medName];
const isEmpty = medCov ? medCov.medsLeft <= 0 : false;
const itemDoseIds = item.doses.flatMap((d) => (d.takenBy || []).length > 0 ? d.takenBy.map((p) => `${d.id}-${p}`) : [d.id]);
const allTaken = itemDoseIds.every((id) => takenDoses.has(id));
return (
<div key={`${day.dateStr}-${item.medName}`} className={`time-row ${allTaken ? "taken" : ""}`}>
<div className="time-main">
<div className="med-name"><MedicationAvatar name={item.medName} imageUrl={med?.imageUrl} size="sm" /><span className="med-name-text">{item.medName}</span>{med?.intakeRemindersEnabled && <span className="reminder-icon info-tooltip" data-tooltip={t('tooltips.intakeReminders')}>🔔</span>}</div>
<div className="tag-row">
<span className="tag subtle">{item.total} {t('common.pills')} {t('common.total')}</span>
</div>
</div>
<div className="doses-col">
{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 (
<div key={dose.id} className="dose-item past">
<span className="dose-time">{dose.timeStr}</span>
<span className="dose-usage">{dose.usage} {dose.usage !== 1 ? t('common.pills') : t('common.pill')}{med?.pillWeightMg && ` (${dose.usage * med.pillWeightMg} mg)`}</span>
<div className="dose-checks">
{people.map((person) => {
const doseId = getDoseId(dose.id, person);
const isTaken = takenDoses.has(doseId);
return (
<div key={doseId} className={`dose-person ${isTaken ? "taken" : ""}`}>
{person && <span className="person-name clickable" onClick={() => openUserFilter(person)}>{person}</span>}
{isTaken ? (
<button className="dose-btn undo" onClick={() => undoDoseTaken(doseId)} title={t('common.undo')}></button>
) : (
<button className="dose-btn take" onClick={() => markDoseTaken(doseId)} disabled={isEmpty} title={t('dose.markAsTaken')}></button>
)}
</div>
);
})}
</div>
</div>
);
})}
</div>
</div>
);
})}
</div>
);
})}
{/* Current and future days */}
{futureDays.map((day) => {
const today = new Date();
today.setHours(0, 0, 0, 0);
const dayDate = new Date(day.date);
dayDate.setHours(0, 0, 0, 0);
const isToday = dayDate.getTime() === today.getTime();
return (
<div key={day.dateStr} className={`day-block ${isToday ? "today" : ""}`}>
<div className="day-divider">{day.dateStr}</div>
{day.meds.map((item) => {
const medCoverage = coverageByMed[item.medName];
const isEmpty = medCoverage ? medCoverage.medsLeft <= 0 : false;
const med = meds.find(m => m.name === item.medName);
const depletionTime = depletionByMed[item.medName];
// Check if this dose is scheduled after medication runs out
const willBeOutOfStock = typeof depletionTime === "number" && item.lastWhen > depletionTime;
const status = willBeOutOfStock
? { className: "danger", label: "status.outOfStock" }
: medCoverage ? getStockStatus(medCoverage.daysLeft, medCoverage.medsLeft, settings) : null;
const itemDoseIds = item.doses.flatMap((d) => (d.takenBy || []).length > 0 ? d.takenBy.map((p) => `${d.id}-${p}`) : [d.id]);
const allTaken = itemDoseIds.every((id) => takenDoses.has(id));
return (
<div key={`${day.dateStr}-${item.medName}`} className={`time-row ${allTaken ? "taken" : ""}`}>
<div className="time-main">
<div className="med-name"><MedicationAvatar name={item.medName} imageUrl={med?.imageUrl} size="sm" /><span className="med-name-text">{item.medName}</span>{med?.intakeRemindersEnabled && <span className="reminder-icon info-tooltip" data-tooltip={t('tooltips.intakeReminders')}>🔔</span>}</div>
<div className="tag-row">
<span className="tag subtle">{item.total} {t('common.pills')} {t('common.total')}</span>
{status && <span className={`tag ${status.className}`}>
{t(status.label)}
</span>}
</div>
</div>
<div className="doses-col">
{item.doses.map((dose) => {
const people = (dose.takenBy || []).length > 0 ? dose.takenBy : [null];
const now = Date.now();
const dayStart = new Date(day.date).setHours(0, 0, 0, 0);
const isPastDay = dayStart < new Date().setHours(0, 0, 0, 0);
return (
<div key={dose.id} className="dose-item">
<span className="dose-time">{dose.timeStr}</span>
<span className="dose-usage">{dose.usage} {dose.usage !== 1 ? t('common.pills') : t('common.pill')}{med?.pillWeightMg && ` (${dose.usage * med.pillWeightMg} mg)`}</span>
<div className="dose-checks">
{people.map((person) => {
const doseId = getDoseId(dose.id, person);
const isTaken = takenDoses.has(doseId);
const isOverdue = !isTaken && dose.when < now && !isPastDay;
return (
<div key={doseId} className={`dose-person ${isTaken ? "taken" : ""} ${isOverdue ? "overdue" : ""}`}>
{person && <span className="person-name clickable" onClick={() => openUserFilter(person)}>{person}</span>}
{isTaken ? (
<button className="dose-btn undo" onClick={() => undoDoseTaken(doseId)} title={t('common.undo')}></button>
) : (
<button className="dose-btn take" onClick={() => markDoseTaken(doseId)} disabled={isEmpty} title={t('dose.markAsTaken')}></button>
)}
</div>
);
})}
</div>
</div>
);
})}
</div>
</div>
);
})}
</div>
);})}
</div>
</article>
</section>
);
}
+563
View File
@@ -0,0 +1,563 @@
import { useTranslation } from "react-i18next";
import { useAppContext } from "../context";
import { ConfirmModal, ExportModal } from "../components";
export function SettingsPage() {
const { t, i18n } = useTranslation();
const {
settings,
setSettings,
settingsLoading,
settingsSaving,
settingsSaved,
saveSettings,
settingsChanged,
// Email testing
testEmail,
testingEmail,
testEmailResult,
// Shoutrrr testing
testShoutrrr,
testingShoutrrr,
testShoutrrrResult,
// Export/Import
exporting,
importing,
showExportModal,
setShowExportModal,
handleExport,
handleImportFileSelect,
showImportConfirm,
setShowImportConfirm,
pendingImportData,
setPendingImportData,
handleImportConfirm,
importResult,
setImportResult,
} = useAppContext();
return (
<section className="grid">
{settingsLoading ? (
<p>{t('settings.loading')}</p>
) : (
<form className="settings-form" onSubmit={saveSettings}>
{/* Language */}
<article className="card">
<div className="card-head">
<h2>{t('settings.language.title')}</h2>
</div>
<div className="setting-section">
<label className="setting-row language-row">
<span className="setting-label">{t('settings.language.select')}</span>
<select
value={i18n.language}
onChange={(e) => i18n.changeLanguage(e.target.value)}
className="language-select"
>
<option value="en">🇬🇧 English</option>
<option value="de">🇩🇪 Deutsch</option>
</select>
</label>
</div>
</article>
{/* Notifications */}
<article className="card">
<div className="card-head">
<h2>{t('settings.notifications.title')}</h2>
</div>
<div className="setting-section">
<div className="section-header">
<h3>{t('settings.notifications.channels')}</h3>
</div>
<div className="notification-matrix">
<div className="matrix-header">
<div className="matrix-label"></div>
<div className="matrix-channel">{t('settings.notifications.email')}</div>
<div className="matrix-channel">{t('settings.notifications.push')}</div>
</div>
<div className="matrix-row">
<div className="matrix-label">{t('settings.notifications.stockReminders')}</div>
<div className="matrix-cell">
<label className={`toggle-switch small${!settings.emailEnabled ? ' disabled' : ''}`}>
<input
type="checkbox"
checked={settings.smtpHost && settings.emailEnabled ? settings.emailStockReminders : false}
onChange={(e) => setSettings({ ...settings, emailStockReminders: e.target.checked })}
disabled={!settings.emailEnabled}
/>
<span className="toggle-slider"></span>
</label>
</div>
<div className="matrix-cell">
<label className={`toggle-switch small${!settings.shoutrrrEnabled ? ' disabled' : ''}`}>
<input
type="checkbox"
checked={settings.shoutrrrUrl && settings.shoutrrrEnabled ? settings.shoutrrrStockReminders : false}
onChange={(e) => setSettings({ ...settings, shoutrrrStockReminders: e.target.checked })}
disabled={!settings.shoutrrrEnabled}
/>
<span className="toggle-slider"></span>
</label>
</div>
</div>
<div className="matrix-row">
<div className="matrix-label">{t('settings.notifications.intakeReminders')}</div>
<div className="matrix-cell">
<label className={`toggle-switch small${!settings.emailEnabled ? ' disabled' : ''}`}>
<input
type="checkbox"
checked={settings.smtpHost && settings.emailEnabled ? settings.emailIntakeReminders : false}
onChange={(e) => setSettings({ ...settings, emailIntakeReminders: e.target.checked })}
disabled={!settings.emailEnabled}
/>
<span className="toggle-slider"></span>
</label>
</div>
<div className="matrix-cell">
<label className={`toggle-switch small${!settings.shoutrrrEnabled ? ' disabled' : ''}`}>
<input
type="checkbox"
checked={settings.shoutrrrUrl && settings.shoutrrrEnabled ? settings.shoutrrrIntakeReminders : false}
onChange={(e) => setSettings({ ...settings, shoutrrrIntakeReminders: e.target.checked })}
disabled={!settings.shoutrrrEnabled}
/>
<span className="toggle-slider"></span>
</label>
</div>
</div>
</div>
{!settings.emailEnabled && !settings.shoutrrrEnabled && (
<p className="hint-text">{t('settings.notifications.enableHint')}</p>
)}
{/* Skip reminders for taken doses */}
<div className="setting-row compact" style={{marginTop: "16px"}}>
<label className="setting-label">
{t('settings.notifications.skipTakenDoses')}
<span className="info-tooltip small" data-tooltip={t('settings.notifications.skipTakenDosesTooltip')}></span>
</label>
<label className={`toggle-switch small${!settings.emailEnabled && !settings.shoutrrrEnabled ? ' disabled' : ''}`}>
<input
type="checkbox"
checked={settings.skipRemindersForTakenDoses}
onChange={(e) => setSettings({ ...settings, skipRemindersForTakenDoses: e.target.checked })}
disabled={!settings.emailEnabled && !settings.shoutrrrEnabled}
/>
<span className="toggle-slider"></span>
</label>
</div>
{/* Repeat reminders for missed doses */}
<div className="setting-row compact" style={{marginTop: "12px"}}>
<label className="setting-label">
{t('settings.notifications.repeatReminders')}
<span className="info-tooltip small" data-tooltip={t('settings.notifications.repeatRemindersTooltip')}></span>
</label>
<label className={`toggle-switch small${!settings.emailEnabled && !settings.shoutrrrEnabled ? ' disabled' : ''}`}>
<input
type="checkbox"
checked={settings.repeatRemindersEnabled}
onChange={(e) => setSettings({ ...settings, repeatRemindersEnabled: e.target.checked })}
disabled={!settings.emailEnabled && !settings.shoutrrrEnabled}
/>
<span className="toggle-slider"></span>
</label>
</div>
{/* Reminder interval (only shown when repeat is enabled) */}
{settings.repeatRemindersEnabled && (
<>
<div className="setting-row compact" style={{marginTop: "12px", marginLeft: "24px"}}>
<label className="setting-label">
{t('settings.notifications.reminderInterval')}
<span className="info-tooltip small" data-tooltip={t('settings.notifications.reminderIntervalTooltip')}></span>
</label>
<input
type="number"
min="5"
max="480"
step="5"
value={settings.reminderRepeatIntervalMinutes}
onChange={(e) => setSettings({ ...settings, reminderRepeatIntervalMinutes: parseInt(e.target.value) || 30 })}
style={{width: "80px", textAlign: "center"}}
/>
</div>
<div className="setting-row compact" style={{marginTop: "8px", marginLeft: "24px"}}>
<label className="setting-label">
{t('settings.notifications.maxNaggingReminders')}
<span className="info-tooltip small" data-tooltip={t('settings.notifications.maxNaggingRemindersTooltip')}></span>
</label>
<input
type="number"
min="1"
max="20"
step="1"
value={settings.maxNaggingReminders ?? 5}
onChange={(e) => setSettings({ ...settings, maxNaggingReminders: parseInt(e.target.value) || 5 })}
style={{width: "80px", textAlign: "center"}}
/>
</div>
</>
)}
</div>
<div className="setting-section">
<div className="section-header">
<h3>{t('settings.notifications.email')}</h3>
<label className={`toggle-switch small${!settings.smtpHost ? ' disabled' : ''}`}>
<input
type="checkbox"
checked={settings.smtpHost ? settings.emailEnabled : false}
onChange={(e) => {
const newVal = e.target.checked;
if (!newVal && !settings.shoutrrrEnabled) {
setSettings({ ...settings, emailEnabled: false, emailStockReminders: false, emailIntakeReminders: false, skipRemindersForTakenDoses: false, repeatRemindersEnabled: false });
} else {
setSettings({ ...settings, emailEnabled: newVal });
}
}}
disabled={!settings.smtpHost}
/>
<span className="toggle-slider"></span>
</label>
</div>
{settings.emailEnabled && (
<>
<div className="setting-group">
<label className="full">
<span className="field-label">{t('settings.email.recipient')}</span>
<div className="input-with-tooltip">
<input
type="email"
value={settings.notificationEmail}
onChange={(e) => setSettings({ ...settings, notificationEmail: e.target.value })}
placeholder="your@email.com"
pattern="[a-z0-9._%+\-]+@[a-z0-9.\-]+\.[a-z]{2,}$"
autoComplete="email"
/>
<span className="info-tooltip" data-tooltip={`SMTP: ${settings.smtpHost || t('settings.email.notConfigured')}:${settings.smtpPort}${settings.hasSmtpPassword ? '\nPassword: ✓' : ''}`}></span>
</div>
</label>
</div>
<div className="setting-actions">
<button type="button" className="ghost" onClick={testEmail} disabled={testingEmail || !settings.notificationEmail}>
{testingEmail ? t('common.sending') : t('common.test')}
</button>
{testEmailResult && (
<span className={testEmailResult.success ? "success-text" : "danger-text"}>
{testEmailResult.message}
</span>
)}
</div>
</>
)}
</div>
<div className="setting-section">
<div className="section-header">
<h3>{t('settings.notifications.push')}</h3>
<label className="toggle-switch small">
<input
type="checkbox"
checked={settings.shoutrrrEnabled}
onChange={(e) => {
const newVal = e.target.checked;
if (!newVal && !settings.emailEnabled) {
setSettings({ ...settings, shoutrrrEnabled: false, shoutrrrStockReminders: false, shoutrrrIntakeReminders: false, skipRemindersForTakenDoses: false, repeatRemindersEnabled: false });
} else {
setSettings({ ...settings, shoutrrrEnabled: newVal });
}
}}
/>
<span className="toggle-slider"></span>
</label>
</div>
{settings.shoutrrrEnabled && (
<>
<div className="setting-group">
<label className="full">
<span className="field-label">{t('settings.push.url')}</span>
<div className="input-with-tooltip">
<input
type="text"
value={settings.shoutrrrUrl}
onChange={(e) => setSettings({ ...settings, shoutrrrUrl: e.target.value })}
placeholder={t('settings.push.urlPlaceholder')}
/>
<span className="info-tooltip" data-tooltip={`${t('settings.push.supports')}\n\n${t('settings.push.docsLink')}`}></span>
</div>
</label>
</div>
<div className="setting-actions">
<button type="button" className="ghost" onClick={testShoutrrr} disabled={testingShoutrrr || !settings.shoutrrrUrl}>
{testingShoutrrr ? t('common.sending') : t('common.test')}
</button>
{testShoutrrrResult && (
<span className={testShoutrrrResult.success ? "success-text" : "danger-text"}>
{testShoutrrrResult.message}
</span>
)}
</div>
</>
)}
</div>
<div className="schedule-overview">
<div className="schedule-header">
<span className="schedule-title">{t('settings.schedule.title')}</span>
<span className="info-tooltip" data-tooltip={t('settings.schedule.envHint')}></span>
</div>
<div className="schedule-row">
<span className="schedule-label">{t('settings.schedule.stockCheck')}</span>
<span className="schedule-value">{t('settings.schedule.dailyAt6')}</span>
</div>
<div className="schedule-row">
<span className="schedule-label">{t('settings.schedule.intakeCheck')}</span>
<span className="schedule-value">{t('settings.schedule.15minBefore')}</span>
</div>
{settings.nextScheduledCheck && (
<div className="schedule-row">
<span className="schedule-label">{t('settings.schedule.nextCheck')}</span>
<span className="schedule-value">{new Date(settings.nextScheduledCheck).toLocaleString(i18n.language, { day: "2-digit", month: "2-digit", year: "numeric", hour: "2-digit", minute: "2-digit" })}</span>
</div>
)}
{settings.lastAutoEmailSent && (
<div className="schedule-row">
<span className="schedule-label">{t('settings.schedule.lastSent')}</span>
<span className="schedule-value">{new Date(settings.lastAutoEmailSent).toLocaleString(i18n.language, { day: "2-digit", month: "2-digit", year: "numeric", hour: "2-digit", minute: "2-digit" })}</span>
</div>
)}
</div>
</article>
{/* Stock Settings */}
<article className="card">
<div className="card-head">
<h2>{t('settings.stock.title')}</h2>
</div>
<div className="setting-section">
<div className="section-header">
<h3>{t('settings.stock.threshold')}</h3>
</div>
<div className="threshold-input">
<label>
<span className="threshold-label">{t('settings.stock.remindWhen')}</span>
<div className="threshold-field">
<input
type="number"
min="1"
max="90"
value={settings.reminderDaysBefore}
onChange={(e) => setSettings({ ...settings, reminderDaysBefore: Number(e.target.value) || 7 })}
/>
<span className="threshold-unit">{t('common.days')}</span>
</div>
</label>
</div>
<div className="setting-row compact">
<label className="setting-label">
{t('settings.stock.repeatDaily')}
<span className="info-tooltip small" data-tooltip={t('settings.stock.repeatTooltip')}></span>
</label>
<label className={`toggle-switch small${!((settings.emailEnabled && settings.emailStockReminders && settings.notificationEmail) || (settings.shoutrrrEnabled && settings.shoutrrrStockReminders && settings.shoutrrrUrl)) ? ' disabled' : ''}`}>
<input
type="checkbox"
checked={settings.repeatDailyReminders}
onChange={(e) => setSettings({ ...settings, repeatDailyReminders: e.target.checked })}
disabled={!((settings.emailEnabled && settings.emailStockReminders && settings.notificationEmail) || (settings.shoutrrrEnabled && settings.shoutrrrStockReminders && settings.shoutrrrUrl))}
/>
<span className="toggle-slider"></span>
</label>
</div>
</div>
<div className="setting-section">
<div className="section-header">
<h3>{t('settings.stock.calculationMode')}</h3>
</div>
<div className="setting-group calculation-mode-group">
<label className={`radio-card ${settings.stockCalculationMode === 'automatic' ? 'selected' : ''}`}>
<input
type="radio"
name="stockCalculationMode"
value="automatic"
checked={settings.stockCalculationMode === 'automatic'}
onChange={(e) => setSettings({ ...settings, stockCalculationMode: e.target.value as 'automatic' | 'manual' })}
/>
<div className="radio-card-content">
<div className="radio-card-text">
<span className="radio-card-title">{t('settings.stock.automatic')}</span>
<span className="radio-card-desc">{t('settings.stock.automaticDesc')}</span>
</div>
</div>
</label>
<label className={`radio-card ${settings.stockCalculationMode === 'manual' ? 'selected' : ''}`}>
<input
type="radio"
name="stockCalculationMode"
value="manual"
checked={settings.stockCalculationMode === 'manual'}
onChange={(e) => setSettings({ ...settings, stockCalculationMode: e.target.value as 'automatic' | 'manual' })}
/>
<div className="radio-card-content">
<div className="radio-card-text">
<span className="radio-card-title">{t('settings.stock.manual')}</span>
<span className="radio-card-desc">{t('settings.stock.manualDesc')}</span>
</div>
</div>
</label>
</div>
</div>
<div className="setting-section">
<div className="section-header">
<h3>{t('settings.stock.display')}</h3>
</div>
<div className="setting-group">
<label>
<span className="field-label">{t('settings.stock.lowStockDays')}</span>
<div className="input-with-tooltip">
<input
type="number"
min="1"
max="365"
value={settings.lowStockDays}
onChange={(e) => setSettings({ ...settings, lowStockDays: Number(e.target.value) || 30 })}
/>
<span className="info-tooltip" data-tooltip={t('settings.stock.lowStockTooltip')}></span>
</div>
</label>
<label>
<span className="field-label">{t('settings.stock.highStockDays')}</span>
<div className="input-with-tooltip">
<input
type="number"
min="1"
max="730"
value={settings.highStockDays}
onChange={(e) => setSettings({ ...settings, highStockDays: Number(e.target.value) || 180 })}
/>
<span className="info-tooltip" data-tooltip={t('settings.stock.highStockTooltip')}></span>
</div>
</label>
</div>
</div>
</article>
{/* Export/Import Section */}
<article className="card">
<div className="card-head">
<h2>
{t('exportImport.title')}
<span className="info-tooltip" data-tooltip={t('exportImport.description')}></span>
</h2>
</div>
<div className="setting-section">
<div className="setting-group">
{/* Import Success Message */}
{importResult && (
<div className="success-banner" style={{marginBottom: '16px', padding: '12px 16px', borderRadius: '8px', backgroundColor: 'var(--success-bg)', border: '1px solid var(--success)', color: 'var(--text-primary)'}}>
<div style={{display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start'}}>
<div>
<strong style={{display: 'block', marginBottom: '4px', color: 'var(--success)'}}> {t('exportImport.importSuccess')}</strong>
<span style={{fontSize: '0.9em'}}>{t('exportImport.importSuccessDetails', {
medications: importResult.medications,
doses: importResult.doses,
shares: importResult.shares
})}</span>
</div>
<button
type="button"
onClick={() => setImportResult(null)}
style={{background: 'none', border: 'none', cursor: 'pointer', fontSize: '1.2em', padding: '0', lineHeight: '1', color: 'inherit', opacity: 0.7}}
aria-label="Close"
>×</button>
</div>
</div>
)}
{/* Export */}
<div className="action-card">
<div className="action-card-content">
<span className="action-card-title">{t('exportImport.exportTitle')}</span>
<span className="action-card-desc">{t('exportImport.exportDesc')}</span>
</div>
<button
type="button"
className="secondary"
onClick={() => setShowExportModal(true)}
disabled={exporting}
>
{exporting ? t('exportImport.exporting') : t('exportImport.export')}
</button>
</div>
{/* Import */}
<div className="action-card">
<div className="action-card-content">
<span className="action-card-title">{t('exportImport.importTitle')}</span>
<span className="action-card-desc">{t('exportImport.importDesc')}</span>
</div>
<input
type="file"
id="import-file-input"
accept=".json,application/json"
onChange={handleImportFileSelect}
disabled={importing}
style={{display: 'none'}}
/>
<button
type="button"
className="secondary"
onClick={() => document.getElementById('import-file-input')?.click()}
disabled={importing}
>
{importing ? t('exportImport.importing') : t('exportImport.import')}
</button>
</div>
</div>
</div>
</article>
<div className="form-footer">
<button type="submit" disabled={settingsSaving || (!settingsChanged && settingsSaved)}>
{settingsSaving ? t('common.saving') : settingsSaved && !settingsChanged ? t('common.saved') : t('settings.saveSettings')}
</button>
</div>
</form>
)}
{/* Import Confirmation Modal */}
{showImportConfirm && (
<ConfirmModal
title={t('exportImport.confirmImport')}
message={
<>
<p style={{ marginBottom: "12px" }}>{t('exportImport.confirmImportMessage')}</p>
<p className="warning-text"> {t('exportImport.confirmImportWarning')}</p>
</>
}
confirmLabel={t('exportImport.confirmButton')}
cancelLabel={t('exportImport.cancelButton')}
onConfirm={handleImportConfirm}
onCancel={() => {
setShowImportConfirm(false);
setPendingImportData(null);
}}
confirmVariant="danger"
/>
)}
{/* Export Options Modal */}
<ExportModal
isOpen={showExportModal}
onClose={() => setShowExportModal(false)}
onExport={handleExport}
exporting={exporting}
/>
</section>
);
}
+6
View File
@@ -0,0 +1,6 @@
// Pages barrel export
export { DashboardPage } from "./DashboardPage";
export { MedicationsPage } from "./MedicationsPage";
export { PlannerPage } from "./PlannerPage";
export { SchedulePage } from "./SchedulePage";
export { SettingsPage } from "./SettingsPage";
+173
View File
@@ -0,0 +1,173 @@
// =============================================================================
// Core Types for MedAssist
// =============================================================================
export type Blister = {
usage: number;
every: number;
start: string;
};
export type Medication = {
id: number;
name: string;
genericName?: string | null;
takenBy: string[];
packCount: number;
blistersPerPack: number;
pillsPerBlister: number;
looseTablets: number;
stockAdjustment?: number;
lastStockCorrectionAt?: string | null;
pillWeightMg?: number | null;
blisters: Blister[];
imageUrl?: string | null;
expiryDate?: string | null;
notes?: string | null;
intakeRemindersEnabled?: boolean;
updatedAt: string | number | null;
};
export type PlannerRow = {
medicationId: number;
medicationName: string;
totalPills: number;
plannerUsage: number;
blisterSize: number;
blistersNeeded: number;
fullBlisters: number;
loosePills: number;
enough: boolean;
};
export type RefillEntry = {
id: number;
packsAdded: number;
loosePillsAdded: number;
refillDate: string;
};
export type FormBlister = {
usage: string;
every: string;
startDate: string;
startTime: string;
};
export type FormState = {
name: string;
genericName: string;
takenBy: string[];
packCount: string;
blistersPerPack: string;
pillsPerBlister: string;
looseTablets: string;
pillWeightMg: string;
expiryDate: string;
notes: string;
intakeRemindersEnabled: boolean;
blisters: FormBlister[];
};
export type FieldErrors = {
name?: string;
genericName?: string;
takenBy?: string;
notes?: string;
};
export type Coverage = {
name: string;
medsLeft: number;
daysLeft: number | null;
depletionDate: string | null;
depletionTime: number | null;
nextDose: string | null;
};
export type StockStatus = {
level: "out-of-stock" | "low" | "normal" | "high";
className: string;
label: string;
};
export type StockThresholds = {
lowStockDays: number;
normalStockDays: number;
highStockDays: number;
};
export type ScheduleEvent = {
id: string;
medName: string;
timeStr: string;
dateStr: string;
usage: number;
when: number;
isPast: boolean;
takenBy: string[];
};
export type BlisterStock = {
fullBlisters: number;
openBlisterPills: number;
loosePills: number;
};
// Shared schedule types
export type SharedMedication = {
id: number;
name: string;
genericName?: string | null;
pillWeightMg?: number | null;
imageUrl?: string | null;
totalPills: number;
packCount: number;
blistersPerPack: number;
looseTablets: number;
pillsPerBlister: number;
takenBy: string[];
blisters: Blister[];
};
export type SharedScheduleData = {
takenBy: string;
sharedBy: string | null;
scheduleDays: number;
medications: SharedMedication[];
stockThresholds?: {
lowStockDays: number;
};
};
export type ExpiredLinkData = {
ownerUsername: string;
takenBy: string;
expiredAt: string;
};
// =============================================================================
// Field Validation Limits (must match backend)
// =============================================================================
export const FIELD_LIMITS = {
name: { min: 1, max: 100 },
genericName: { max: 100 },
takenBy: { max: 100 },
notes: { max: 2000 }
} as const;
// =============================================================================
// Helper Functions for Medication Calculations
// =============================================================================
type MedLike = Pick<Medication, 'packCount' | 'blistersPerPack' | 'pillsPerBlister' | 'looseTablets'> & { stockAdjustment?: number };
/** Calculate total pills including stockAdjustment */
export function getMedTotal(med: MedLike): number {
return med.packCount * med.blistersPerPack * med.pillsPerBlister + med.looseTablets + (med.stockAdjustment ?? 0);
}
/** Get the base package size (without stockAdjustment) */
export function getPackageSize(med: MedLike): number {
return med.packCount * med.blistersPerPack * med.pillsPerBlister + med.looseTablets;
}
+153
View File
@@ -0,0 +1,153 @@
// =============================================================================
// Formatting Utilities
// =============================================================================
import type { Medication, BlisterStock } from "../types";
/**
* Format a number using the current locale with optional decimal places
*/
export function formatNumber(n: number | null | undefined, decimals = 0): string {
if (n === null || n === undefined) return "—";
return n.toLocaleString(undefined, {
minimumFractionDigits: decimals,
maximumFractionDigits: decimals
});
}
/**
* Format a date/time string for display
*/
export function formatDateTime(iso: string | null | undefined, locale?: string): string {
if (!iso) return "-";
const d = new Date(iso);
if (isNaN(d.getTime())) return "-";
const dateOpts: Intl.DateTimeFormatOptions = {
year: "numeric",
month: "2-digit",
day: "2-digit"
};
const timeOpts: Intl.DateTimeFormatOptions = {
hour: "2-digit",
minute: "2-digit"
};
const dateStr = d.toLocaleDateString(locale, dateOpts);
const timeStr = d.toLocaleTimeString(locale, timeOpts);
return `${dateStr} ${timeStr}`;
}
/**
* Pad a number to 2 digits with leading zero
*/
export function pad2(n: number): string {
return n.toString().padStart(2, "0");
}
/**
* Convert a Date to ISO date string (YYYY-MM-DD)
*/
export function toIsoString(d: Date): string {
return `${d.getFullYear()}-${pad2(d.getMonth() + 1)}-${pad2(d.getDate())}`;
}
/**
* Get the date portion (YYYY-MM-DD) from an ISO datetime string or Date
*/
export function toDateValue(input: string | Date): string {
if (input instanceof Date) {
return toIsoString(input);
}
return input.slice(0, 10);
}
/**
* Get the time portion (HH:MM) from an ISO datetime string or Date
*/
export function toTimeValue(input: string | Date): string {
const d = input instanceof Date ? input : new Date(input);
return `${pad2(d.getHours())}:${pad2(d.getMinutes())}`;
}
/**
* Combine a date string (YYYY-MM-DD) and time string (HH:MM) into ISO datetime
*/
export function combineDateAndTime(dateStr: string, timeStr: string): string {
return `${dateStr}T${timeStr}:00`;
}
/**
* Format a Date or ISO string to datetime-local input value (YYYY-MM-DDTHH:MM)
*/
export function toInputValue(input: Date | string): string {
const d = input instanceof Date ? input : new Date(input);
return `${d.getFullYear()}-${pad2(d.getMonth() + 1)}-${pad2(d.getDate())}T${pad2(d.getHours())}:${pad2(d.getMinutes())}`;
}
/**
* Derive total pills from medication inventory
*/
export function deriveTotal(
packCount: number,
blistersPerPack: number,
pillsPerBlister: number,
looseTablets: number
): number {
return packCount * blistersPerPack * pillsPerBlister + looseTablets;
}
/**
* Get CSS class for expiry date status
*/
export function getExpiryClass(expiryDate: string | null | undefined, thresholdDays: number): string {
if (!expiryDate) return "";
const exp = new Date(expiryDate);
const now = new Date();
const diff = (exp.getTime() - now.getTime()) / (1000 * 60 * 60 * 24);
if (diff < 0) return "expired";
if (diff <= thresholdDays) return "expiring-soon";
return "";
}
/**
* Calculate blister stock breakdown for a medication
*/
export function getBlisterStock(med: Medication): BlisterStock {
const total = med.packCount * med.blistersPerPack * med.pillsPerBlister + med.looseTablets + (med.stockAdjustment ?? 0);
const bSize = med.pillsPerBlister;
const fullBlisters = Math.floor(total / bSize);
const openBlisterPills = total % bSize;
return { fullBlisters, openBlisterPills, loosePills: openBlisterPills };
}
/**
* Format full blisters count with optional pills per blister
*/
export function formatFullBlisters(stock: BlisterStock, pillsPerBlister?: number): string {
const count = stock.fullBlisters;
if (pillsPerBlister !== undefined) {
return `${count} (${count * pillsPerBlister})`;
}
return String(count);
}
/**
* Format open blister and loose pills
*/
export function formatOpenBlisterAndLoose(stock: BlisterStock): string {
return String(stock.openBlisterPills);
}
/**
* Compare semantic version strings
* Returns: negative if a < b, positive if a > b, 0 if equal
*/
export function compareSemver(a: string, b: string): number {
const pa = a.replace(/^v/, "").split(".").map(Number);
const pb = b.replace(/^v/, "").split(".").map(Number);
for (let i = 0; i < Math.max(pa.length, pb.length); i++) {
const na = pa[i] ?? 0;
const nb = pb[i] ?? 0;
if (na !== nb) return na - nb;
}
return 0;
}
+72
View File
@@ -0,0 +1,72 @@
// =============================================================================
// ICS Calendar Generation
// =============================================================================
import type { Medication } from "../types";
/**
* Format a Date for ICS format (YYYYMMDDTHHMMSSZ)
*/
function formatICSDate(date: Date): string {
return date.toISOString().replace(/[-:]/g, "").replace(/\.\d{3}/, "");
}
/**
* Generate and download an ICS calendar file for a medication's schedule
*/
export function generateICS(med: Medication): void {
const events = med.blisters
.map((blister, idx) => {
const start = new Date(blister.start);
const end = new Date(start.getTime() + 15 * 60 * 1000); // 15 min duration
const interval = blister.every;
const pillInfo = `${blister.usage} pill${blister.usage !== 1 ? "s" : ""}${med.pillWeightMg ? ` (${blister.usage * med.pillWeightMg} mg)` : ""}`;
const summary = `💊 ${med.name} - ${pillInfo}`;
const description = [
`Medication: ${med.name}`,
med.genericName ? `Generic: ${med.genericName}` : "",
med.takenBy && med.takenBy.length > 0 ? `For: ${med.takenBy.join(", ")}` : "",
`Dosage: ${pillInfo}`,
`Frequency: every ${interval} day${interval !== 1 ? "s" : ""}`,
med.notes ? `Notes: ${med.notes}` : ""
]
.filter(Boolean)
.join("\\n");
return `BEGIN:VEVENT
UID:medassist-ng-${med.id}-${idx}@medassist-ng
DTSTAMP:${formatICSDate(new Date())}
DTSTART:${formatICSDate(start)}
DTEND:${formatICSDate(end)}
RRULE:FREQ=DAILY;INTERVAL=${interval}
SUMMARY:${summary}
DESCRIPTION:${description}
BEGIN:VALARM
TRIGGER:-PT5M
ACTION:DISPLAY
DESCRIPTION:Time to take ${med.name}
END:VALARM
END:VEVENT`;
})
.join("\n");
const ics = `BEGIN:VCALENDAR
VERSION:2.0
PRODID:-//MedAssist-ng//Medication Schedule//EN
CALSCALE:GREGORIAN
METHOD:PUBLISH
X-WR-CALNAME:${med.name} Schedule
${events}
END:VCALENDAR`;
const blob = new Blob([ics], { type: "text/calendar;charset=utf-8" });
const url = URL.createObjectURL(blob);
const link = document.createElement("a");
link.href = url;
link.download = `${med.name.replace(/[^a-zA-Z0-9]/g, "_")}_schedule.ics`;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
URL.revokeObjectURL(url);
}
+8
View File
@@ -0,0 +1,8 @@
// =============================================================================
// Utility Functions - Barrel Export
// =============================================================================
export * from "./formatters";
export * from "./schedule";
export * from "./storage";
export * from "./ics";
+275
View File
@@ -0,0 +1,275 @@
// =============================================================================
// Schedule Building and Coverage Calculations
// =============================================================================
import type { Medication, Coverage, StockStatus, StockThresholds, ScheduleEvent } from "../types";
import { getMedTotal } from "../types";
/**
* Build schedule preview events for medications
*/
export function buildSchedulePreview(
meds: Medication[],
locale: string,
includePast: boolean = false
): { events: ScheduleEvent[]; today: number; nextThree: number; totalBlisters: number } {
const events: ScheduleEvent[] = [];
if (!Array.isArray(meds)) return { events, today: 0, nextThree: 0, totalBlisters: 0 };
const now = new Date();
const todayStart = new Date(now.getFullYear(), now.getMonth(), now.getDate());
const end = new Date();
end.setDate(end.getDate() + 180); // 6 months horizon
meds.forEach((med) => {
med.blisters.forEach((blister, idx) => {
const start = new Date(blister.start);
if (Number.isNaN(start.getTime())) return;
for (let d = new Date(start); d <= end; d.setDate(d.getDate() + blister.every)) {
const isPast = d < todayStart;
if (isPast && !includePast) continue;
const whenMs = d.getTime();
events.push({
id: `${med.id}-${idx}-${whenMs}`,
medName: med.name,
takenBy: med.takenBy || [],
usage: blister.usage,
when: whenMs,
isPast,
timeStr: d.toLocaleTimeString(locale, { hour: "2-digit", minute: "2-digit" }),
dateStr: d.toLocaleDateString(locale, { weekday: "short", day: "2-digit", month: "short" })
});
}
});
});
events.sort((a, b) => a.when - b.when);
const todayCount = events.filter((e) => {
const t = new Date(e.when);
const n = new Date();
return t.getFullYear() === n.getFullYear() && t.getMonth() === n.getMonth() && t.getDate() === n.getDate();
}).length;
return {
events,
today: todayCount,
nextThree: events.length,
totalBlisters: meds.reduce((acc, m) => acc + m.blisters.length, 0)
};
}
/**
* Calculate coverage information for medications
*/
export function calculateCoverage(
meds: Medication[],
events: Array<{ medName: string; when: number }>,
locale: string,
reminderDaysBefore: number,
stockCalculationMode: "automatic" | "manual",
takenDoses: Set<string>
): { low: Coverage[]; all: Coverage[] } {
const MS_PER_DAY = 86_400_000;
const now = Date.now();
const coverage: Coverage[] = meds.map((m) => {
const personCount = Math.max(1, m.takenBy?.length || 1);
const dailyRate = m.blisters.reduce((sum, s) => sum + (s.every > 0 ? s.usage / s.every : 0), 0) * personCount;
let consumed = 0;
const stockCorrectionCutoff = m.lastStockCorrectionAt ? new Date(m.lastStockCorrectionAt).getTime() : 0;
if (stockCalculationMode === "automatic") {
m.blisters.forEach((s) => {
const blisterStart = new Date(s.start).getTime();
const effectiveStart = Math.max(blisterStart, stockCorrectionCutoff);
if (Number.isNaN(effectiveStart) || effectiveStart > now) return;
const period = Math.max(1, s.every) * MS_PER_DAY;
const occurrences = Math.floor((now - effectiveStart) / period) + 1;
consumed += occurrences * s.usage * personCount;
});
} else {
takenDoses.forEach((doseId) => {
const parts = doseId.split("-");
if (parts.length >= 3) {
const medId = parseInt(parts[0], 10);
const blisterIdx = parseInt(parts[1], 10);
const doseTimestamp = parseInt(parts[2], 10);
if (medId === m.id && m.blisters[blisterIdx]) {
const blisterStart = new Date(m.blisters[blisterIdx].start).getTime();
if (!Number.isNaN(blisterStart) && doseTimestamp >= blisterStart && doseTimestamp > stockCorrectionCutoff) {
consumed += m.blisters[blisterIdx].usage;
}
}
}
});
}
const totalPills = getMedTotal(m);
const medsLeft = Math.max(0, totalPills - consumed);
const rawDaysLeft = dailyRate > 0 ? medsLeft / dailyRate : null;
const daysLeft = rawDaysLeft !== null ? Math.max(0, Math.floor(rawDaysLeft)) : null;
const depletionMs = daysLeft !== null ? now + daysLeft * MS_PER_DAY : null;
const depletionDate = depletionMs !== null
? new Date(depletionMs).toLocaleDateString(locale, { weekday: "short", day: "2-digit", month: "short" })
: null;
const nextEvent = events.find((e) => e.medName === m.name);
return {
name: m.name,
medsLeft: Number(medsLeft.toFixed(1)),
daysLeft,
depletionDate,
depletionTime: depletionMs,
nextDose: nextEvent
? new Date(nextEvent.when).toLocaleString(locale, { weekday: "short", day: "2-digit", month: "short", hour: "2-digit", minute: "2-digit" })
: null
};
});
const low = coverage.filter((c) => c.medsLeft <= 0 || (c.daysLeft !== null && c.daysLeft <= reminderDaysBefore));
return { low, all: coverage };
}
/**
* Get stock status based on days left and thresholds
*/
export function getStockStatus(daysLeft: number | null, medsLeft: number, thresholds: StockThresholds): StockStatus {
if (medsLeft <= 0 || daysLeft === 0) {
return { level: "out-of-stock", className: "danger", label: "status.outOfStock" };
}
if (daysLeft === null) {
return { level: "normal", className: "success", label: "status.noSchedule" };
}
if (daysLeft > thresholds.highStockDays) {
return { level: "high", className: "high", label: "status.highStock" };
}
if (daysLeft >= thresholds.lowStockDays) {
return { level: "normal", className: "success", label: "status.normal" };
}
return { level: "low", className: "warning", label: "status.lowStock" };
}
/**
* Get next reminder date for a medication
*/
export function getNextReminderForMed(med: Coverage, reminderDaysBefore: number, locale: string): string {
if (!med.depletionTime) return "—";
const reminderTime = med.depletionTime - reminderDaysBefore * 86_400_000;
const now = Date.now();
if (reminderTime <= now) {
return "Due now";
}
return new Date(reminderTime).toLocaleDateString(locale, {
day: "2-digit",
month: "short"
});
}
/**
* Get reminder status text for dashboard display
*/
export function getReminderStatusText(
reminderDaysBefore: number,
lowStockDays: number,
_lowStock: Coverage[],
allCoverage: Coverage[],
lastSent: string | null,
lastType: "stock" | "intake" | null,
lastChannel: "email" | "push" | "both" | null,
t: (key: string, options?: Record<string, unknown>) => string,
locale: string
): { lines: Array<{ text: string; className?: string; strong?: boolean }> } {
const emptyMeds = allCoverage.filter((c) => c.medsLeft <= 0);
const medsNeedingReminder = allCoverage
.filter((c) => c.medsLeft > 0 && c.daysLeft !== null && c.daysLeft <= reminderDaysBefore)
.sort((a, b) => (a.daysLeft ?? 0) - (b.daysLeft ?? 0));
const lowStockNotYetCritical = allCoverage.filter(
(c) => c.medsLeft > 0 && c.daysLeft !== null && c.daysLeft > reminderDaysBefore && c.daysLeft < lowStockDays
);
const formatLastSent = (iso: string) => {
const date = new Date(iso);
return date.toLocaleDateString(locale, { day: "2-digit", month: "short", hour: "2-digit", minute: "2-digit" });
};
const getTypeLabel = () => (lastType === "intake" ? t("dashboard.reminders.typeIntake") : t("dashboard.reminders.typeStock"));
const getChannelLabel = () => {
if (lastChannel === "both") return t("dashboard.reminders.channelBoth");
if (lastChannel === "push") return t("dashboard.reminders.channelPush");
return t("dashboard.reminders.channelEmail");
};
const formatLastInfo = (iso: string) => {
const dateStr = formatLastSent(iso);
if (lastType && lastChannel) {
return `${dateStr} (${getTypeLabel()}, ${getChannelLabel()})`;
}
return dateStr;
};
const lines: Array<{ text: string; className?: string; strong?: boolean }> = [];
if (emptyMeds.length > 0) {
lines.push({ text: `🚨 ${t("dashboard.reminders.emptyStock", { count: emptyMeds.length })}`, className: "danger-text", strong: true });
if (medsNeedingReminder.length > 0) {
lines.push({ text: `${t("dashboard.reminders.needReorder", { count: medsNeedingReminder.length })}`, className: "danger-text" });
}
if (lowStockNotYetCritical.length > 0) {
lines.push({ text: t("dashboard.reminders.lowWarning", { count: lowStockNotYetCritical.length }), className: "warning-text" });
}
if (lastSent) {
lines.push({ text: `${t("dashboard.reminders.lastReminder")}: ${formatLastInfo(lastSent)}` });
}
return { lines };
}
if (medsNeedingReminder.length > 0) {
lines.push({ text: `${t("dashboard.reminders.needReorder", { count: medsNeedingReminder.length })}`, className: "danger-text", strong: true });
if (lowStockNotYetCritical.length > 0) {
lines.push({ text: t("dashboard.reminders.lowWarning", { count: lowStockNotYetCritical.length }), className: "warning-text" });
}
if (lastSent) {
lines.push({ text: `${t("dashboard.reminders.lastReminder")}: ${formatLastInfo(lastSent)}` });
}
return { lines };
}
if (lowStockNotYetCritical.length > 0) {
const nextMed = lowStockNotYetCritical.sort((a, b) => (a.daysLeft ?? 0) - (b.daysLeft ?? 0))[0];
const daysUntilReminder = Math.max(0, (nextMed.daysLeft ?? 0) - reminderDaysBefore);
lines.push({ text: t("dashboard.reminders.lowWarning", { count: lowStockNotYetCritical.length }), className: "warning-text" });
lines.push({ text: `${t("dashboard.reminders.nextIn")}: ${nextMed.name} ${t("dashboard.reminders.inDays", { days: daysUntilReminder })}` });
return { lines };
}
const allWithDepletion = allCoverage
.filter((c) => c.depletionTime !== null && c.daysLeft !== null && c.medsLeft > 0)
.sort((a, b) => (a.daysLeft ?? Infinity) - (b.daysLeft ?? Infinity));
if (allWithDepletion.length > 0) {
const nextMed = allWithDepletion[0];
const daysUntilReminder = (nextMed.daysLeft ?? 0) - reminderDaysBefore;
if (daysUntilReminder > 0) {
lines.push({ text: `${t("dashboard.reminders.allOk")}`, className: "success-text" });
lines.push({ text: `${t("dashboard.reminders.nextIn")}: ${nextMed.name} ${t("dashboard.reminders.inDays", { days: daysUntilReminder })}` });
return { lines };
}
}
lines.push({ text: `${t("dashboard.reminders.allStockOk")}`, className: "success-text" });
if (lastSent) {
lines.push({ text: `${t("dashboard.reminders.lastReminder")}: ${formatLastInfo(lastSent)}` });
} else {
lines.push({ text: t("dashboard.reminders.noRemindersNeeded") });
}
return { lines };
}
+85
View File
@@ -0,0 +1,85 @@
// =============================================================================
// Local Storage Utilities
// =============================================================================
import { pad2 } from "./formatters";
/**
* Generate a user-specific storage key
* @param userId - The user ID
* @param key - The storage key name
*/
export function userStorageKey(userId: number | string, key: string): string {
return `${key}_user_${userId}`;
}
/**
* Get today's date as ISO string (YYYY-MM-DD)
*/
export function todayIso(): string {
const d = new Date();
return `${d.getFullYear()}-${pad2(d.getMonth() + 1)}-${pad2(d.getDate())}`;
}
/**
* Get a date N days from today as ISO string (YYYY-MM-DD)
*/
export function plusDaysIso(days: number): string {
const d = new Date();
d.setDate(d.getDate() + days);
return `${d.getFullYear()}-${pad2(d.getMonth() + 1)}-${pad2(d.getDate())}`;
}
/**
* Load collapsed days state from localStorage
*/
export function loadCollapsedDaysFromStorage(
collapsedKey: string,
expandedKey: string
): { collapsed: Set<string>; expanded: Set<string> } {
const collapsed = new Set<string>();
const expanded = new Set<string>();
try {
const storedCollapsed = localStorage.getItem(collapsedKey);
if (storedCollapsed) {
const arr = JSON.parse(storedCollapsed);
if (Array.isArray(arr)) arr.forEach((s: string) => collapsed.add(s));
}
const storedExpanded = localStorage.getItem(expandedKey);
if (storedExpanded) {
const arr = JSON.parse(storedExpanded);
if (Array.isArray(arr)) arr.forEach((s: string) => expanded.add(s));
}
} catch {
// Ignore parse errors
}
return { collapsed, expanded };
}
/**
* Save collapsed days state to localStorage
*/
export function saveCollapsedDaysToStorage(storageKey: string, state: Record<string, boolean>): void {
try {
localStorage.setItem(storageKey, JSON.stringify(state));
} catch {
// Ignore storage errors
}
}
/**
* Get theme from localStorage or default
*/
export function getStoredTheme(): "light" | "dark" {
if (typeof window !== "undefined") {
return (localStorage.getItem("theme") as "light" | "dark") || "dark";
}
return "dark";
}
/**
* Save theme to localStorage
*/
export function saveTheme(theme: "light" | "dark"): void {
localStorage.setItem("theme", theme);
}