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
File diff suppressed because it is too large Load Diff
+328 -123
View File
@@ -1,17 +1,16 @@
import { useState, useMemo, useEffect } from "react";
import { useEffect, useMemo, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { useAppContext } from "../context";
import { MedicationAvatar, MobileEditModal } from "../components";
import { useMedicationForm } from "../hooks";
import { formatNumber, formatDateTime, combineDateAndTime } from "../utils/formatters";
import { getPackageSize, FIELD_LIMITS } from "../types";
import { ConfirmModal, MedicationAvatar, MobileEditModal } from "../components";
import { useAppContext, useUnsavedChanges } from "../context";
import { useMedicationForm, useUnsavedChangesWarning } from "../hooks";
import type { Medication } from "../types";
import { FIELD_LIMITS, getPackageSize } from "../types";
import { combineDateAndTime, formatDateTime, formatNumber } from "../utils/formatters";
export function MedicationsPage() {
const { t, i18n } = useTranslation();
const { t } = useTranslation();
const {
meds,
loading,
saving,
setSaving,
loadMeds,
@@ -34,7 +33,6 @@ export function MedicationsPage() {
setForm,
setOriginalForm,
editingId,
setEditingId,
formSaved,
setFormSaved,
formChanged,
@@ -53,12 +51,39 @@ export function MedicationsPage() {
startEdit,
} = useMedicationForm();
// Warn user about unsaved changes when navigating away
useUnsavedChangesWarning(formChanged);
// Mobile modal state (declared early because it's used in useEffect below)
const [showEditModal, setShowEditModal] = useState(false);
// Sync formChanged state to the global context for navigation blocking
const { setHasUnsavedChanges } = useUnsavedChanges();
useEffect(() => {
setHasUnsavedChanges(formChanged);
return () => setHasUnsavedChanges(false); // Clear on unmount
}, [formChanged, setHasUnsavedChanges]);
// Push history state when form changes to capture browser back button
const hasUnsavedHistoryState = useRef(false);
useEffect(() => {
if (formChanged && !hasUnsavedHistoryState.current && !showEditModal) {
// Push a history state so we can intercept browser back
window.history.pushState({ unsavedChanges: true }, "");
hasUnsavedHistoryState.current = true;
} else if (!formChanged && hasUnsavedHistoryState.current) {
// Clean up history state when form is saved/reset
hasUnsavedHistoryState.current = false;
}
}, [formChanged, showEditModal]);
// 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);
// Track if close was confirmed programmatically (to avoid double confirmation)
const closeConfirmedRef = useRef(false);
// Confirmation modal for unsaved changes
const [showUnsavedConfirm, setShowUnsavedConfirm] = useState(false);
// Calculate total tablets
const totalTablets = useMemo(() => {
@@ -72,19 +97,53 @@ export function MedicationsPage() {
// Open mobile edit modal
function openEditModal() {
setShowEditModal(true);
window.history.pushState({ modal: 'edit' }, '');
window.history.pushState({ modal: "edit" }, "");
}
// Close mobile edit modal
function closeEditModal() {
if (showEditModal) {
// Check for unsaved changes before closing
if (formChanged) {
setShowUnsavedConfirm(true);
return;
}
// Mark as confirmed to avoid double confirmation in popstate handler
closeConfirmedRef.current = true;
window.history.back();
}
}
// Handle confirmed close (user clicked "Leave" in confirmation modal)
function handleConfirmClose() {
setShowUnsavedConfirm(false);
closeConfirmedRef.current = true;
hasUnsavedHistoryState.current = false;
if (showEditModal) {
setShowEditModal(false);
}
resetForm();
window.history.back();
}
// Handle cancelled close (user clicked "Stay" in confirmation modal)
function handleCancelClose() {
setShowUnsavedConfirm(false);
}
// Helper to reset form and clear history state
function handleResetForm() {
if (hasUnsavedHistoryState.current) {
hasUnsavedHistoryState.current = false;
// Go back to remove the unsaved changes history entry
window.history.back();
}
resetForm();
}
// Handle delete medication
async function handleDeleteMed(id: number) {
if (!confirm(t('medications.deleteConfirm'))) return;
if (!confirm(t("medications.deleteConfirm"))) return;
await deleteMed(id, editingId, resetForm);
}
@@ -100,7 +159,7 @@ export function MedicationsPage() {
setSaving(true);
// Prepare medication data
const blisters = form.blisters.map(b => ({
const blisters = form.blisters.map((b) => ({
usage: Number(b.usage) || 1,
every: Number(b.every) || 1,
start: combineDateAndTime(b.startDate, b.startTime),
@@ -151,6 +210,12 @@ export function MedicationsPage() {
setFormSaved(true);
loadMeds();
// Clean up history state if we had unsaved changes
if (hasUnsavedHistoryState.current) {
hasUnsavedHistoryState.current = false;
// Don't go back here, just clear the flag - the state will be cleaned naturally
}
// Reset form after successful save
if (!editingId) {
resetForm();
@@ -160,34 +225,62 @@ export function MedicationsPage() {
}
} catch (err) {
console.error("Save error:", err);
alert(t('common.saveFailed'));
alert(t("common.saveFailed"));
}
setSaving(false);
}
// Handle browser back button for modals
// Handle browser back button for modals and unsaved changes
useEffect(() => {
const handlePopState = () => {
// If close was already confirmed programmatically, allow navigation
if (closeConfirmedRef.current) {
closeConfirmedRef.current = false;
if (showEditModal) {
setShowEditModal(false);
resetForm();
}
return;
}
// Handle mobile edit modal
if (showEditModal) {
// Check for unsaved changes (user pressed browser back directly)
if (formChanged) {
// Re-push history state to stay in modal
window.history.pushState({ modal: "edit" }, "");
// Show confirmation modal
setShowUnsavedConfirm(true);
return;
}
setShowEditModal(false);
resetForm();
return;
}
// Handle desktop form with unsaved changes
if (formChanged && hasUnsavedHistoryState.current) {
// Re-push history state to stay on page
window.history.pushState({ unsavedChanges: true }, "");
// Show confirmation modal
setShowUnsavedConfirm(true);
}
};
window.addEventListener('popstate', handlePopState);
return () => window.removeEventListener('popstate', handlePopState);
}, [showEditModal]);
window.addEventListener("popstate", handlePopState);
return () => window.removeEventListener("popstate", handlePopState);
}, [showEditModal, formChanged, resetForm]);
// 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]);
}, [showEditModal, closeEditModal]);
// Handle edit button click - open modal on mobile
function handleEditClick(med: Medication) {
@@ -198,10 +291,10 @@ export function MedicationsPage() {
<section className="grid">
<article className="card meds">
<div className="card-head">
<h2>{t('medications.list.title')}</h2>
<button
type="button"
className="btn primary small"
<h2>{t("medications.list.title")}</h2>
<button
type="button"
className="btn primary small"
onClick={() => {
resetForm();
// On mobile, open the edit modal
@@ -210,7 +303,7 @@ export function MedicationsPage() {
}
}}
>
+ {t('form.newEntry')}
+ {t("form.newEntry")}
</button>
</div>
<div className="med-list">
@@ -223,22 +316,38 @@ export function MedicationsPage() {
<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>
<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 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>
<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)}
{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)}
</div>
))}
</div>
@@ -249,106 +358,145 @@ export function MedicationsPage() {
<article className="card form desktop-only">
<div className="card-head">
<h2>{editingId ? t('form.editEntry') : t('form.newEntry')}</h2>
<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')}
<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
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')}
<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')}
<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>
<button type="button" className="tag-remove" onClick={() => removeTakenByPerson(person)}>
×
</button>
</span>
))}
<input
value={takenByInput}
onChange={(e) => setTakenByInput(e.target.value)}
<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')}
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} />
))}
{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)} />
{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)} />
{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)} />
{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)} />
{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')} />
{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')}
{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')} />
{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>
<h4 className="refill-title">{t("refill.title")}</h4>
<div className="refill-form-inline">
<label>
{t('refill.packs')}
{t("refill.packs")}
<input
type="number"
min="0"
value={refillPacks}
onChange={(e) => setRefillPacks(parseInt(e.target.value) || 0)}
onChange={(e) => setRefillPacks(parseInt(e.target.value, 10) || 0)}
/>
</label>
<label>
{t('refill.loosePills')}
{t("refill.loosePills")}
<input
type="number"
min="0"
value={refillLoose}
onChange={(e) => setRefillLoose(parseInt(e.target.value) || 0)}
onChange={(e) => setRefillLoose(parseInt(e.target.value, 10) || 0)}
/>
</label>
<button
@@ -357,29 +505,36 @@ export function MedicationsPage() {
onClick={() => handleSubmitRefill(editingId!)}
disabled={(refillPacks < 1 && refillLoose < 1) || refillSaving}
>
{refillSaving ? t('refill.adding') : t('refill.button')}
{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>
<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')}
<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'; }}
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 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>}
@@ -387,63 +542,88 @@ export function MedicationsPage() {
<div className="full blisters">
<div className="card-head">
<h3>{t('form.blisters.title')}</h3>
<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 }))}
<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>
<span>🔔 {t("form.blisters.remind")}</span>
</label>
<button type="button" className="primary" onClick={addBlister}>+ {t('form.blisters.addIntake')}</button>
<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)} />
{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)} />
{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)} />
{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)} />
{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>
<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>
<label className="setting-label">{t("form.medicationImage")}</label>
{(() => {
// When editing an existing medication
if (editingId) {
const currentMed = meds.find(m => m.id === 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>
<button type="button" className="danger" onClick={() => deleteMedImage(editingId)}>
{t("form.removeImage")}
</button>
</div>
);
}
return (
<input
type="file"
<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}
@@ -455,13 +635,22 @@ export function MedicationsPage() {
return (
<div className="image-preview">
<img src={pendingImagePreview} alt="Preview" />
<button type="button" className="danger" onClick={() => { setPendingImage(null); setPendingImagePreview(null); }}>{t('form.removeImage')}</button>
<button
type="button"
className="danger"
onClick={() => {
setPendingImage(null);
setPendingImagePreview(null);
}}
>
{t("form.removeImage")}
</button>
</div>
);
}
return (
<input
type="file"
<input
type="file"
accept="image/jpeg,image/png,image/webp,image/gif"
onChange={(e) => {
const file = e.target.files?.[0];
@@ -479,12 +668,15 @@ export function MedicationsPage() {
<div className="full align-end gap">
{editingId && (
<button type="button" className="ghost" onClick={resetForm}>
{t('common.cancel')}
<button type="button" className="ghost" onClick={handleResetForm}>
{t("common.cancel")}
</button>
)}
<button type="submit" disabled={saving || hasValidationErrors || (!formChanged && (formSaved || !!editingId))}>
{formSaved && !formChanged ? t('common.saved') : t('common.save')}
)}
<button
type="submit"
disabled={saving || hasValidationErrors || (!formChanged && (formSaved || !!editingId))}
>
{formSaved && !formChanged ? t("common.saved") : t("common.save")}
</button>
</div>
</form>
@@ -520,12 +712,25 @@ export function MedicationsPage() {
meds={meds}
onUploadMedImage={uploadMedImage}
onDeleteMedImage={deleteMedImage}
onClose={() => { closeEditModal(); }}
onResetForm={resetForm}
onClose={() => {
closeEditModal();
}}
onResetForm={handleResetForm}
onSaveMedication={saveMedication}
/>
{/* Unsaved Changes Confirmation Modal */}
{showUnsavedConfirm && (
<ConfirmModal
title={t("common.unsavedChanges.title", "Unsaved Changes")}
message={t("common.unsavedChanges.message")}
confirmLabel={t("common.unsavedChanges.leave", "Leave")}
cancelLabel={t("common.unsavedChanges.stay", "Stay")}
onConfirm={handleConfirmClose}
onCancel={handleCancelClose}
confirmVariant="danger"
/>
)}
</section>
);
}
+68 -29
View File
@@ -1,8 +1,8 @@
import { useState, useEffect } from "react";
import { useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { MedicationAvatar } from "../components";
import { useAuth } from "../components/Auth";
import { useAppContext } from "../context";
import { MedicationAvatar } from "../components";
import type { PlannerRow } from "../types";
import { toInputValue } from "../utils/formatters";
@@ -39,7 +39,7 @@ export function PlannerPage() {
const [plannerLoading, setPlannerLoading] = useState(false);
const [range, setRange] = useState<{ start: string; end: string }>({
start: toInputValue(todayIso()),
end: toInputValue(plusDaysIso(3))
end: toInputValue(plusDaysIso(3)),
});
const [sendingPlannerEmail, setSendingPlannerEmail] = useState(false);
const [plannerEmailResult, setPlannerEmailResult] = useState<{ success: boolean; message: string } | null>(null);
@@ -49,15 +49,23 @@ export function PlannerPage() {
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([]); }
try {
setPlannerRows(JSON.parse(savedRows));
} catch {
setPlannerRows([]);
}
} else {
setPlannerRows([]);
}
if (savedRange) {
try { setRange(JSON.parse(savedRange)); } catch { /* keep default */ }
try {
setRange(JSON.parse(savedRange));
} catch {
/* keep default */
}
} else {
setRange({ start: toInputValue(todayIso()), end: toInputValue(plusDaysIso(3)) });
}
@@ -71,9 +79,13 @@ export function PlannerPage() {
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) })
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[];
.catch(() => [])) as PlannerRow[];
setPlannerRows(rows);
setPlannerLoading(false);
// Save to user-specific localStorage
@@ -124,43 +136,70 @@ export function PlannerPage() {
<section className="grid">
<article className="card">
<div className="card-head">
<h2>{t('planner.title')}</h2>
<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 })} />
{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 })} />
{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>
<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>
<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);
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 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 && ` + ${Math.round(row.loosePills * 10) / 10} ${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>
<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>
);
})}
@@ -168,7 +207,7 @@ export function PlannerPage() {
{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')}
{sendingPlannerEmail ? t("common.sending") : t("planner.sendEmail")}
</button>
{plannerEmailResult && (
<span className={plannerEmailResult.success ? "success-text" : "danger-text"}>
+248 -131
View File
@@ -1,7 +1,7 @@
import { useTranslation } from "react-i18next";
import { MedicationAvatar } from "../components";
import { useAuth } from "../components/Auth";
import { useAppContext } from "../context";
import { MedicationAvatar } from "../components";
import type { Coverage } from "../types";
// Helper for user-specific localStorage keys
@@ -10,7 +10,11 @@ function userStorageKey(userId: number | undefined, key: string): string {
}
// Helper function to get stock status
function getStockStatus(daysLeft: number | null, medsLeft: number, settings: { lowStockDays: number; normalStockDays: number; highStockDays: number }) {
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" };
@@ -18,7 +22,11 @@ function getStockStatus(daysLeft: number | null, medsLeft: number, settings: { l
}
// 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 {
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];
@@ -61,8 +69,8 @@ export function SchedulePage() {
<section className="grid">
<article className="card schedule-full">
<div className="card-head">
<h2>{t('dashboard.schedules.title')}</h2>
<select
<h2>{t("dashboard.schedules.title")}</h2>
<select
className="schedule-days-select"
value={scheduleDays}
onChange={(e) => {
@@ -71,89 +79,267 @@ export function SchedulePage() {
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>
<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')}
{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="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 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>
{!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]);
);
})()}
{/* 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="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>
<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) => {
// If no takenBy, show single checkbox; otherwise show one per person
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 past">
<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>
<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" : ""}`}>
{person && <span className="person-name clickable" onClick={() => openUserFilter(person)}>{person}</span>}
<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 undo"
onClick={() => undoDoseTaken(doseId)}
title={t("common.undo")}
>
</button>
) : (
<button className="dose-btn take" onClick={() => markDoseTaken(doseId)} disabled={isEmpty} title={t('dose.markAsTaken')}></button>
<button
className="dose-btn take"
onClick={() => markDoseTaken(doseId)}
disabled={isEmpty}
title={t("dose.markAsTaken")}
>
</button>
)}
</div>
);
@@ -169,75 +355,6 @@ export function SchedulePage() {
</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>
+249 -118
View File
@@ -1,6 +1,6 @@
import { useTranslation } from "react-i18next";
import { useAppContext } from "../context";
import { ConfirmModal, ExportModal } from "../components";
import { useAppContext } from "../context";
import { getSystemLocale } from "../utils/formatters";
export function SettingsPage() {
@@ -30,7 +30,6 @@ export function SettingsPage() {
handleImportFileSelect,
showImportConfirm,
setShowImportConfirm,
pendingImportData,
setPendingImportData,
handleImportConfirm,
importResult,
@@ -40,17 +39,17 @@ export function SettingsPage() {
return (
<section className="grid">
{settingsLoading ? (
<p>{t('settings.loading')}</p>
<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>
<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>
<span className="setting-label">{t("settings.language.select")}</span>
<select
value={i18n.language}
onChange={(e) => i18n.changeLanguage(e.target.value)}
@@ -66,23 +65,23 @@ export function SettingsPage() {
{/* Notifications */}
<article className="card">
<div className="card-head">
<h2>{t('settings.notifications.title')}</h2>
<h2>{t("settings.notifications.title")}</h2>
</div>
<div className="setting-section">
<div className="section-header">
<h3>{t('settings.notifications.channels')}</h3>
<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 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-label">{t("settings.notifications.stockReminders")}</div>
<div className="matrix-cell">
<label className={`toggle-switch small${!settings.emailEnabled ? ' disabled' : ''}`}>
<label className={`toggle-switch small${!settings.emailEnabled ? " disabled" : ""}`}>
<input
type="checkbox"
checked={settings.smtpHost && settings.emailEnabled ? settings.emailStockReminders : false}
@@ -93,10 +92,12 @@ export function SettingsPage() {
</label>
</div>
<div className="matrix-cell">
<label className={`toggle-switch small${!settings.shoutrrrEnabled ? ' disabled' : ''}`}>
<label className={`toggle-switch small${!settings.shoutrrrEnabled ? " disabled" : ""}`}>
<input
type="checkbox"
checked={settings.shoutrrrUrl && settings.shoutrrrEnabled ? settings.shoutrrrStockReminders : false}
checked={
settings.shoutrrrUrl && settings.shoutrrrEnabled ? settings.shoutrrrStockReminders : false
}
onChange={(e) => setSettings({ ...settings, shoutrrrStockReminders: e.target.checked })}
disabled={!settings.shoutrrrEnabled}
/>
@@ -105,9 +106,9 @@ export function SettingsPage() {
</div>
</div>
<div className="matrix-row">
<div className="matrix-label">{t('settings.notifications.intakeReminders')}</div>
<div className="matrix-label">{t("settings.notifications.intakeReminders")}</div>
<div className="matrix-cell">
<label className={`toggle-switch small${!settings.emailEnabled ? ' disabled' : ''}`}>
<label className={`toggle-switch small${!settings.emailEnabled ? " disabled" : ""}`}>
<input
type="checkbox"
checked={settings.smtpHost && settings.emailEnabled ? settings.emailIntakeReminders : false}
@@ -118,10 +119,12 @@ export function SettingsPage() {
</label>
</div>
<div className="matrix-cell">
<label className={`toggle-switch small${!settings.shoutrrrEnabled ? ' disabled' : ''}`}>
<label className={`toggle-switch small${!settings.shoutrrrEnabled ? " disabled" : ""}`}>
<input
type="checkbox"
checked={settings.shoutrrrUrl && settings.shoutrrrEnabled ? settings.shoutrrrIntakeReminders : false}
checked={
settings.shoutrrrUrl && settings.shoutrrrEnabled ? settings.shoutrrrIntakeReminders : false
}
onChange={(e) => setSettings({ ...settings, shoutrrrIntakeReminders: e.target.checked })}
disabled={!settings.shoutrrrEnabled}
/>
@@ -131,16 +134,20 @@ export function SettingsPage() {
</div>
</div>
{!settings.emailEnabled && !settings.shoutrrrEnabled && (
<p className="hint-text">{t('settings.notifications.enableHint')}</p>
<p className="hint-text">{t("settings.notifications.enableHint")}</p>
)}
{/* Skip reminders for taken doses */}
<div className="setting-row compact" style={{marginTop: "16px"}}>
<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>
{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' : ''}`}>
<label
className={`toggle-switch small${!settings.emailEnabled && !settings.shoutrrrEnabled ? " disabled" : ""}`}
>
<input
type="checkbox"
checked={settings.skipRemindersForTakenDoses}
@@ -150,14 +157,21 @@ export function SettingsPage() {
<span className="toggle-slider"></span>
</label>
</div>
{/* Repeat reminders for missed doses */}
<div className="setting-row compact" style={{marginTop: "12px"}}>
<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>
{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' : ''}`}>
<label
className={`toggle-switch small${!settings.emailEnabled && !settings.shoutrrrEnabled ? " disabled" : ""}`}
>
<input
type="checkbox"
checked={settings.repeatRemindersEnabled}
@@ -167,14 +181,19 @@ export function SettingsPage() {
<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"}}>
<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>
{t("settings.notifications.reminderInterval")}
<span
className="info-tooltip small"
data-tooltip={t("settings.notifications.reminderIntervalTooltip")}
>
</span>
</label>
<input
type="number"
@@ -182,14 +201,21 @@ export function SettingsPage() {
max="480"
step="5"
value={settings.reminderRepeatIntervalMinutes}
onChange={(e) => setSettings({ ...settings, reminderRepeatIntervalMinutes: parseInt(e.target.value) || 30 })}
style={{width: "80px", textAlign: "center"}}
onChange={(e) =>
setSettings({ ...settings, reminderRepeatIntervalMinutes: parseInt(e.target.value, 10) || 30 })
}
style={{ width: "80px", textAlign: "center" }}
/>
</div>
<div className="setting-row compact" style={{marginTop: "8px", marginLeft: "24px"}}>
<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>
{t("settings.notifications.maxNaggingReminders")}
<span
className="info-tooltip small"
data-tooltip={t("settings.notifications.maxNaggingRemindersTooltip")}
>
</span>
</label>
<input
type="number"
@@ -197,8 +223,13 @@ export function SettingsPage() {
max="20"
step="1"
value={settings.maxNaggingReminders ?? 5}
onChange={(e) => setSettings({ ...settings, maxNaggingReminders: parseInt(e.target.value) || 5 })}
style={{width: "80px", textAlign: "center"}}
onChange={(e) => {
const val = parseInt(e.target.value, 10);
if (!Number.isNaN(val)) {
setSettings({ ...settings, maxNaggingReminders: Math.max(1, Math.min(20, val)) });
}
}}
style={{ width: "80px", textAlign: "center" }}
/>
</div>
</>
@@ -207,15 +238,22 @@ export function SettingsPage() {
<div className="setting-section">
<div className="section-header">
<h3>{t('settings.notifications.email')}</h3>
<label className={`toggle-switch small${!settings.smtpHost ? ' disabled' : ''}`}>
<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 });
setSettings({
...settings,
emailEnabled: false,
emailStockReminders: false,
emailIntakeReminders: false,
skipRemindersForTakenDoses: false,
repeatRemindersEnabled: false,
});
} else {
setSettings({ ...settings, emailEnabled: newVal });
}
@@ -229,7 +267,7 @@ export function SettingsPage() {
<>
<div className="setting-group">
<label className="full">
<span className="field-label">{t('settings.email.recipient')}</span>
<span className="field-label">{t("settings.email.recipient")}</span>
<div className="input-with-tooltip">
<input
type="email"
@@ -239,13 +277,23 @@ export function SettingsPage() {
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>
<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
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"}>
@@ -259,7 +307,7 @@ export function SettingsPage() {
<div className="setting-section">
<div className="section-header">
<h3>{t('settings.notifications.push')}</h3>
<h3>{t("settings.notifications.push")}</h3>
<label className="toggle-switch small">
<input
type="checkbox"
@@ -267,7 +315,14 @@ export function SettingsPage() {
onChange={(e) => {
const newVal = e.target.checked;
if (!newVal && !settings.emailEnabled) {
setSettings({ ...settings, shoutrrrEnabled: false, shoutrrrStockReminders: false, shoutrrrIntakeReminders: false, skipRemindersForTakenDoses: false, repeatRemindersEnabled: false });
setSettings({
...settings,
shoutrrrEnabled: false,
shoutrrrStockReminders: false,
shoutrrrIntakeReminders: false,
skipRemindersForTakenDoses: false,
repeatRemindersEnabled: false,
});
} else {
setSettings({ ...settings, shoutrrrEnabled: newVal });
}
@@ -280,21 +335,31 @@ export function SettingsPage() {
<>
<div className="setting-group">
<label className="full">
<span className="field-label">{t('settings.push.url')}</span>
<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')}
placeholder={t("settings.push.urlPlaceholder")}
/>
<span className="info-tooltip" data-tooltip={`${t('settings.push.supports')}\n\n${t('settings.push.docsLink')}`}></span>
<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
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"}>
@@ -308,27 +373,45 @@ export function SettingsPage() {
<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>
<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>
<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>
<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(getSystemLocale(i18n.language), { day: "2-digit", month: "2-digit", year: "numeric", hour: "2-digit", minute: "2-digit" })}</span>
<span className="schedule-label">{t("settings.schedule.nextCheck")}</span>
<span className="schedule-value">
{new Date(settings.nextScheduledCheck).toLocaleString(getSystemLocale(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(getSystemLocale(i18n.language), { day: "2-digit", month: "2-digit", year: "numeric", hour: "2-digit", minute: "2-digit" })}</span>
<span className="schedule-label">{t("settings.schedule.lastSent")}</span>
<span className="schedule-value">
{new Date(settings.lastAutoEmailSent).toLocaleString(getSystemLocale(i18n.language), {
day: "2-digit",
month: "2-digit",
year: "numeric",
hour: "2-digit",
minute: "2-digit",
})}
</span>
</div>
)}
</div>
@@ -337,16 +420,16 @@ export function SettingsPage() {
{/* Stock Settings */}
<article className="card">
<div className="card-head">
<h2>{t('settings.stock.title')}</h2>
<h2>{t("settings.stock.title")}</h2>
</div>
<div className="setting-section">
<div className="section-header">
<h3>{t('settings.stock.threshold')}</h3>
<h3>{t("settings.stock.threshold")}</h3>
</div>
<div className="threshold-input">
<label>
<span className="threshold-label">{t('settings.stock.remindWhen')}</span>
<span className="threshold-label">{t("settings.stock.remindWhen")}</span>
<div className="threshold-field">
<input
type="number"
@@ -355,21 +438,30 @@ export function SettingsPage() {
value={settings.reminderDaysBefore}
onChange={(e) => setSettings({ ...settings, reminderDaysBefore: Number(e.target.value) || 7 })}
/>
<span className="threshold-unit">{t('common.days')}</span>
<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>
{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' : ''}`}>
<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))}
disabled={
!(
(settings.emailEnabled && settings.emailStockReminders && settings.notificationEmail) ||
(settings.shoutrrrEnabled && settings.shoutrrrStockReminders && settings.shoutrrrUrl)
)
}
/>
<span className="toggle-slider"></span>
</label>
@@ -378,36 +470,40 @@ export function SettingsPage() {
<div className="setting-section">
<div className="section-header">
<h3>{t('settings.stock.calculationMode')}</h3>
<h3>{t("settings.stock.calculationMode")}</h3>
</div>
<div className="setting-group calculation-mode-group">
<label className={`radio-card ${settings.stockCalculationMode === 'automatic' ? 'selected' : ''}`}>
<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' })}
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>
<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' : ''}`}>
<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' })}
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>
<span className="radio-card-title">{t("settings.stock.manual")}</span>
<span className="radio-card-desc">{t("settings.stock.manualDesc")}</span>
</div>
</div>
</label>
@@ -416,11 +512,11 @@ export function SettingsPage() {
<div className="setting-section">
<div className="section-header">
<h3>{t('settings.stock.display')}</h3>
<h3>{t("settings.stock.display")}</h3>
</div>
<div className="setting-group">
<label>
<span className="field-label">{t('settings.stock.lowStockDays')}</span>
<span className="field-label">{t("settings.stock.lowStockDays")}</span>
<div className="input-with-tooltip">
<input
type="number"
@@ -429,11 +525,13 @@ export function SettingsPage() {
value={settings.lowStockDays}
onChange={(e) => setSettings({ ...settings, lowStockDays: Number(e.target.value) || 30 })}
/>
<span className="info-tooltip" data-tooltip={t('settings.stock.lowStockTooltip')}></span>
<span className="info-tooltip" data-tooltip={t("settings.stock.lowStockTooltip")}>
</span>
</div>
</label>
<label>
<span className="field-label">{t('settings.stock.highStockDays')}</span>
<span className="field-label">{t("settings.stock.highStockDays")}</span>
<div className="input-with-tooltip">
<input
type="number"
@@ -442,7 +540,9 @@ export function SettingsPage() {
value={settings.highStockDays}
onChange={(e) => setSettings({ ...settings, highStockDays: Number(e.target.value) || 180 })}
/>
<span className="info-tooltip" data-tooltip={t('settings.stock.highStockTooltip')}></span>
<span className="info-tooltip" data-tooltip={t("settings.stock.highStockTooltip")}>
</span>
</div>
</label>
</div>
@@ -453,38 +553,65 @@ export function SettingsPage() {
<article className="card">
<div className="card-head">
<h2>
{t('exportImport.title')}
<span className="info-tooltip" data-tooltip={t('exportImport.description')}></span>
{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
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>
<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
<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}}
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>
>
×
</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>
<span className="action-card-title">{t("exportImport.exportTitle")}</span>
<span className="action-card-desc">{t("exportImport.exportDesc")}</span>
</div>
<button
type="button"
@@ -492,15 +619,15 @@ export function SettingsPage() {
onClick={() => setShowExportModal(true)}
disabled={exporting}
>
{exporting ? t('exportImport.exporting') : t('exportImport.export')}
{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>
<span className="action-card-title">{t("exportImport.importTitle")}</span>
<span className="action-card-desc">{t("exportImport.importDesc")}</span>
</div>
<input
type="file"
@@ -508,15 +635,15 @@ export function SettingsPage() {
accept=".json,application/json"
onChange={handleImportFileSelect}
disabled={importing}
style={{display: 'none'}}
style={{ display: "none" }}
/>
<button
type="button"
className="secondary"
onClick={() => document.getElementById('import-file-input')?.click()}
onClick={() => document.getElementById("import-file-input")?.click()}
disabled={importing}
>
{importing ? t('exportImport.importing') : t('exportImport.import')}
{importing ? t("exportImport.importing") : t("exportImport.import")}
</button>
</div>
</div>
@@ -525,7 +652,11 @@ export function SettingsPage() {
<div className="form-footer">
<button type="submit" disabled={settingsSaving || (!settingsChanged && settingsSaved)}>
{settingsSaving ? t('common.saving') : settingsSaved && !settingsChanged ? t('common.saved') : t('settings.saveSettings')}
{settingsSaving
? t("common.saving")
: settingsSaved && !settingsChanged
? t("common.saved")
: t("settings.saveSettings")}
</button>
</div>
</form>
@@ -534,15 +665,15 @@ export function SettingsPage() {
{/* Import Confirmation Modal */}
{showImportConfirm && (
<ConfirmModal
title={t('exportImport.confirmImport')}
title={t("exportImport.confirmImport")}
message={
<>
<p style={{ marginBottom: "12px" }}>{t('exportImport.confirmImportMessage')}</p>
<p className="warning-text"> {t('exportImport.confirmImportWarning')}</p>
<p style={{ marginBottom: "12px" }}>{t("exportImport.confirmImportMessage")}</p>
<p className="warning-text"> {t("exportImport.confirmImportWarning")}</p>
</>
}
confirmLabel={t('exportImport.confirmButton')}
cancelLabel={t('exportImport.cancelButton')}
confirmLabel={t("exportImport.confirmButton")}
cancelLabel={t("exportImport.cancelButton")}
onConfirm={handleImportConfirm}
onCancel={() => {
setShowImportConfirm(false);