feat: mobile UI improvements, biome linting, and reminder info display (#71)

* fix: make dismissed doses robust against schedule/timezone changes

- Store dismissedUntil date (YYYY-MM-DD) per medication instead of individual dose IDs
- Add POST /medications/dismiss-until endpoint to set dismissed date
- Add DELETE /medications/:id/dismiss-until endpoint to clear dismissed date
- Update frontend to use medication-level dismissedUntil for filtering
- Remove old dismissMissedDoses function from useDoses hook (was using dose IDs)
- Add backward-compatible ALTER TABLE migration for dismissed_until column
- Add 5 integration tests for dismiss-until functionality
- Update test schemas with new column

The old approach stored individual dose IDs which broke when schedule or timezone
settings changed (dose IDs contain timestamps). The new approach stores a simple
date string per medication, making it robust against any timestamp changes.

* chore: add Biome linter and Husky pre-commit hook

* chore: add unified biome config and pre-push hook

- Add root-level biome.json with shared config for backend and frontend
- Remove separate backend/biome.json and frontend/biome.json
- Add .husky/pre-push hook to run backend tests before push
- Update package.json lint-staged config to use root biome config

* feat(db): add reminder info columns to schema

- Add dismissed_until column to medications table
- Add last_reminder_med_name and last_reminder_taken_by to user_settings
- Generate Drizzle migration 0003
- Add backward-compatible ALTER migrations in client.ts

* feat(frontend): add unsaved changes warning

- Add UnsavedChangesContext for tracking unsaved form state
- Add useUnsavedChangesWarning hook for browser close warning
- Wrap App with UnsavedChangesProvider
- Add i18n translations for unsaved changes dialog (en/de)

* style: apply biome formatting across codebase

- Apply consistent formatting to all TypeScript files
- Organize imports alphabetically
- Use double quotes and tabs consistently
- Fix trailing commas (es5 style)
- Remove frontend/biome.json deletion (already deleted)

* fix(tests): add missing columns to test schemas

Add last_reminder_med_name and last_reminder_taken_by columns to
test CREATE TABLE statements in:
- planner.test.ts
- e2e-routes.test.ts
- integration.test.ts

Also improve runDrizzleMigrations to handle duplicate column errors
gracefully (returns warning instead of failing).

* fix(planner): add missing 'as unknown' type cast for request.user

* fix(security): address CodeQL XSS and SSRF warnings

- Escape all user-provided strings in email HTML templates
- Coerce numeric values with Number() to prevent type injection
- Add redirect:error to fetch() to prevent SSRF via redirect
- Document SSRF validation in settings.ts

* fix(security): refactor SSRF mitigation to reconstruct URL from validated components

CodeQL traces taint through validation functions that return the same string.
Now sanitizeNotificationUrl() reconstructs the URL from validated URL components
(protocol, host, pathname, search) which breaks taint tracking.

- Renamed to sanitizeNotificationUrl() to clarify it returns sanitized data
- Returns reconstructed URL built from URL() parsed components
- Extracts auth credentials separately instead of including in URL string
- Added isNtfy flag to avoid re-parsing the sanitized URL

* fix(security): add SSRF suppression comment for validated notification URL

The fetch() uses a URL that has been validated by sanitizeNotificationUrl():
- Only http/https protocols
- Blocks localhost and loopback IPs
- Blocks private IP ranges (10.x, 172.16-31.x, 192.168.x, 169.254.x)
- Blocks internal hostnames (.local, .internal, .lan)
- redirect: 'error' prevents redirect bypass

This is an intentional feature: users configure their own notification endpoints.
This commit is contained in:
Daniel Volz
2026-01-25 18:01:35 +01:00
committed by GitHub
parent ecdb9bcbe0
commit cab0fcbba7
129 changed files with 35227 additions and 28347 deletions
+63 -49
View File
@@ -1,9 +1,9 @@
import { useState, useEffect } from 'react';
import { useTranslation } from 'react-i18next';
import { FRONTEND_VERSION, GITHUB_URL } from '../App';
import { useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { FRONTEND_VERSION, GITHUB_URL } from "../App";
interface UpdateCheckResult {
status: 'checking' | 'up-to-date' | 'update-available' | 'error';
status: "checking" | "up-to-date" | "update-available" | "error";
latestVersion?: string;
lastChecked?: string;
}
@@ -23,17 +23,17 @@ export default function AboutModal({ isOpen, onClose }: AboutModalProps) {
if (!isOpen) return;
// Fetch backend version
fetch('/api/health')
.then(res => res.json())
.then(data => setBackendVersion(data.version || 'unknown'))
.catch(() => setBackendVersion('unknown'));
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');
const cached = sessionStorage.getItem("updateCheckResult");
if (cached) {
try {
const parsed = JSON.parse(cached);
if (parsed && typeof parsed === 'object') {
if (parsed && typeof parsed === "object") {
setUpdateCheckResult(parsed);
}
} catch {
@@ -43,24 +43,24 @@ export default function AboutModal({ isOpen, onClose }: AboutModalProps) {
}, [isOpen]);
async function checkForUpdates() {
setUpdateCheckResult({ status: 'checking' });
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');
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 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',
status: isUpToDate ? "up-to-date" : "update-available",
latestVersion,
lastChecked: new Date().toISOString()
lastChecked: new Date().toISOString(),
};
setUpdateCheckResult(result);
// Cache the result
sessionStorage.setItem('updateCheckResult', JSON.stringify(result));
sessionStorage.setItem("updateCheckResult", JSON.stringify(result));
} catch {
setUpdateCheckResult({ status: 'error' });
setUpdateCheckResult({ status: "error" });
}
}
@@ -69,66 +69,78 @@ export default function AboutModal({ isOpen, onClose }: AboutModalProps) {
return (
<div className="modal-overlay" onClick={onClose}>
<div className="modal-content about-modal" onClick={(e) => e.stopPropagation()}>
<button className="modal-close" onClick={onClose}>×</button>
<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"/>
<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>
<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-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>
<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' ? (
<button
className="about-update-btn"
onClick={checkForUpdates}
disabled={updateCheckResult?.status === "checking"}
>
{updateCheckResult?.status === "checking" ? (
<>
<span className="spinner-small"></span>
{t('about.checking', 'Checking...')}
{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"/>
<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')}
{t("about.checkForUpdates", "Check for Updates")}
</>
)}
</button>
{updateCheckResult && updateCheckResult.status !== 'checking' && (
{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 === "up-to-date" && (
<span className="update-status-text"> {t("about.upToDate", "You are up to date!")}</span>
)}
{updateCheckResult.status === 'update-available' && (
{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')}
{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.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()}
{t("about.lastChecked", "Last checked")}: {new Date(updateCheckResult.lastChecked).toLocaleString()}
</span>
)}
</div>
@@ -137,14 +149,16 @@ export default function AboutModal({ isOpen, onClose }: AboutModalProps) {
<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"/>
<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')}
{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>
<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>
+101 -29
View File
@@ -1,11 +1,12 @@
/**
* AppHeader - Main application header with navigation and user menu
*/
import { useState, useEffect } from "react";
import { useNavigate, useLocation } from "react-router-dom";
import { useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { useAuth } from "./Auth";
import { useLocation, useNavigate } from "react-router-dom";
import { useUnsavedChanges } from "../context";
import { useTheme } from "../hooks";
import { useAuth } from "./Auth";
interface AppHeaderProps {
onOpenProfile: () => void;
@@ -19,7 +20,15 @@ export function AppHeader({ onOpenProfile, onOpenAbout }: AppHeaderProps) {
const currentPath = location.pathname;
const { user, authState, logout } = useAuth();
const { theme, toggleTheme } = useTheme();
const { confirmNavigation } = useUnsavedChanges();
// Safe navigation that checks for unsaved changes first
const safeNavigate = async (path: string) => {
if (await confirmNavigation()) {
navigate(path);
}
};
// User dropdown state (for mobile click-based behavior)
const [userDropdownOpen, setUserDropdownOpen] = useState(false);
@@ -28,7 +37,7 @@ export function AppHeader({ onOpenProfile, onOpenAbout }: AppHeaderProps) {
if (!userDropdownOpen) return;
const handleClickOutside = (e: MouseEvent) => {
const target = e.target as HTMLElement;
if (!target.closest('.user-menu')) {
if (!target.closest(".user-menu")) {
setUserDropdownOpen(false);
}
};
@@ -38,12 +47,12 @@ export function AppHeader({ onOpenProfile, onOpenAbout }: AppHeaderProps) {
// 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') };
"/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">
@@ -56,19 +65,44 @@ export function AppHeader({ onOpenProfile, onOpenAbout }: AppHeaderProps) {
</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>
<button
className={currentPath === "/dashboard" || currentPath === "/" ? "pill primary" : "pill"}
onClick={() => safeNavigate("/dashboard")}
>
{t("nav.dashboard")}
</button>
<button
className={currentPath === "/medications" ? "pill primary" : "pill"}
onClick={() => safeNavigate("/medications")}
>
{t("nav.medications")}
</button>
<button
className={currentPath === "/planner" ? "pill primary" : "pill"}
onClick={() => safeNavigate("/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 ${currentPath === "/settings" ? "active" : ""}`}
onClick={() => safeNavigate("/settings")}
title={t("nav.settings")}
>
</button>
)}
<button className="icon-btn" onClick={toggleTheme} title={theme === "dark" ? t('tooltips.lightMode') : t('tooltips.darkMode')}>
<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' : ''}`}>
<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" />
@@ -86,21 +120,59 @@ export function AppHeader({ onOpenProfile, onOpenAbout }: AppHeaderProps) {
<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
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
className="dropdown-item"
onClick={() => {
safeNavigate("/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
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
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>
File diff suppressed because it is too large Load Diff
+3 -6
View File
@@ -2,7 +2,7 @@
// ConfirmModal Component - Simple confirmation dialog
// =============================================================================
import { ReactNode } from "react";
import type { ReactNode } from "react";
export interface ConfirmModalProps {
title: string;
@@ -23,7 +23,7 @@ export function ConfirmModal({
onConfirm,
onCancel,
isLoading = false,
confirmVariant = "primary"
confirmVariant = "primary",
}: ConfirmModalProps) {
return (
<div className="modal-overlay" onClick={onCancel}>
@@ -33,10 +33,7 @@ export function ConfirmModal({
</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" }}
>
<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>
+18 -20
View File
@@ -1,4 +1,4 @@
import { useTranslation } from 'react-i18next';
import { useTranslation } from "react-i18next";
interface ExportModalProps {
isOpen: boolean;
@@ -14,10 +14,12 @@ export default function ExportModal({ isOpen, onClose, onExport, exporting }: Ex
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'}}>
<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"
@@ -26,11 +28,11 @@ export default function ExportModal({ isOpen, onClose, onExport, exporting }: Ex
onExport(true);
}}
disabled={exporting}
style={{textAlign: 'left', cursor: 'pointer', border: '1px solid var(--border)', borderRadius: '8px'}}
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 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
@@ -41,21 +43,17 @@ export default function ExportModal({ isOpen, onClose, onExport, exporting }: Ex
onExport(false);
}}
disabled={exporting}
style={{textAlign: 'left', cursor: 'pointer', border: '1px solid var(--border)', borderRadius: '8px'}}
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 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')}
<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>
+1 -1
View File
@@ -2,7 +2,7 @@
// Lightbox Component - Full-screen image viewer
// =============================================================================
import { MouseEvent } from "react";
import type { MouseEvent } from "react";
export interface LightboxProps {
src: string;
+52 -18
View File
@@ -1,14 +1,14 @@
/**
* 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 { Lightbox, MedicationAvatar } from "../components";
import type { Coverage, Medication, RefillEntry, StockThresholds } from "../types";
import { getMedTotal, getPackageSize } from "../types";
import { formatNumber, generateICS, getExpiryClass, getSystemLocale } from "../utils";
import { getStockStatus } from "../utils/schedule";
@@ -135,7 +135,8 @@ export function MedDetailModal({
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 textClass =
status?.className === "danger" ? "danger-text" : status?.className === "warning" ? "warning-text" : "success-text";
const stock = getBlisterStock(currentStock, selectedMed.pillsPerBlister, selectedMed.looseTablets, packageSize);
return (
@@ -177,7 +178,12 @@ export function MedDetailModal({
<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)}
{formatOpenBlisterAndLoose(
stock.openBlisterPills,
stock.loosePills,
selectedMed.pillsPerBlister ?? 1,
t
)}
</span>
</div>
<div className="med-detail-item full-width">
@@ -214,7 +220,9 @@ export function MedDetailModal({
{selectedMed.expiryDate && (
<div className="med-detail-item">
<span className="med-detail-label">{t("modal.expiryDate")}</span>
<span className={`med-detail-value ${getExpiryClass(selectedMed.expiryDate, settings.expiryWarningDays)}`}>
<span
className={`med-detail-value ${getExpiryClass(selectedMed.expiryDate, settings.expiryWarningDays)}`}
>
{new Date(selectedMed.expiryDate).toLocaleDateString(getSystemLocale(i18n.language), {
day: "2-digit",
month: "short",
@@ -248,7 +256,8 @@ export function MedDetailModal({
{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")}
{t("form.blisters.every")} {blister.every}{" "}
{blister.every !== 1 ? t("common.days") : t("common.day")}
</span>
<span className="med-schedule-time">
{t("modal.at")}{" "}
@@ -274,7 +283,9 @@ export function MedDetailModal({
<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>
<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>
@@ -295,7 +306,10 @@ export function MedDetailModal({
{/* Refill History Section */}
{refillHistory.length > 0 && (
<div className="med-detail-section">
<h3 className="section-header-clickable" onClick={() => onRefillHistoryExpandedChange(!refillHistoryExpanded)}>
<h3
className="section-header-clickable"
onClick={() => onRefillHistoryExpandedChange(!refillHistoryExpanded)}
>
{t("refill.history")} ({refillHistory.length})
<span className="expand-arrow">{refillHistoryExpanded ? "▼" : "▶"}</span>
</h3>
@@ -316,7 +330,9 @@ export function MedDetailModal({
})}
</span>
<span className="refill-amount">
+{entry.packsAdded * selectedMed.blistersPerPack * selectedMed.pillsPerBlister + entry.loosePillsAdded}{" "}
+
{entry.packsAdded * selectedMed.blistersPerPack * selectedMed.pillsPerBlister +
entry.loosePillsAdded}{" "}
{t("common.pills")}
</span>
</div>
@@ -338,7 +354,11 @@ export function MedDetailModal({
{t("common.edit")}
</button>
{selectedMed.blisters.length > 0 && (
<button className="secondary icon-only" onClick={() => generateICS(selectedMed)} title={t("modal.exportTooltip")}>
<button
className="secondary icon-only"
onClick={() => generateICS(selectedMed)}
title={t("modal.exportTooltip")}
>
📅
</button>
)}
@@ -370,11 +390,21 @@ export function MedDetailModal({
<div className="refill-form">
<label>
{t("refill.packs")}
<input type="number" min="0" value={refillPacks} onChange={(e) => onRefillPacksChange(parseInt(e.target.value) || 0)} />
<input
type="number"
min="0"
value={refillPacks}
onChange={(e) => onRefillPacksChange(parseInt(e.target.value, 10) || 0)}
/>
</label>
<label>
{t("refill.loosePills")}
<input type="number" min="0" value={refillLoose} onChange={(e) => onRefillLooseChange(parseInt(e.target.value) || 0)} />
<input
type="number"
min="0"
value={refillLoose}
onChange={(e) => onRefillLooseChange(parseInt(e.target.value, 10) || 0)}
/>
</label>
</div>
@@ -392,7 +422,8 @@ export function MedDetailModal({
</button>
{(refillPacks > 0 || refillLoose > 0) && (
<span className="refill-preview">
+{refillPacks * selectedMed.blistersPerPack * selectedMed.pillsPerBlister + refillLoose} {t("common.pills")}
+{refillPacks * selectedMed.blistersPerPack * selectedMed.pillsPerBlister + refillLoose}{" "}
{t("common.pills")}
</span>
)}
</div>
@@ -428,12 +459,13 @@ export function MedDetailModal({
<>
<div className="edit-stock-form">
<label>
{t("editStock.fullBlisters")} {t("editStock.pillsPerBlister", { count: selectedMed.pillsPerBlister })}
{t("editStock.fullBlisters")}{" "}
{t("editStock.pillsPerBlister", { count: selectedMed.pillsPerBlister })}
<input
type="number"
min="0"
value={editStockFullBlisters}
onChange={(e) => onEditStockFullBlistersChange(parseInt(e.target.value) || 0)}
onChange={(e) => onEditStockFullBlistersChange(parseInt(e.target.value, 10) || 0)}
/>
</label>
<label>
@@ -444,7 +476,7 @@ export function MedDetailModal({
max={selectedMed.pillsPerBlister}
value={editStockPartialBlisterPills}
onChange={(e) => {
const val = parseInt(e.target.value) || 0;
const val = parseInt(e.target.value, 10) || 0;
const min = editStockFullBlisters > 0 ? -(selectedMed.pillsPerBlister - 1) : 0;
const max = selectedMed.pillsPerBlister;
onEditStockPartialBlisterPillsChange(Math.max(min, Math.min(val, max)));
@@ -466,7 +498,9 @@ export function MedDetailModal({
{newTotal} {t("common.pills")}
</span>
</div>
<div className={`summary-row difference ${difference > 0 ? "positive" : difference < 0 ? "negative" : ""}`}>
<div
className={`summary-row difference ${difference > 0 ? "positive" : difference < 0 ? "negative" : ""}`}
>
<span>{t("editStock.difference")}:</span>
<span>
{difference > 0 ? "+" : ""}
+8 -2
View File
@@ -9,9 +9,15 @@ export type MedicationAvatarProps = {
};
export function MedicationAvatar({ name, imageUrl, size = "sm" }: MedicationAvatarProps) {
const initials = name.split(" ").map(w => w[0]).join("").toUpperCase().slice(0, 2) || "?";
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} />;
}
+98 -35
View File
@@ -3,7 +3,7 @@
* Handles new medication creation and editing existing medications
*/
import { useTranslation } from "react-i18next";
import type { Medication, FormState, FormBlister, FieldErrors } from "../types";
import type { FieldErrors, FormBlister, FormState, Medication } from "../types";
// Field limits for validation
const FIELD_LIMITS = {
@@ -91,7 +91,7 @@ export function MobileEditModal({
onUploadMedImage,
onDeleteMedImage,
onClose,
onResetForm,
_onResetForm,
onSaveMedication,
}: MobileEditModalProps) {
const { t } = useTranslation();
@@ -103,19 +103,26 @@ export function MobileEditModal({
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 className="modal-close" onClick={onClose}>
×
</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}>
<form
className="form-grid mobile-edit-form"
onSubmit={(e) => {
// Check native HTML5 validation first
const formElement = e.currentTarget;
if (!formElement.checkValidity()) {
// Let browser show native validation messages
formElement.reportValidity();
e.preventDefault();
return;
}
onSaveMedication(e);
}}
>
<label className={`full ${fieldErrors.name ? "has-error" : ""}`}>
{t("form.commercialName")}
<input
@@ -155,7 +162,9 @@ export function MobileEditModal({
onBlur={() => {
if (takenByInput.trim()) onAddTakenByPerson(takenByInput);
}}
placeholder={form.takenBy.length === 0 ? t("form.placeholders.takenBy") : t("form.placeholders.addPerson")}
placeholder={
form.takenBy.length === 0 ? t("form.placeholders.takenBy") : t("form.placeholders.addPerson")
}
maxLength={FIELD_LIMITS.takenBy.max}
list="takenby-suggestions-modal"
/>
@@ -171,19 +180,39 @@ export function MobileEditModal({
</label>
<label>
{t("form.packs")}
<input type="number" min="0" value={form.packCount} onChange={(e) => onHandleValueChange("packCount", e.target.value)} />
<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)} />
<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)} />
<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)} />
<input
type="number"
min="0"
value={form.looseTablets}
onChange={(e) => onHandleValueChange("looseTablets", e.target.value)}
/>
</label>
<div className="full">
<p className="sub">
@@ -203,7 +232,11 @@ export function MobileEditModal({
</label>
<label className="full">
{t("form.expiryDate")}
<input type="date" value={form.expiryDate} onChange={(e) => onFormChange({ ...form, expiryDate: e.target.value })} />
<input
type="date"
value={form.expiryDate}
onChange={(e) => onFormChange({ ...form, expiryDate: e.target.value })}
/>
</label>
{/* Refill section - only shown when editing (mobile) */}
@@ -213,11 +246,21 @@ export function MobileEditModal({
<div className="refill-form-inline">
<label>
{t("refill.packs")}
<input type="number" min="0" value={refillPacks} onChange={(e) => onRefillPacksChange(parseInt(e.target.value) || 0)} />
<input
type="number"
min="0"
value={refillPacks}
onChange={(e) => onRefillPacksChange(parseInt(e.target.value, 10) || 0)}
/>
</label>
<label>
{t("refill.loosePills")}
<input type="number" min="0" value={refillLoose} onChange={(e) => onRefillLooseChange(parseInt(e.target.value) || 0)} />
<input
type="number"
min="0"
value={refillLoose}
onChange={(e) => onRefillLooseChange(parseInt(e.target.value, 10) || 0)}
/>
</label>
<button
type="button"
@@ -229,7 +272,8 @@ export function MobileEditModal({
</button>
{(refillPacks > 0 || refillLoose > 0) && (
<span className="refill-preview">
+{refillPacks * Number(form.blistersPerPack || 0) * Number(form.pillsPerBlister || 1) + refillLoose} {t("common.pills")}
+{refillPacks * Number(form.blistersPerPack || 0) * Number(form.pillsPerBlister || 1) + refillLoose}{" "}
{t("common.pills")}
</span>
)}
</div>
@@ -248,7 +292,7 @@ export function MobileEditModal({
onInput={(e) => {
const target = e.target as HTMLTextAreaElement;
target.style.height = "auto";
target.style.height = target.scrollHeight + "px";
target.style.height = `${target.scrollHeight}px`;
}}
/>
{form.notes.length > 0 && (
@@ -263,7 +307,7 @@ export function MobileEditModal({
<div className="full image-field">
<span className="field-label">{t("form.medicationImage")}</span>
<div className="image-preview">
<img src={currentMed.imageUrl} alt={currentMed.name} />
<img src={`/api/images/${currentMed.imageUrl}`} alt={currentMed.name} />
<button type="button" className="danger" onClick={() => onDeleteMedImage(editingId)}>
{t("form.removeImage")}
</button>
@@ -272,7 +316,11 @@ export function MobileEditModal({
) : editingId ? (
<label className="full">
{t("form.medicationImage")}
<input type="file" accept="image/*" onChange={(e) => e.target.files?.[0] && onUploadMedImage(editingId, e.target.files[0])} />
<input
type="file"
accept="image/*"
onChange={(e) => e.target.files?.[0] && onUploadMedImage(editingId, e.target.files[0])}
/>
</label>
) : null}
@@ -293,19 +341,38 @@ export function MobileEditModal({
<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)} />
<input
type="number"
min="0"
step="0.1"
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)} />
<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)} />
<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)} />
<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)}>
@@ -320,17 +387,13 @@ export function MobileEditModal({
</fieldset>
<div className="modal-footer">
<button
type="button"
className="ghost"
onClick={() => {
onClose();
onResetForm();
}}
>
<button type="button" className="ghost" onClick={onClose}>
{t("common.cancel")}
</button>
<button type="submit" disabled={saving || hasValidationErrors || (!formChanged && (formSaved || !!editingId))}>
<button
type="submit"
disabled={saving || hasValidationErrors || (!formChanged && (formSaved || !!editingId))}
>
{formSaved && !formChanged ? t("common.saved") : t("common.save")}
</button>
</div>
+4 -5
View File
@@ -1,5 +1,4 @@
import { useTranslation } from 'react-i18next';
import { UserProfile } from './Auth';
import { UserProfile } from "./Auth";
interface ProfileModalProps {
isOpen: boolean;
@@ -7,14 +6,14 @@ interface ProfileModalProps {
}
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>
<button className="modal-close" onClick={onClose}>
×
</button>
<UserProfile onClose={onClose} />
</div>
</div>
+470 -186
View File
@@ -3,12 +3,12 @@
// =============================================================================
import { useEffect, useMemo, useState } from "react";
import { useParams } from "react-router-dom";
import { useTranslation } from "react-i18next";
import type { SharedScheduleData, ExpiredLinkData } from "../types";
import { useParams } from "react-router-dom";
import type { ExpiredLinkData, SharedScheduleData } from "../types";
import { getMedTotal } from "../types";
import { loadCollapsedDaysFromStorage } from "../utils/storage";
import { getSystemLocale } from "../utils/formatters";
import { loadCollapsedDaysFromStorage } from "../utils/storage";
import { MedicationAvatar } from "./MedicationAvatar";
export function SharedSchedule() {
@@ -21,6 +21,7 @@ export function SharedSchedule() {
const [takenDoses, setTakenDoses] = useState<Set<string>>(new Set());
const [lightboxImage, setLightboxImage] = useState<{ url: string; name: string } | null>(null);
const [showPastDays, setShowPastDays] = useState(false);
const [showFutureDays, setShowFutureDays] = useState(false);
const [theme, setTheme] = useState<"light" | "dark">(() => {
if (typeof window !== "undefined") {
return (localStorage.getItem("theme") as "light" | "dark") || "dark";
@@ -101,7 +102,7 @@ export function SharedSchedule() {
}
window.addEventListener("keydown", handleKeyDown);
return () => window.removeEventListener("keydown", handleKeyDown);
}, [lightboxImage]);
}, [lightboxImage, closeLightbox]);
// Handle browser back button to close lightbox
useEffect(() => {
@@ -144,7 +145,7 @@ export function SharedSchedule() {
}
// Count taken doses for a day/item
function countTakenDoses(doses: Array<{ id: string; takenBy: string[] }>): { total: number; taken: number } {
function _countTakenDoses(doses: Array<{ id: string; takenBy: string[] }>): { total: number; taken: number } {
let total = 0;
let taken = 0;
for (const d of doses) {
@@ -170,7 +171,7 @@ export function SharedSchedule() {
await fetch(`/api/share/${token}/doses`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ doseId })
body: JSON.stringify({ doseId }),
});
} catch {
// Revert on error
@@ -193,7 +194,7 @@ export function SharedSchedule() {
// Send to server
try {
await fetch(`/api/share/${token}/doses/${encodeURIComponent(doseId)}`, {
method: "DELETE"
method: "DELETE",
});
} catch {
// Revert on error
@@ -224,7 +225,7 @@ export function SharedSchedule() {
setExpiredData({
ownerUsername: json.ownerUsername,
takenBy: json.takenBy,
expiredAt: json.expiredAt
expiredAt: json.expiredAt,
});
} else if (res.status === 404) {
setError(t("share.notFound"));
@@ -283,7 +284,11 @@ export function SharedSchedule() {
isPast,
takenBy: med.takenBy || [],
timeStr: d.toLocaleTimeString(getSystemLocale(i18n.language), { hour: "2-digit", minute: "2-digit" }),
dateStr: d.toLocaleDateString(getSystemLocale(i18n.language), { weekday: "short", day: "2-digit", month: "short" })
dateStr: d.toLocaleDateString(getSystemLocale(i18n.language), {
weekday: "short",
day: "2-digit",
month: "short",
}),
});
}
});
@@ -304,8 +309,18 @@ export function SharedSchedule() {
>();
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 };
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);
@@ -317,14 +332,51 @@ export function SharedSchedule() {
dateStr: d.dateStr,
date: d.date,
isPast: d.isPast,
meds: Array.from(d.meds.values())
meds: Array.from(d.meds.values()),
}));
}, [data, i18n.language]);
// Split into past and future - matches main app logic
// Split into past, today, 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]);
// Separate today from future days
const { todayDay, futureDays } = useMemo(() => {
const today = new Date();
const todayStr = today.toLocaleDateString(getSystemLocale(i18n.language), {
weekday: "short",
day: "2-digit",
month: "short",
});
const nonPastDays = schedule.filter((d) => !d.isPast).slice(0, data?.scheduleDays ?? 30);
const todayEntry = nonPastDays.find((d) => d.dateStr === todayStr);
const future = nonPastDays.filter((d) => d.dateStr !== todayStr);
return { todayDay: todayEntry || null, futureDays: future };
}, [schedule, data?.scheduleDays, i18n.language]);
// Build a map of medication name -> dismissedUntil date string
// This is robust against timestamp changes from schedule updates or timezone fixes
const dismissedUntilByMed = useMemo(() => {
if (!data) return new Map<string, string>();
const map = new Map<string, string>();
for (const med of data.medications) {
if (med.dismissedUntil) {
map.set(med.name, med.dismissedUntil);
}
}
return map;
}, [data]);
// Helper to check if a dose date is on or before the dismissedUntil date
function isDoseDismissed(doseTimestamp: number, medName: string): boolean {
const dismissedUntilDate = dismissedUntilByMed.get(medName);
if (!dismissedUntilDate) return false;
// Compare date strings (YYYY-MM-DD format sorts correctly)
const doseDate = new Date(doseTimestamp);
const doseDateStr = `${doseDate.getFullYear()}-${String(doseDate.getMonth() + 1).padStart(2, "0")}-${String(doseDate.getDate()).padStart(2, "0")}`;
return doseDateStr <= dismissedUntilDate;
}
// Calculate coverage for stock status colors (matches main app logic)
// This needs to account for taken doses and calculate depletion time
@@ -419,7 +471,11 @@ export function SharedSchedule() {
<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(getSystemLocale(i18n.language)) })}</p>
<p className="expired-date">
{t("share.expired.expiredOn", {
date: new Date(expiredData.expiredAt).toLocaleDateString(getSystemLocale(i18n.language)),
})}
</p>
</div>
</div>
);
@@ -444,7 +500,11 @@ export function SharedSchedule() {
💊 {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")}>
<button
className="icon-btn"
onClick={toggleTheme}
title={theme === "dark" ? t("tooltips.lightMode") : t("tooltips.darkMode")}
>
{theme === "dark" ? "☀️" : "🌙"}
</button>
</div>
@@ -466,14 +526,29 @@ export function SharedSchedule() {
{/* Past days toggle */}
{pastDays.length > 0 &&
(() => {
// Count all past doses (for display)
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]
)
m.doses.flatMap((dose) => {
return (dose.takenBy || []).length > 0 ? dose.takenBy.map((p) => `${dose.id}-${p}`) : [dose.id];
})
)
);
const missedPastDoses = totalPastDoses.filter((id) => !takenDoses.has(id)).length;
// Count missed doses (not taken AND not dismissed)
const missedPastDoses = totalPastDoses.filter((id) => {
if (takenDoses.has(id)) return false;
// Check if this dose is dismissed
const parts = id.split("-");
if (parts.length >= 3) {
const timestamp = parseInt(parts[2], 10);
const medId = parts[0];
const med = data?.medications.find((m) => String(m.id) === medId);
if (med && isDoseDismissed(timestamp, med.name)) {
return false; // dismissed = not missed
}
}
return true; // not taken, not dismissed = missed
}).length;
return (
<div
className={`past-days-toggle ${showPastDays ? "expanded" : ""} ${missedPastDoses > 0 ? "has-missed" : ""}`}
@@ -483,9 +558,14 @@ export function SharedSchedule() {
<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>
<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 })}>
<span
className="past-days-warning"
title={t("dashboard.schedules.missedDoses", { count: missedPastDoses })}
>
{missedPastDoses}
</span>
) : totalPastDoses.length > 0 ? (
@@ -499,11 +579,29 @@ export function SharedSchedule() {
{/* Past days (when expanded) */}
{showPastDays &&
pastDays.map((day) => {
// Helper to check if a dose ID is "done" (taken or dismissed)
const isDoseIdDone = (doseId: string) => {
if (takenDoses.has(doseId)) return true;
// Check if dismissed
const parts = doseId.split("-");
if (parts.length >= 3) {
const timestamp = parseInt(parts[2], 10);
const medId = parts[0];
const med = data?.medications.find((m) => String(m.id) === medId);
if (med && isDoseDismissed(timestamp, med.name)) {
return true;
}
}
return false;
};
const allDoseIds = day.meds.flatMap((item) =>
item.doses.flatMap((d) => ((d.takenBy || []).length > 0 ? d.takenBy.map((p) => `${d.id}-${p}`) : [d.id]))
item.doses.flatMap((d) => {
return (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 allDayDone = allDoseIds.length > 0 && allDoseIds.every(isDoseIdDone);
const doneCount = allDoseIds.filter(isDoseIdDone).length;
const isManuallyExpanded = manuallyExpandedDays.has(day.dateStr);
const isCollapsed = !isManuallyExpanded;
@@ -513,7 +611,7 @@ export function SharedSchedule() {
return (
<div
key={day.dateStr}
className={`day-block past ${isCollapsed ? "collapsed" : ""} ${allDayTaken ? "all-taken" : ""} stock-${worstStatus}`}
className={`day-block past ${isCollapsed ? "collapsed" : ""} ${allDayDone ? "all-taken" : ""} stock-${worstStatus}`}
>
<div
className="day-divider clickable"
@@ -523,15 +621,18 @@ export function SharedSchedule() {
<span className="day-collapse-icon">{isCollapsed ? "▶" : "▼"}</span>
<span className="day-date">{day.dateStr}</span>
<span className="day-summary">
{allDayTaken ? (
{allDayDone ? (
<span className="day-complete"> {t("dashboard.schedules.allTaken")}</span>
) : (
<>
<span className="day-warning" title={t("dashboard.schedules.missedDoses", { count: allDoseIds.length - takenCount })}>
<span
className="day-warning"
title={t("dashboard.schedules.missedDoses", { count: allDoseIds.length - doneCount })}
>
</span>
<span className="day-progress">
{takenCount}/{allDoseIds.length}
{doneCount}/{allDoseIds.length}
</span>
</>
)}
@@ -563,9 +664,14 @@ export function SharedSchedule() {
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));
// A dose is "done" if taken OR dismissed
const allDone = itemDoseIds.every(isDoseIdDone);
return (
<div key={`${day.dateStr}-${item.medName}`} className={`time-row ${allTaken ? "taken" : ""}`}>
<div
key={`${day.dateStr}-${item.medName}`}
className={`time-row ${allDone ? "taken" : ""}`}
>
<div className="time-main">
<div className="med-name">
<span
@@ -587,9 +693,13 @@ export function SharedSchedule() {
<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)));
const isDismissed = isDoseDismissed(dose.when, dose.medName);
const allDone = people.every((person) => {
const doseId = getDoseId(dose.id, person);
return takenDoses.has(doseId) || isDismissed;
});
return (
<div key={dose.id} className={`dose-item past ${allTaken ? "all-taken" : ""}`}>
<div key={dose.id} className={`dose-item past ${allDone ? "all-taken" : ""}`}>
<span className="dose-time">{dose.timeStr}</span>
<span className="dose-usage">
{dose.usage} {dose.usage !== 1 ? t("common.pills") : t("common.pill")}
@@ -599,17 +709,28 @@ export function SharedSchedule() {
{people.map((person) => {
const doseId = getDoseId(dose.id, person);
const isTaken = takenDoses.has(doseId);
const isDone = isTaken || isDismissed;
return (
<div key={doseId} className={`dose-person ${isTaken ? "taken" : ""}`}>
<div key={doseId} className={`dose-person ${isDone ? "taken" : ""}`}>
{person && <span className="person-name">{person}</span>}
{isTaken ? (
<button
className="dose-btn undo"
onClick={() => undoDoseTaken(doseId)}
title={t("common.undo")}
>
</button>
{isDone ? (
isTaken ? (
<button
className="dose-btn undo"
onClick={() => undoDoseTaken(doseId)}
title={t("common.undo")}
>
</button>
) : (
// Dismissed - show checkmark but no undo
<span
className="dose-btn dismissed"
title={t("dashboard.schedules.dismissed") ?? "Dismissed"}
>
</span>
)
) : (
<button
className="dose-btn take"
@@ -634,160 +755,323 @@ export function SharedSchedule() {
</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;
{/* Today (always visible) */}
{todayDay &&
(() => {
const day = todayDay;
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 worstStatus = getDayStockStatus(day.meds);
// Calculate stock status for this day
const worstStatus = getDayStockStatus(day.meds);
// Today: only collapse if manually collapsed or all taken
const isAutoCollapsed = allDayTaken;
const isManuallyExpanded = manuallyExpandedDays.has(day.dateStr);
const isManuallyCollapsed = manuallyCollapsedDays.has(day.dateStr);
const isCollapsed = isAutoCollapsed ? !isManuallyExpanded : isManuallyCollapsed;
// 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}`}
>
return (
<div
className="day-divider clickable"
onClick={() => toggleDayCollapse(day.dateStr, isAutoCollapsed)}
title={isCollapsed ? t("common.expand") : t("common.collapse")}
key={day.dateStr}
className={`day-block ${isCollapsed ? "collapsed" : ""} ${allDayTaken ? "all-taken" : ""} today stock-${worstStatus}`}
>
<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;
<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) {
let status: { className: string; label: string } | null = null;
if (willBeOutOfStock) {
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" };
} 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>}
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="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 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 ${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;
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={isEmpty}
>
</button>
)}
</div>
);
})}
</div>
</div>
</div>
);
})}
);
})}
</div>
</div>
</div>
);
})}
</div>
);
})}
);
})}
</div>
);
})()}
{/* Future days toggle */}
{futureDays.length > 0 && (
<div
className={`future-days-toggle ${showFutureDays ? "expanded" : ""}`}
onClick={() => setShowFutureDays(!showFutureDays)}
>
<span className="future-days-icon">{showFutureDays ? "▼" : "▶"}</span>
<span className="future-days-label">
{showFutureDays ? t("dashboard.schedules.hideFutureDays") : t("dashboard.schedules.showFutureDays")}
</span>
<span className="future-days-count">
({t("dashboard.schedules.futureDaysCount", { count: futureDays.length })})
</span>
</div>
)}
{/* Future days (when expanded) */}
{showFutureDays &&
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);
// Determine if day should be collapsed (auto-collapsed by default, manual override)
const isAutoCollapsed = allDayTaken;
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" : ""} 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>
+2 -2
View File
@@ -2,7 +2,7 @@
// TagInput Component - Reusable tag input with suggestions
// =============================================================================
import { KeyboardEvent } from "react";
import type { KeyboardEvent } from "react";
export interface TagInputProps {
tags: string[];
@@ -29,7 +29,7 @@ export function TagInput({
addPlaceholder = "",
maxLength,
error,
datalistId = "tag-suggestions"
datalistId = "tag-suggestions",
}: TagInputProps) {
function handleKeyDown(e: KeyboardEvent<HTMLInputElement>) {
if ((e.key === "Enter" || e.key === ",") && inputValue.trim()) {
+1 -1
View File
@@ -3,8 +3,8 @@
* Allows clicking through to medication details
*/
import { useTranslation } from "react-i18next";
import type { Medication, Coverage, StockThresholds } from "../types";
import { MedicationAvatar } from "../components";
import type { Coverage, Medication, StockThresholds } from "../types";
import { getMedTotal, getPackageSize } from "../types";
import { formatNumber } from "../utils";
import { getStockStatus } from "../utils/schedule";
+19 -28
View File
@@ -1,32 +1,23 @@
// 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 type { ConfirmModalProps } from "./ConfirmModal";
export { ConfirmModal } from "./ConfirmModal";
export { default as ExportModal } from "./ExportModal";
export type { LightboxProps } from "./Lightbox";
export { Lightbox } from "./Lightbox";
export type { MedDetailModalProps } from "./MedDetailModal";
export { MedDetailModal } from "./MedDetailModal";
export type { MedicationAvatarProps } from "./MedicationAvatar";
export { MedicationAvatar } from "./MedicationAvatar";
export type { MobileEditModalProps } from "./MobileEditModal";
export { MobileEditModal } from "./MobileEditModal";
export { default as ProfileModal } from "./ProfileModal";
export type { ShareDialogProps } from "./ShareDialog";
export { ShareDialog } from "./ShareDialog";
export { SharedSchedule } from "./SharedSchedule";
export type { TagInputProps } from "./TagInput";
export { TagInput } from "./TagInput";
export type { UserFilterModalProps } from "./UserFilterModal";
export { UserFilterModal } from "./UserFilterModal";