feat: Add package type support and per-intake takenBy (#89)

## Package Type Feature
- Add 'blister' and 'bottle' package types for medications
- Bottle type uses totalPills for capacity and looseTablets for current stock
- Blister type continues to use packCount/blistersPerPack/pillsPerBlister
- Add doseUnit field for flexible dosing (mg, ml, IU, etc.)
- Full UI support in medication form and detail modal

## Per-Intake TakenBy
- Move takenBy from medication level to individual intakes
- Each intake schedule can now be assigned to a different person
- Update scheduler-utils to handle per-intake takenBy
- Update SharedSchedule to filter by per-intake takenBy
- Backward compatible with existing medication data

## UI Improvements
- Add PasswordInput component with show/hide toggle
- Centralize stockThresholds in AppContext for consistent status display
- Fix SharedSchedule sync issues with per-intake takenBy
- Improve mobile editing experience

## Technical
- Add migrations 0004 and 0005 for schema changes
- Update all relevant tests (1064 tests passing)
- Maintain backward compatibility with ALTER migrations
This commit is contained in:
Daniel Volz
2026-01-31 23:49:11 +01:00
committed by GitHub
parent ac4b8151e4
commit 571d94bf7e
37 changed files with 2896 additions and 990 deletions
+5 -2
View File
@@ -185,6 +185,9 @@ function AppContent() {
const [showProfile, setShowProfile] = useState(false);
const [showAbout, setShowAbout] = useState(false);
// Get centralized stockThresholds from context
const { stockThresholds } = ctx;
// Close modal on Escape key
useEffect(() => {
const handleEscape = (e: KeyboardEvent) => {
@@ -417,7 +420,7 @@ function AppContent() {
<MedDetailModal
selectedMed={selectedMed}
coverage={coverage}
settings={settings}
settings={stockThresholds}
showImageLightbox={showImageLightbox}
showRefillModal={showRefillModal}
showEditStockModal={showEditStockModal}
@@ -450,7 +453,7 @@ function AppContent() {
selectedUser={selectedUser}
meds={meds}
coverage={coverage}
settings={settings}
settings={stockThresholds}
onClose={closeUserFilter}
onOpenMedDetail={openMedDetail}
/>
+7 -12
View File
@@ -1,6 +1,7 @@
import { createContext, type ReactNode, useCallback, useContext, useEffect, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { ConfirmModal } from "./ConfirmModal";
import { PasswordInput } from "./PasswordInput";
// =============================================================================
// Types (no roles - all users are equal)
@@ -402,9 +403,8 @@ export function LoginForm({
<div className="form-group">
<label htmlFor="password">{t("auth.password", "Password")}</label>
<input
<PasswordInput
id="password"
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
required
@@ -522,9 +522,8 @@ export function RegisterForm({ onSuccess, onSwitchToLogin }: { onSuccess?: () =>
<div className="form-group">
<label htmlFor="password">{t("auth.password", "Password")} *</label>
<input
<PasswordInput
id="password"
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
required
@@ -536,9 +535,8 @@ export function RegisterForm({ onSuccess, onSwitchToLogin }: { onSuccess?: () =>
<div className="form-group">
<label htmlFor="confirmPassword">{t("auth.confirmPassword", "Confirm Password")} *</label>
<input
<PasswordInput
id="confirmPassword"
type="password"
value={confirmPassword}
onChange={(e) => setConfirmPassword(e.target.value)}
required
@@ -722,9 +720,8 @@ export function UserProfile({ onClose }: { onClose?: () => void }) {
<div className="form-group">
<label htmlFor="current-password">{t("auth.currentPassword", "Current Password")}</label>
<input
<PasswordInput
id="current-password"
type="password"
value={currentPassword}
onChange={(e) => setCurrentPassword(e.target.value)}
autoComplete="current-password"
@@ -734,9 +731,8 @@ export function UserProfile({ onClose }: { onClose?: () => void }) {
<div className="form-group">
<label htmlFor="new-password">{t("auth.newPassword", "New Password")}</label>
<input
<PasswordInput
id="new-password"
type="password"
value={newPassword}
onChange={(e) => setNewPassword(e.target.value)}
autoComplete="new-password"
@@ -747,9 +743,8 @@ export function UserProfile({ onClose }: { onClose?: () => void }) {
<div className="form-group">
<label htmlFor="confirm-new-password">{t("auth.confirmPassword", "Confirm Password")}</label>
<input
<PasswordInput
id="confirm-new-password"
type="password"
value={confirmPassword}
onChange={(e) => setConfirmPassword(e.target.value)}
autoComplete="new-password"
+48 -31
View File
@@ -171,25 +171,30 @@ export function MedDetailModal({
<div className="med-detail-section">
<h3>{t("modal.stockInfo")}</h3>
<div className="med-detail-grid">
<div className="med-detail-item">
<span className="med-detail-label">{t("table.fullBlisters")}</span>
<span className={`med-detail-value ${textClass}`}>{formatFullBlisters(stock.fullBlisters, t)}</span>
</div>
<div className="med-detail-item">
<span className="med-detail-label">{t("table.openBlister")}</span>
<span className={`med-detail-value ${textClass}`}>
{formatOpenBlisterAndLoose(
stock.openBlisterPills,
stock.loosePills,
selectedMed.pillsPerBlister ?? 1,
t
)}
</span>
</div>
<div className="med-detail-item full-width">
{selectedMed.packageType === "blister" && (
<>
<div className="med-detail-item">
<span className="med-detail-label">{t("table.fullBlisters")}</span>
<span className={`med-detail-value ${textClass}`}>{formatFullBlisters(stock.fullBlisters, t)}</span>
</div>
<div className="med-detail-item">
<span className="med-detail-label">{t("table.openBlister")}</span>
<span className={`med-detail-value ${textClass}`}>
{formatOpenBlisterAndLoose(
stock.openBlisterPills,
stock.loosePills,
selectedMed.pillsPerBlister ?? 1,
t
)}
</span>
</div>
</>
)}
<div className={`med-detail-item ${selectedMed.packageType === "bottle" ? "full-width" : "full-width"}`}>
<span className="med-detail-label">{t("modal.currentStock")}</span>
<span className={`med-detail-value ${textClass}`}>
{currentStock} / {packageSize}
{currentStock} /{" "}
{selectedMed.packageType === "bottle" ? (selectedMed.totalPills ?? packageSize) : packageSize}
</span>
</div>
</div>
@@ -199,22 +204,33 @@ export function MedDetailModal({
<div className="med-detail-section">
<h3>{t("modal.packageDetails")}</h3>
<div className="med-detail-grid">
<div className="med-detail-item">
<span className="med-detail-label">{t("modal.packs")}</span>
<span className="med-detail-value">{selectedMed.packCount}</span>
</div>
<div className="med-detail-item">
<span className="med-detail-label">{t("modal.blistersPerPack")}</span>
<span className="med-detail-value">{selectedMed.blistersPerPack}</span>
</div>
<div className="med-detail-item">
<span className="med-detail-label">{t("modal.pillsPerBlister")}</span>
<span className="med-detail-value">{selectedMed.pillsPerBlister}</span>
</div>
{selectedMed.packageType === "blister" ? (
<>
<div className="med-detail-item">
<span className="med-detail-label">{t("modal.packs")}</span>
<span className="med-detail-value">{selectedMed.packCount}</span>
</div>
<div className="med-detail-item">
<span className="med-detail-label">{t("modal.blistersPerPack")}</span>
<span className="med-detail-value">{selectedMed.blistersPerPack}</span>
</div>
<div className="med-detail-item">
<span className="med-detail-label">{t("modal.pillsPerBlister")}</span>
<span className="med-detail-value">{selectedMed.pillsPerBlister}</span>
</div>
</>
) : (
<div className="med-detail-item">
<span className="med-detail-label">{t("form.totalCapacity")}</span>
<span className="med-detail-value">{selectedMed.totalPills ?? "—"}</span>
</div>
)}
{selectedMed.pillWeightMg && (
<div className="med-detail-item">
<span className="med-detail-label">{t("modal.pillWeight")}</span>
<span className="med-detail-value">{selectedMed.pillWeightMg} mg</span>
<span className="med-detail-value">
{selectedMed.pillWeightMg} {selectedMed.doseUnit ?? "mg"}
</span>
</div>
)}
{selectedMed.expiryDate && (
@@ -253,7 +269,8 @@ export function MedDetailModal({
<div key={idx} className="med-schedule-item">
<span className="med-schedule-usage">
{totalUsage} {totalUsage !== 1 ? t("common.pills") : t("common.pill")}
{selectedMed.pillWeightMg && ` (${totalUsage * selectedMed.pillWeightMg} mg)`}
{selectedMed.pillWeightMg &&
` (${totalUsage * selectedMed.pillWeightMg} ${selectedMed.doseUnit ?? "mg"})`}
</span>
<span className="med-schedule-freq">
{t("form.blisters.every")} {blister.every}{" "}
+140 -70
View File
@@ -3,7 +3,8 @@
* Handles new medication creation and editing existing medications
*/
import { useTranslation } from "react-i18next";
import type { FieldErrors, FormBlister, FormState, Medication } from "../types";
import type { DoseUnit, FieldErrors, FormBlister, FormIntake, FormState, Medication } from "../types";
import { DOSE_UNITS } from "../types";
import { deriveTotal } from "../utils";
// Field limits for validation
@@ -31,10 +32,14 @@ export interface MobileEditModalProps {
onAddTakenByPerson: (person: string) => void;
onRemoveTakenByPerson: (person: string) => void;
onTakenByKeyDown: (e: React.KeyboardEvent<HTMLInputElement>) => void;
// Blister helpers
// Blister helpers (legacy)
onSetBlisterValue: (idx: number, field: keyof FormBlister, value: string) => void;
onAddBlister: () => void;
onRemoveBlister: (idx: number) => void;
// Intake helpers (new - with per-intake takenBy)
onSetIntakeValue: (idx: number, field: keyof FormIntake, value: string | boolean) => void;
onAddIntake: (takenBy?: string) => void;
onRemoveIntake: (idx: number) => void;
// Value change handler for numeric fields
onHandleValueChange: <K extends keyof FormState>(field: K, value: string) => void;
// Refill state (for edit mode)
@@ -56,6 +61,10 @@ export interface MobileEditModalProps {
/** Calculate total pills from form state */
function deriveTotalFromForm(form: FormState) {
if (form.packageType === "bottle") {
// For bottle type, looseTablets is the current stock
return Number(form.looseTablets) || 0;
}
const packCount = Number(form.packCount) || 0;
const blistersPerPack = Number(form.blistersPerPack) || 0;
const pillsPerBlister = Number(form.pillsPerBlister) || 1;
@@ -82,6 +91,9 @@ export function MobileEditModal({
onSetBlisterValue,
onAddBlister,
onRemoveBlister,
onSetIntakeValue,
onAddIntake,
onRemoveIntake,
onHandleValueChange,
refillPacks,
onRefillPacksChange,
@@ -180,57 +192,106 @@ export function MobileEditModal({
</div>
{fieldErrors.takenBy && <span className="field-error">{fieldErrors.takenBy}</span>}
</label>
<label>
{t("form.packs")}
<input
type="number"
min="0"
value={form.packCount}
onChange={(e) => onHandleValueChange("packCount", e.target.value)}
/>
</label>
<label>
{t("form.blistersPerPack")}
<input
type="number"
min="0"
value={form.blistersPerPack}
onChange={(e) => onHandleValueChange("blistersPerPack", e.target.value)}
/>
</label>
<label>
{t("form.pillsPerBlister")}
<input
type="number"
min="1"
value={form.pillsPerBlister}
onChange={(e) => onHandleValueChange("pillsPerBlister", e.target.value)}
/>
</label>
<label>
{t("form.loosePills")}
<input
type="number"
min="0"
value={form.looseTablets}
onChange={(e) => onHandleValueChange("looseTablets", e.target.value)}
/>
<label className="full">
{t("form.packageType")}
<select
className="package-type-select"
value={form.packageType}
onChange={(e) => onHandleValueChange("packageType", e.target.value)}
>
<option value="blister">{t("form.packageTypeBlister")}</option>
<option value="bottle">{t("form.packageTypeBottle")}</option>
</select>
</label>
{form.packageType === "blister" ? (
<>
<label>
{t("form.packs")}
<input
type="number"
min="0"
value={form.packCount}
onChange={(e) => onHandleValueChange("packCount", e.target.value)}
/>
</label>
<label>
{t("form.blistersPerPack")}
<input
type="number"
min="0"
value={form.blistersPerPack}
onChange={(e) => onHandleValueChange("blistersPerPack", e.target.value)}
/>
</label>
<label>
{t("form.pillsPerBlister")}
<input
type="number"
min="1"
value={form.pillsPerBlister}
onChange={(e) => onHandleValueChange("pillsPerBlister", e.target.value)}
/>
</label>
<label>
{t("form.loosePills")}
<input
type="number"
min="0"
value={form.looseTablets}
onChange={(e) => onHandleValueChange("looseTablets", e.target.value)}
/>
</label>
</>
) : (
<>
<label>
{t("form.totalCapacity")}
<input
type="number"
min="1"
value={form.totalPills}
onChange={(e) => onHandleValueChange("totalPills", e.target.value)}
/>
</label>
<label>
{t("form.currentPills")}
<input
type="number"
min="0"
value={form.looseTablets}
onChange={(e) => onHandleValueChange("looseTablets", e.target.value)}
/>
</label>
</>
)}
<div className="full">
<p className="sub">
<strong>{t("form.total")}:</strong> {deriveTotalFromForm(form)} {t("common.pills")}
</p>
</div>
<label className="full">
{t("form.pillWeight")}
<input
type="number"
min="0"
step="0.1"
value={form.pillWeightMg}
onChange={(e) => onFormChange({ ...form, pillWeightMg: e.target.value })}
placeholder={t("form.placeholders.weight")}
/>
{t("form.pillWeight")} ({form.doseUnit})
<div className="dose-input-group">
<input
type="number"
min="0"
step="0.1"
value={form.pillWeightMg}
onChange={(e) => onFormChange({ ...form, pillWeightMg: e.target.value })}
placeholder={t("form.placeholders.weight")}
/>
<select
value={form.doseUnit}
onChange={(e) => onFormChange({ ...form, doseUnit: e.target.value as DoseUnit })}
className="dose-unit-select"
>
{DOSE_UNITS.map((unit) => (
<option key={unit.value} value={unit.value}>
{unit.label}
</option>
))}
</select>
</div>
</label>
<label className="full">
{t("form.expiryDate")}
@@ -327,19 +388,8 @@ export function MobileEditModal({
) : null}
<fieldset className="full blister-section">
<legend>
{t("form.blisters.title")}
<label className="toggle-switch small" title={t("form.blisters.remindTooltip")}>
<input
type="checkbox"
checked={form.intakeRemindersEnabled}
onChange={(e) => onFormChange({ ...form, intakeRemindersEnabled: e.target.checked })}
/>
<span className="toggle-slider"></span>
</label>
<span className="legend-hint">{t("form.blisters.remind")}</span>
</legend>
{form.blisters.map((b, idx) => (
<legend>{t("form.blisters.title")}</legend>
{form.intakes.map((intake, idx) => (
<div key={idx} className="blister-row">
<label className="compact">
<span>{t("form.blisters.usage")}</span>
@@ -347,8 +397,8 @@ export function MobileEditModal({
type="number"
min="0"
step="0.1"
value={b.usage}
onChange={(e) => onSetBlisterValue(idx, "usage", e.target.value)}
value={intake.usage}
onChange={(e) => onSetIntakeValue(idx, "usage", e.target.value)}
/>
</label>
<label className="compact">
@@ -356,34 +406,54 @@ export function MobileEditModal({
<input
type="number"
min="1"
value={b.every}
onChange={(e) => onSetBlisterValue(idx, "every", e.target.value)}
value={intake.every}
onChange={(e) => onSetIntakeValue(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)}
value={intake.startDate}
onChange={(e) => onSetIntakeValue(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)}
value={intake.startTime}
onChange={(e) => onSetIntakeValue(idx, "startTime", e.target.value)}
/>
</label>
{form.blisters.length > 1 && (
<button type="button" className="danger remove-blister-btn" onClick={() => onRemoveBlister(idx)}>
<label className="compact full-row">
<span>{t("form.blisters.takenByIntake")}</span>
<select value={intake.takenBy} onChange={(e) => onSetIntakeValue(idx, "takenBy", e.target.value)}>
<option value="">{t("form.blisters.takenByEveryone")}</option>
{existingPeople.map((person) => (
<option key={person} value={person}>
{person}
</option>
))}
</select>
</label>
<label className="toggle-switch small" title={t("form.blisters.remindTooltip")}>
<input
type="checkbox"
checked={intake.intakeRemindersEnabled}
onChange={(e) => onSetIntakeValue(idx, "intakeRemindersEnabled", e.target.checked)}
/>
<span className="toggle-slider"></span>
</label>
<span className="legend-hint">🔔</span>
{form.intakes.length > 1 && (
<button type="button" className="danger remove-blister-btn" onClick={() => onRemoveIntake(idx)}>
{t("common.remove")}
</button>
)}
</div>
))}
<button type="button" className="ghost add-blister" onClick={onAddBlister}>
<button type="button" className="ghost add-blister" onClick={() => onAddIntake()}>
+ {t("form.blisters.addIntake")}
</button>
</fieldset>
+74
View File
@@ -0,0 +1,74 @@
import { useState } from "react";
interface PasswordInputProps {
id: string;
value: string;
onChange: (e: React.ChangeEvent<HTMLInputElement>) => void;
required?: boolean;
autoComplete?: string;
minLength?: number;
maxLength?: number;
placeholder?: string;
}
export function PasswordInput({
id,
value,
onChange,
required,
autoComplete,
minLength,
maxLength,
placeholder,
}: PasswordInputProps) {
const [showPassword, setShowPassword] = useState(false);
return (
<div className="password-input-wrapper">
<input
id={id}
type={showPassword ? "text" : "password"}
value={value}
onChange={onChange}
required={required}
autoComplete={autoComplete}
minLength={minLength}
maxLength={maxLength}
placeholder={placeholder}
/>
<button
type="button"
className="password-toggle-btn"
onClick={() => setShowPassword(!showPassword)}
tabIndex={-1}
aria-label={showPassword ? "Hide password" : "Show password"}
>
{showPassword ? (
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<path d="M17.94 17.94A10.07 10.07 0 0 1 12 20c-7 0-11-8-11-8a18.45 18.45 0 0 1 5.06-5.94M9.9 4.24A9.12 9.12 0 0 1 12 4c7 0 11 8 11 8a18.5 18.5 0 0 1-2.16 3.19m-6.72-1.07a3 3 0 1 1-4.24-4.24" />
<line x1="1" y1="1" x2="23" y2="23" />
</svg>
) : (
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z" />
<circle cx="12" cy="12" r="3" />
</svg>
)}
</button>
</div>
);
}
+132 -182
View File
@@ -153,21 +153,20 @@ export function SharedSchedule() {
}
}, [token]);
// Get dose ID with optional person suffix
function getDoseId(baseDoseId: string, person: string | null): string {
return person ? `${baseDoseId}-${person}` : baseDoseId;
// Get dose ID - for per-intake takenBy, the ID already has the person suffix
// This helper is kept for compatibility but since dose.id already includes the suffix, it just returns the id
function getDoseId(doseId: string, _person: string | null): string {
// The dose.id already includes the person suffix if there's a per-intake takenBy
return doseId;
}
// Count taken doses for a day/item
function _countTakenDoses(doses: Array<{ id: string; takenBy: string[] }>): { total: number; taken: number } {
// Count taken doses for a day/item (simplified - per-intake takenBy means one person per dose)
function _countTakenDoses(doses: Array<{ id: string; takenBy: string | null }>): { total: number; taken: number } {
let total = 0;
let taken = 0;
for (const d of doses) {
const people = (d.takenBy || []).length > 0 ? d.takenBy : [null];
for (const person of people) {
total++;
if (takenDoses.has(getDoseId(d.id, person))) taken++;
}
total++;
if (takenDoses.has(d.id)) taken++;
}
return { total, taken };
}
@@ -274,32 +273,42 @@ export function SharedSchedule() {
usage: number;
timeStr: string;
isPast: boolean;
takenBy: string[];
takenBy: string | null; // Per-intake takenBy (single person or null)
dateStr: string;
}[] = [];
for (const med of data.medications) {
med.blisters.forEach((blister, blisterIdx) => {
const startDate = new Date(blister.start);
// Use intakes (with per-intake takenBy) if available, fallback to blisters (legacy)
const intakes =
med.intakes ||
med.blisters.map((b) => ({ ...b, takenBy: null as string | null, intakeRemindersEnabled: false }));
intakes.forEach((intake, intakeIdx) => {
// Filter: only include intakes for this person (null = everyone, or matches share's takenBy)
if (intake.takenBy !== null && intake.takenBy !== data.takenBy) return;
const startDate = new Date(intake.start);
if (Number.isNaN(startDate.getTime())) return;
// Use the same iteration method as buildSchedulePreview (setDate instead of adding ms)
// This ensures identical timestamps even across DST changes
for (let d = new Date(startDate); d <= end; d.setDate(d.getDate() + blister.every)) {
for (let d = new Date(startDate); d <= end; d.setDate(d.getDate() + intake.every)) {
const t = d.getTime();
const isPast = d < todayStart;
// Use date-only timestamp for stable ID (immune to time changes)
// This ensures changing intake times doesn't invalidate past dose tracking
// Must match buildSchedulePreview in schedule.ts exactly
const dateOnlyMs = new Date(d.getFullYear(), d.getMonth(), d.getDate()).getTime();
const doseId = `${med.id}-${blisterIdx}-${dateOnlyMs}`;
// Dose ID includes person suffix if there's a per-intake takenBy
const baseDoseId = `${med.id}-${intakeIdx}-${dateOnlyMs}`;
const doseId = intake.takenBy ? `${baseDoseId}-${intake.takenBy}` : baseDoseId;
doses.push({
id: doseId,
when: t,
medName: med.name,
usage: blister.usage,
usage: intake.usage,
isPast,
takenBy: med.takenBy || [],
takenBy: intake.takenBy, // Per-intake takenBy (string | null)
timeStr: d.toLocaleTimeString(getSystemLocale(i18n.language), { hour: "2-digit", minute: "2-digit" }),
dateStr: d.toLocaleDateString(getSystemLocale(i18n.language), {
weekday: "short",
@@ -436,16 +445,11 @@ export function SharedSchedule() {
const depletion: Record<string, number | null> = {};
// Calculate total pills taken per medication from takenDoses
// Each person's taken dose counts separately toward pills consumed
// With per-intake takenBy, each dose.id is unique and already has person suffix if needed
const takenByMed: Record<string, number> = {};
for (const dose of schedule.flatMap((d) => d.meds.flatMap((m) => m.doses))) {
// Check all person-specific dose IDs for this dose
const people = (dose.takenBy || []).length > 0 ? dose.takenBy : [null];
for (const person of people) {
const doseId = person ? `${dose.id}-${person}` : dose.id;
if (takenDoses.has(doseId)) {
takenByMed[dose.medName] = (takenByMed[dose.medName] || 0) + dose.usage;
}
if (takenDoses.has(dose.id)) {
takenByMed[dose.medName] = (takenByMed[dose.medName] || 0) + dose.usage;
}
}
@@ -453,9 +457,9 @@ export function SharedSchedule() {
const totalCount = getMedTotal(med);
const taken = takenByMed[med.name] || 0;
const currentCount = Math.max(0, totalCount - taken);
// Calculate daily usage from blisters, multiplied by number of people
const personCount = Math.max(1, med.takenBy?.length || 1);
const dailyUsage = med.blisters.reduce((sum, b) => sum + b.usage / b.every, 0) * personCount;
// Calculate daily usage from intakes (or blisters for legacy)
const intakes = med.intakes || med.blisters;
const dailyUsage = intakes.reduce((sum, b) => sum + b.usage / b.every, 0);
const daysLeft = dailyUsage > 0 ? currentCount / dailyUsage : null;
coverage[med.name] = { daysLeft, medsLeft: currentCount, dailyUsage };
@@ -577,13 +581,8 @@ export function SharedSchedule() {
{pastDays.length > 0 &&
(() => {
// Count all past doses (for display)
const totalPastDoses = pastDays.flatMap((d) =>
d.meds.flatMap((m) =>
m.doses.flatMap((dose) => {
return (dose.takenBy || []).length > 0 ? dose.takenBy.map((p) => `${dose.id}-${p}`) : [dose.id];
})
)
);
// With per-intake takenBy, each dose.id is unique
const totalPastDoses = pastDays.flatMap((d) => d.meds.flatMap((m) => m.doses.map((dose) => dose.id)));
// Count missed doses (not taken AND not dismissed AND not from previous schedule)
// Check: per-dose dismissed flag, medication-level dismissedUntil, and updatedAt
const missedPastDoses = totalPastDoses.filter((id) => {
@@ -663,11 +662,7 @@ export function SharedSchedule() {
return false;
};
const allDoseIds = day.meds.flatMap((item) =>
item.doses.flatMap((d) => {
return (d.takenBy || []).length > 0 ? d.takenBy.map((p) => `${d.id}-${p}`) : [d.id];
})
);
const allDoseIds = day.meds.flatMap((item) => item.doses.map((d) => d.id));
const allDayDone = allDoseIds.length > 0 && allDoseIds.every(isDoseIdDone);
const doneCount = allDoseIds.filter(isDoseIdDone).length;
const isManuallyExpanded = manuallyExpandedDays.has(day.dateStr);
@@ -729,9 +724,7 @@ export function SharedSchedule() {
}
}
const itemDoseIds = item.doses.flatMap((d) =>
(d.takenBy || []).length > 0 ? d.takenBy.map((p) => `${d.id}-${p}`) : [d.id]
);
const itemDoseIds = item.doses.map((d) => d.id);
// A dose is "done" if taken OR dismissed
const allDone = itemDoseIds.every(isDoseIdDone);
@@ -760,70 +753,53 @@ export function SharedSchedule() {
</div>
<div className="doses-col">
{item.doses.map((dose) => {
const people = (dose.takenBy || []).length > 0 ? dose.takenBy : [null];
// Check: medication-level dismissedUntil, per-dose dismissed flag, and previous schedule
const isMedLevelDismissed = isDoseDismissed(dose.when, dose.medName);
const isFromPreviousSchedule = isDoseFromPreviousSchedule(dose.id, dose.medName);
const allDone = people.every((person) => {
const doseId = getDoseId(dose.id, person);
return (
takenDoses.has(doseId) ||
dismissedDoses.has(doseId) ||
isMedLevelDismissed ||
isFromPreviousSchedule
);
});
const isTaken = takenDoses.has(dose.id);
const isPerDoseDismissed = dismissedDoses.has(dose.id);
const isDone =
isTaken || isPerDoseDismissed || isMedLevelDismissed || isFromPreviousSchedule;
return (
<div key={dose.id} className={`dose-item past ${allDone ? "all-taken" : ""}`}>
<div key={dose.id} className={`dose-item past ${isDone ? "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)`}
{med?.pillWeightMg &&
` (${dose.usage * med.pillWeightMg} ${med.doseUnit ?? "mg"})`}
</span>
<div className="dose-checks">
{people.map((person) => {
const doseId = getDoseId(dose.id, person);
const isTaken = takenDoses.has(doseId);
const isPerDoseDismissed = dismissedDoses.has(doseId);
const isDone =
isTaken ||
isPerDoseDismissed ||
isMedLevelDismissed ||
isFromPreviousSchedule;
return (
<div key={doseId} className={`dose-person ${isDone ? "taken" : ""}`}>
{person && <span className="person-name">{person}</span>}
{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"
onClick={() => markDoseTaken(doseId)}
disabled={isEmpty}
title={t("dose.markAsTaken")}
>
</button>
)}
</div>
);
})}
<div className={`dose-person ${isDone ? "taken" : ""}`}>
{dose.takenBy && <span className="person-name">{dose.takenBy}</span>}
{isDone ? (
isTaken ? (
<button
className="dose-btn undo"
onClick={() => undoDoseTaken(dose.id)}
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"
onClick={() => markDoseTaken(dose.id)}
disabled={isEmpty}
title={t("dose.markAsTaken")}
>
</button>
)}
</div>
</div>
</div>
);
@@ -839,11 +815,7 @@ export function SharedSchedule() {
{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 allDoseIds = day.meds.flatMap((item) => item.doses.map((d) => 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);
@@ -898,9 +870,7 @@ export function SharedSchedule() {
}
}
const itemDoseIds = item.doses.flatMap((d) =>
(d.takenBy || []).length > 0 ? d.takenBy.map((p) => `${d.id}-${p}`) : [d.id]
);
const itemDoseIds = item.doses.map((d) => d.id);
const allTaken = itemDoseIds.every((id) => takenDoses.has(id));
return (
<div
@@ -927,47 +897,40 @@ export function SharedSchedule() {
</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)));
const isTaken = takenDoses.has(dose.id);
const isOverdue = dose.when < Date.now() && !isTaken;
return (
<div key={dose.id} className={`dose-item ${allTaken ? "all-taken" : ""}`}>
<div key={dose.id} className={`dose-item ${isTaken ? "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)`}
{med?.pillWeightMg &&
` (${dose.usage * med.pillWeightMg} ${med.doseUnit ?? "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" : ""}`}
<div
className={`dose-person ${isTaken ? "taken" : ""} ${isOverdue ? "overdue" : ""}`}
>
{dose.takenBy && <span className="person-name">{dose.takenBy}</span>}
{isTaken ? (
<button
className="dose-btn undo"
onClick={() => undoDoseTaken(dose.id)}
title={t("common.undo")}
>
{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>
);
})}
</button>
) : (
<button
className="dose-btn take"
onClick={() => markDoseTaken(dose.id)}
title={t("dose.markAsTaken")}
disabled={isEmpty}
>
</button>
)}
</div>
</div>
</div>
);
@@ -1000,11 +963,7 @@ export function SharedSchedule() {
{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 allDoseIds = day.meds.flatMap((item) => item.doses.map((d) => d.id));
const allDayTaken = allDoseIds.length > 0 && allDoseIds.every((id) => takenDoses.has(id));
const takenCount = allDoseIds.filter((id) => takenDoses.has(id)).length;
@@ -1062,9 +1021,7 @@ export function SharedSchedule() {
}
}
const itemDoseIds = item.doses.flatMap((d) =>
(d.takenBy || []).length > 0 ? d.takenBy.map((p) => `${d.id}-${p}`) : [d.id]
);
const itemDoseIds = item.doses.map((d) => d.id);
const allTaken = itemDoseIds.every((id) => takenDoses.has(id));
return (
<div
@@ -1091,56 +1048,49 @@ export function SharedSchedule() {
</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)));
const isTaken = takenDoses.has(dose.id);
// 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();
const isOverdue = dose.when < Date.now() && !isTaken && !isFutureDose;
return (
<div
key={dose.id}
className={`dose-item ${isFutureDose ? "future" : ""} ${allTaken ? "all-taken" : ""}`}
className={`dose-item ${isFutureDose ? "future" : ""} ${isTaken ? "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)`}
{med?.pillWeightMg &&
` (${dose.usage * med.pillWeightMg} ${med.doseUnit ?? "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" : ""}`}
<div
className={`dose-person ${isTaken ? "taken" : ""} ${isOverdue ? "overdue" : ""}`}
>
{dose.takenBy && <span className="person-name">{dose.takenBy}</span>}
{isTaken ? (
<button
className="dose-btn undo"
onClick={() => undoDoseTaken(dose.id)}
title={t("common.undo")}
>
{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>
);
})}
</button>
) : (
<button
className="dose-btn take"
onClick={() => markDoseTaken(dose.id)}
title={t("dose.markAsTaken")}
disabled={isFutureDose || isEmpty}
>
</button>
)}
</div>
</div>
</div>
);
+1
View File
@@ -13,6 +13,7 @@ export type { MedicationAvatarProps } from "./MedicationAvatar";
export { MedicationAvatar } from "./MedicationAvatar";
export type { MobileEditModalProps } from "./MobileEditModal";
export { MobileEditModal } from "./MobileEditModal";
export { PasswordInput } from "./PasswordInput";
export { default as ProfileModal } from "./ProfileModal";
export type { ShareDialogProps } from "./ShareDialog";
export { ShareDialog } from "./ShareDialog";
+22 -1
View File
@@ -3,7 +3,7 @@ import { createContext, useCallback, useContext, useEffect, useMemo, useState }
import { useTranslation } from "react-i18next";
import { useAuth } from "../components/Auth";
import { useCollapsedDays, useDoses, useMedications, useRefill, useSettings, useShare } from "../hooks";
import type { Coverage, Medication, ScheduleEvent } from "../types";
import type { Coverage, Medication, ScheduleEvent, StockThresholds } from "../types";
import { getSystemLocale } from "../utils/formatters";
import { buildSchedulePreview, calculateCoverage } from "../utils/schedule";
@@ -134,6 +134,7 @@ export interface AppContextValue {
coverage: { all: Coverage[]; low: Coverage[] };
coverageByMed: Record<string, Coverage>;
depletionByMed: Record<string, number | null>;
stockThresholds: StockThresholds;
existingPeople: string[];
groupedSchedule: GroupedDay[];
pastDays: GroupedDay[];
@@ -296,6 +297,24 @@ export function AppProvider({ children }: { children: React.ReactNode }) {
const coverageByMed = useMemo(() => Object.fromEntries(coverage.all.map((c) => [c.name, c])), [coverage.all]);
// Centralized stock thresholds for consistent status display across all components
const stockThresholds: StockThresholds = useMemo(
() => ({
lowStockDays: settingsHook.settings.lowStockDays,
normalStockDays: settingsHook.settings.normalStockDays,
highStockDays: settingsHook.settings.highStockDays,
criticalStockDays: settingsHook.settings.reminderDaysBefore, // Critical uses the reminder threshold
expiryWarningDays: settingsHook.settings.expiryWarningDays,
}),
[
settingsHook.settings.lowStockDays,
settingsHook.settings.normalStockDays,
settingsHook.settings.highStockDays,
settingsHook.settings.reminderDaysBefore,
settingsHook.settings.expiryWarningDays,
]
);
const existingPeople = useMemo(() => {
const allPeople = medications.meds.flatMap((m) => m.takenBy || []);
return [...new Set(allPeople)].filter(Boolean).sort();
@@ -798,6 +817,7 @@ export function AppProvider({ children }: { children: React.ReactNode }) {
coverage,
coverageByMed,
depletionByMed,
stockThresholds,
existingPeople,
groupedSchedule,
pastDays,
@@ -861,6 +881,7 @@ export function AppProvider({ children }: { children: React.ReactNode }) {
coverage,
coverageByMed,
depletionByMed,
stockThresholds,
existingPeople,
groupedSchedule,
pastDays,
+69 -1
View File
@@ -1,6 +1,6 @@
import { useCallback, useEffect, useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
import type { FieldErrors, FormBlister, FormState, Medication } from "../types";
import type { FieldErrors, FormBlister, FormIntake, FormState, Medication } from "../types";
import { FIELD_LIMITS } from "../types";
import { toDateValue, toTimeValue } from "../utils/formatters";
@@ -14,19 +14,38 @@ export const defaultBlister = (): FormBlister => {
};
};
/**
* Create a new intake with optional per-intake takenBy
*/
export const defaultIntake = (takenBy: string = ""): FormIntake => {
const now = new Date();
return {
usage: "1",
every: "1",
startDate: toDateValue(now),
startTime: toTimeValue(now),
takenBy, // Per-intake user assignment (empty string = null/everyone)
intakeRemindersEnabled: false,
};
};
export const defaultForm = (): FormState => ({
name: "",
genericName: "",
takenBy: [],
packageType: "blister",
packCount: "1",
blistersPerPack: "1",
pillsPerBlister: "1",
totalPills: "",
looseTablets: "0",
pillWeightMg: "",
doseUnit: "mg",
expiryDate: "",
notes: "",
intakeRemindersEnabled: false,
blisters: [defaultBlister()],
intakes: [defaultIntake()],
});
export interface UseMedicationFormReturn {
@@ -53,6 +72,10 @@ export interface UseMedicationFormReturn {
setBlisterValue: (idx: number, field: keyof FormBlister, value: string) => void;
addBlister: () => void;
removeBlister: (idx: number) => void;
// Intake management with per-intake takenBy
setIntakeValue: (idx: number, field: keyof FormIntake, value: string | boolean) => void;
addIntake: (takenBy?: string) => void;
removeIntake: (idx: number) => void;
startEdit: (med: Medication, openEditModal: () => void) => void;
resetForm: () => void;
handleValueChange: <K extends keyof FormState>(key: K, value: string) => void;
@@ -134,19 +157,60 @@ export function useMedicationForm(): UseMedicationFormReturn {
setForm((prev) => ({ ...prev, blisters: prev.blisters.filter((_, i) => i !== idx) }));
}, []);
// Intake management with per-intake takenBy
const setIntakeValue = useCallback((idx: number, field: keyof FormIntake, value: string | boolean) => {
setForm((prev) => {
const next = [...prev.intakes];
next[idx] = { ...next[idx], [field]: value };
return { ...prev, intakes: next };
});
}, []);
const addIntake = useCallback((takenBy: string = "") => {
setForm((prev) => ({ ...prev, intakes: [...prev.intakes, defaultIntake(takenBy)] }));
}, []);
const removeIntake = useCallback((idx: number) => {
setForm((prev) => ({ ...prev, intakes: prev.intakes.filter((_, i) => i !== idx) }));
}, []);
const startEdit = useCallback((med: Medication, openEditModal: () => void) => {
setEditingId(med.id);
setTakenByInput(""); // Clear tag input when starting edit
setFormSaved(true); // Existing medication is already saved
// Parse intakes - prefer new format, fallback to legacy blisters
const intakesFromApi =
med.intakes && med.intakes.length > 0
? med.intakes.map((i) => ({
usage: String(i.usage),
every: String(i.every),
startDate: toDateValue(i.start),
startTime: toTimeValue(i.start),
takenBy: i.takenBy ?? "", // Convert null to empty string for form
intakeRemindersEnabled: i.intakeRemindersEnabled,
}))
: med.blisters.map((s) => ({
usage: String(s.usage),
every: String(s.every),
startDate: toDateValue(s.start),
startTime: toTimeValue(s.start),
takenBy: "", // Legacy blisters have no per-intake takenBy
intakeRemindersEnabled: med.intakeRemindersEnabled ?? false,
}));
const editForm: FormState = {
name: med.name,
genericName: med.genericName ?? "",
takenBy: med.takenBy || [], // Already an array from API
packageType: med.packageType ?? "blister",
packCount: String(med.packCount),
blistersPerPack: String(med.blistersPerPack),
pillsPerBlister: String(med.pillsPerBlister),
totalPills: med.totalPills ? String(med.totalPills) : "",
looseTablets: String(med.looseTablets),
pillWeightMg: med.pillWeightMg ? String(med.pillWeightMg) : "",
doseUnit: med.doseUnit ?? "mg",
expiryDate: med.expiryDate ? med.expiryDate.slice(0, 10) : "",
notes: med.notes ?? "",
intakeRemindersEnabled: med.intakeRemindersEnabled ?? false,
@@ -156,6 +220,7 @@ export function useMedicationForm(): UseMedicationFormReturn {
startDate: toDateValue(s.start),
startTime: toTimeValue(s.start),
})),
intakes: intakesFromApi,
};
setForm(editForm);
setOriginalForm(editForm);
@@ -234,6 +299,9 @@ export function useMedicationForm(): UseMedicationFormReturn {
setBlisterValue,
addBlister,
removeBlister,
setIntakeValue,
addIntake,
removeIntake,
startEdit,
resetForm,
handleValueChange,
+21 -6
View File
@@ -88,7 +88,8 @@
"lowMeds": "{{count}} Medikament knapp",
"lowMeds_other": "{{count}} Medikamente knapp",
"daysLeft": "{{days}} Tag übrig",
"daysLeft_other": "{{days}} Tage übrig"
"daysLeft_other": "{{days}} Tage übrig",
"needsRefill": "Nachfüllen nötig"
}
},
"table": {
@@ -98,11 +99,16 @@
"currentPills": "Aktuelle Tabletten",
"fullBlisters": "Volle Blister",
"openBlister": "Offener Blister",
"stock": "Bestand",
"stockDetails": "Details",
"daysLeft": "Tage übrig",
"status": "Bestand",
"status": "Status",
"runsOut": "Aufgebraucht",
"autoRemind": "Auto-Erinnerung",
"expiry": "Ablaufdatum"
"expiry": "Ablaufdatum",
"pillsCount": "{{count}} Tabletten",
"pillsCount_one": "{{count}} Tablette",
"pillsCount_other": "{{count}} Tabletten"
},
"medications": {
"list": {
@@ -116,7 +122,8 @@
"blisters": "Blister pro Packung",
"pillsPerBlister": "Tabletten pro Blister",
"loose": "Lose",
"total": "Gesamt"
"total": "Gesamt",
"stock": "Bestand"
}
},
"form": {
@@ -126,11 +133,16 @@
"commercialName": "Handelsname",
"genericName": "Wirkstoff",
"takenBy": "Eingenommen von",
"packageType": "Verpackungsart",
"packageTypeBlister": "Blisterpackung",
"packageTypeBottle": "Pillendose / Behälter",
"packs": "Packungen",
"blistersPerPack": "Blister pro Packung",
"pillsPerBlister": "Tabletten pro Blister",
"totalCapacity": "Gesamtkapazität",
"currentPills": "Aktuelle Tabletten",
"loosePills": "Lose Tabletten",
"pillWeight": "Tablettengewicht (mg)",
"pillWeight": "Dosis pro Tablette",
"total": "Gesamt (Tabletten)",
"expiryDate": "Ablaufdatum",
"notes": "Notizen",
@@ -154,7 +166,9 @@
"every": "alle",
"from": "ab",
"startDate": "Datum",
"startTime": "Uhrzeit"
"startTime": "Uhrzeit",
"takenByIntake": "Eingenommen von",
"takenByEveryone": "Alle"
}
},
"planner": {
@@ -260,6 +274,7 @@
},
"status": {
"outOfStock": "Leer",
"criticalStock": "Kritisch",
"lowStock": "Niedrig",
"normal": "Normal",
"highStock": "Hoch",
+21 -6
View File
@@ -88,7 +88,8 @@
"lowMeds": "{{count}} medication low",
"lowMeds_other": "{{count}} medications low",
"daysLeft": "{{days}} day left",
"daysLeft_other": "{{days}} days left"
"daysLeft_other": "{{days}} days left",
"needsRefill": "Needs refill"
}
},
"table": {
@@ -98,11 +99,16 @@
"currentPills": "Current pills",
"fullBlisters": "Full blisters",
"openBlister": "Open blister",
"stock": "Stock",
"stockDetails": "Details",
"daysLeft": "Days left",
"status": "Stock",
"status": "Status",
"runsOut": "Runs out",
"autoRemind": "Auto-remind",
"expiry": "Expiry"
"expiry": "Expiry",
"pillsCount": "{{count}} pills",
"pillsCount_one": "{{count}} pill",
"pillsCount_other": "{{count}} pills"
},
"medications": {
"list": {
@@ -116,7 +122,8 @@
"blisters": "Blisters per pack",
"pillsPerBlister": "Pills per blister",
"loose": "Loose",
"total": "Total"
"total": "Total",
"stock": "Stock"
}
},
"form": {
@@ -126,11 +133,16 @@
"commercialName": "Commercial Name",
"genericName": "Generic Name",
"takenBy": "Taken by",
"packageType": "Package Type",
"packageTypeBlister": "Blister Pack",
"packageTypeBottle": "Pill Bottle / Container",
"packs": "Packs",
"blistersPerPack": "Blisters per pack",
"pillsPerBlister": "Pills per blister",
"totalCapacity": "Total Capacity",
"currentPills": "Current Pills",
"loosePills": "Loose pills",
"pillWeight": "Pill weight (mg)",
"pillWeight": "Dose per pill",
"total": "Total (pills)",
"expiryDate": "Expiry Date",
"notes": "Notes",
@@ -154,7 +166,9 @@
"every": "every",
"from": "from",
"startDate": "Date",
"startTime": "Time"
"startTime": "Time",
"takenByIntake": "Taken by",
"takenByEveryone": "Everyone"
}
},
"planner": {
@@ -260,6 +274,7 @@
},
"status": {
"outOfStock": "Empty",
"criticalStock": "Critical",
"lowStock": "Low",
"normal": "Normal",
"highStock": "High",
+133 -259
View File
@@ -1,28 +1,16 @@
import { useState } from "react";
import { useTranslation } from "react-i18next";
import { ConfirmModal, MedicationAvatar } from "../components";
import { useAuth } from "../components/Auth";
import { useAppContext } from "../context";
import type { Coverage } from "../types";
import { formatNumber, getExpiryClass, getSystemLocale } from "../utils/formatters";
import { getStockStatus } from "../utils/schedule";
// Helper for user-specific localStorage keys
function userStorageKey(userId: number | undefined, key: string): string {
return userId ? `user_${userId}_${key}` : key;
}
// Helper function to get stock status
function getStockStatus(
daysLeft: number | null,
medsLeft: number,
settings: { lowStockDays: number; normalStockDays: number; highStockDays: number }
) {
if (medsLeft <= 0 || daysLeft === null || daysLeft <= 0) return { className: "danger", label: "status.outOfStock" };
if (daysLeft <= settings.lowStockDays) return { className: "danger", label: "status.lowStock" };
if (daysLeft >= settings.highStockDays) return { className: "success", label: "status.highStock" };
return { className: "success", label: "status.normal" };
}
// Helper function to calculate blister stock
function getBlisterStock(totalPills: number, pillsPerBlister: number, _looseTablets: number, _originalTotal: number) {
const fullBlisters = Math.floor(totalPills / pillsPerBlister);
@@ -57,19 +45,6 @@ function getMedTotal(med: {
return med.packCount * med.blistersPerPack * med.pillsPerBlister + med.looseTablets + (med.stockAdjustment ?? 0);
}
// Get next reminder date for a medication
function getNextReminderForMed(row: Coverage, reminderDaysBefore: number, locale: string): string {
if (!row.depletionDate) return "-";
const depletionDate = new Date(row.depletionDate);
const reminderDate = new Date(depletionDate);
reminderDate.setDate(reminderDate.getDate() - reminderDaysBefore);
const now = new Date();
if (reminderDate <= now) return "-";
return reminderDate.toLocaleDateString(locale, { day: "2-digit", month: "short" });
}
// Notification bell SVG icon (no emoji)
function NotificationBellIcon() {
return (
@@ -112,7 +87,7 @@ function getReminderStatusData(
const lowCount = allCoverage.filter((c) => {
if (c.medsLeft <= 0) return false;
if (c.daysLeft === null) return false;
return c.daysLeft < lowStockDays && c.daysLeft > 3;
return c.daysLeft < lowStockDays && c.daysLeft > reminderDaysBefore;
}).length;
// Determine status
@@ -134,13 +109,16 @@ function getReminderStatusData(
};
}
// Collect all low stock medications (critical + low)
const lowStockMeds: { name: string; daysLeft: number; isCritical: boolean }[] = [];
// Collect all low stock medications (critical + low), deduplicated by name
const lowStockMap = new Map<string, { name: string; daysLeft: number; isCritical: boolean }>();
// Add critical meds (from lowCoverage - these are ≤3 days)
for (const c of lowCoverage) {
if (c.daysLeft !== null) {
lowStockMeds.push({ name: c.name, daysLeft: Math.round(c.daysLeft), isCritical: true });
const existing = lowStockMap.get(c.name);
if (!existing || c.daysLeft < existing.daysLeft) {
lowStockMap.set(c.name, { name: c.name, daysLeft: Math.round(c.daysLeft), isCritical: true });
}
}
}
@@ -148,13 +126,16 @@ function getReminderStatusData(
for (const c of allCoverage) {
if (c.medsLeft <= 0) continue;
if (c.daysLeft === null) continue;
if (c.daysLeft < lowStockDays && c.daysLeft > 3) {
lowStockMeds.push({ name: c.name, daysLeft: Math.round(c.daysLeft), isCritical: false });
if (c.daysLeft < lowStockDays && c.daysLeft > reminderDaysBefore) {
const existing = lowStockMap.get(c.name);
if (!existing || c.daysLeft < existing.daysLeft) {
lowStockMap.set(c.name, { name: c.name, daysLeft: Math.round(c.daysLeft), isCritical: false });
}
}
}
// Sort by days left (most urgent first)
lowStockMeds.sort((a, b) => a.daysLeft - b.daysLeft);
// Convert to array and sort by days left (most urgent first)
const lowStockMeds = Array.from(lowStockMap.values()).sort((a, b) => a.daysLeft - b.daysLeft);
// Parse last sent info
let lastSent: { date: string; medName: string | null; takenBy: string | null } | null = null;
@@ -213,39 +194,9 @@ export function DashboardPage() {
openUserFilter,
openShareDialog,
openScheduleLightbox,
stockThresholds,
} = useAppContext();
// Local state for reminder email
const [sendingReminderEmail, setSendingReminderEmail] = useState(false);
const [reminderEmailResult, setReminderEmailResult] = useState<{ success: boolean; message: string } | null>(null);
async function sendReminderEmail() {
if (!settings.notificationEmail || coverage.low.length === 0) return;
setSendingReminderEmail(true);
setReminderEmailResult(null);
try {
const res = await fetch("/api/reminder/send-email", {
method: "POST",
headers: { "Content-Type": "application/json" },
credentials: "include",
body: JSON.stringify({
email: settings.notificationEmail,
lowStock: coverage.low,
}),
});
const data = await res.json();
if (res.ok) {
setReminderEmailResult({ success: true, message: data.message || "Email sent!" });
} else {
setReminderEmailResult({ success: false, message: data.error || "Failed to send" });
}
} catch {
setReminderEmailResult({ success: false, message: "Network error" });
}
setSendingReminderEmail(false);
}
// Get structured reminder data
const reminderData = getReminderStatusData(
settings.reminderDaysBefore,
@@ -279,23 +230,46 @@ export function DashboardPage() {
<NotificationBellIcon />
</span>
<span className="reminder-status-title">{t("dashboard.reminders.active")}</span>
<span className={`reminder-status-badge ${reminderData.status.className}`}>
{reminderData.status.className === "success" && "✓ "}
{reminderData.status.text}
</span>
{reminderData.lowStockMeds.length === 0 && (
<span className={`reminder-status-badge ${reminderData.status.className}`}>
{reminderData.status.className === "success" && "✓ "}
{reminderData.status.text}
</span>
)}
</div>
{(reminderData.lowStockMeds.length > 0 || (intakeRemindersEnabled && reminderData.lastSent)) && (
<div className="reminder-status-details">
{stockRemindersEnabled && reminderData.lowStockMeds.length > 0 && (
<div className="reminder-low-stock-list">
{reminderData.lowStockMeds.map((med) => (
<div key={med.name} className={`reminder-low-stock-item ${med.isCritical ? "critical" : ""}`}>
<span className="reminder-med-name">{med.name}</span>
<span className="reminder-days-left">
{t("dashboard.reminders.daysLeft", { count: med.daysLeft, days: med.daysLeft })}
</span>
</div>
))}
<div className="reminder-status-row">
<span className="reminder-status-label">{t("dashboard.reminders.needsRefill")}:</span>
<span className="reminder-status-value">
{reminderData.lowStockMeds.map((med, idx) => {
const medication = meds.find((m) => m.name === med.name);
const cov = coverage.all.find((c) => c.name === med.name);
const status = cov ? getStockStatus(cov.daysLeft, cov.medsLeft, stockThresholds) : null;
const textClass =
status?.className === "danger"
? "danger-text"
: status?.className === "warning"
? "warning-text"
: "";
return (
<span key={med.name}>
{idx > 0 && ", "}
<span
className={`med-link clickable ${textClass}`}
onClick={() => medication && openMedDetail(medication)}
>
{med.name}
</span>
<span className={`reminder-days-left ${textClass}`}>
{" "}
{t("dashboard.reminders.daysLeft", { count: med.daysLeft, days: med.daysLeft })}
</span>
</span>
);
})}
</span>
</div>
)}
{intakeRemindersEnabled && reminderData.lastSent && (
@@ -306,9 +280,9 @@ export function DashboardPage() {
<span className="reminder-med-name">{reminderData.lastSent.medName}</span>
)}
{reminderData.lastSent.takenBy && (
<span className="reminder-taken-by">({reminderData.lastSent.takenBy})</span>
<span className="reminder-taken-by"> ({reminderData.lastSent.takenBy})</span>
)}
<span className="reminder-date">{reminderData.lastSent.date}</span>
<span className="reminder-date"> {reminderData.lastSent.date}</span>
</span>
</div>
)}
@@ -328,149 +302,53 @@ export function DashboardPage() {
return <p className="muted">{t("dashboard.reorder.noMeds")}</p>;
}
// Count medications with "Low" stock status (based on lowStockDays setting)
const lowStockMeds = coverage.all.filter((c) => {
if (c.medsLeft <= 0) return true; // out of stock
if (c.daysLeft === null) return false; // no schedule
return c.daysLeft < settings.lowStockDays;
});
const lowStockCount = lowStockMeds.length;
const lowStockNames = lowStockMeds.map((c) => c.name).join(", ");
if (coverage.low.length === 0) {
// No critical meds (≤3 days)
if (lowStockCount === 0) {
// All good - everything is Normal or High
return <p className="success-text">{t("dashboard.reorder.allGood")}</p>;
} else {
// Some meds are Low but not critical - render with clickable med names
return (
<p className="warning-text">
{t("dashboard.reorder.lowWarningPrefix")}{" "}
{lowStockMeds.map((c, idx) => {
const med = meds.find((m) => m.name === c.name);
return (
<span key={c.name}>
{idx > 0 && ", "}
<span className="med-link clickable" onClick={() => med && openMedDetail(med)}>
{c.name}
</span>
</span>
);
})}{" "}
{t("dashboard.reorder.lowWarningSuffix", { count: lowStockCount })}
</p>
);
// Count medications with low stock (based on lowStockDays setting), deduplicated by name
const lowStockMap = new Map<string, Coverage>();
for (const c of coverage.all) {
if (c.daysLeft === null && c.medsLeft > 0) continue; // no schedule, has stock
if (c.medsLeft <= 0 || c.daysLeft === null || c.daysLeft < settings.lowStockDays) {
const existing = lowStockMap.get(c.name);
if (!existing || (c.daysLeft ?? 0) < (existing.daysLeft ?? 0)) {
lowStockMap.set(c.name, c);
}
}
}
const lowStockMeds = Array.from(lowStockMap.values());
const lowStockCount = lowStockMeds.length;
if (lowStockCount === 0) {
// All good - everything is Normal or High
return <p className="success-text">{t("dashboard.reorder.allGood")}</p>;
}
// Some meds are low - show simple text with clickable names and days left
return (
<>
<div className="table table-7">
<div className="table-head">
<span>{t("table.name")}</span>
<span>{t("table.fullBlisters")}</span>
<span>{t("table.openBlister")}</span>
<span>{t("table.daysLeft")}</span>
<span>{t("table.status")}</span>
<span>{t("table.runsOut")}</span>
<span>{t("table.autoRemind")}</span>
</div>
{coverage.low.map((row) => {
const status = getStockStatus(row.daysLeft, row.medsLeft, settings);
const med = meds.find((m) => m.name === row.name);
const textClass =
status.className === "danger"
? "danger-text"
: status.className === "warning"
? "warning-text"
: "success-text";
const stock = getBlisterStock(
Math.round(row.medsLeft),
med?.pillsPerBlister ?? 1,
med?.looseTablets ?? 0,
med ? getMedTotal(med) : Math.round(row.medsLeft)
);
return (
<div key={row.name} className="table-row clickable" onClick={() => med && openMedDetail(med)}>
<span data-label={t("table.name")} className="cell-with-avatar">
<MedicationAvatar name={row.name} imageUrl={med?.imageUrl} />
<span className="med-name-text">{row.name}</span>
{med?.takenBy &&
med.takenBy.length > 0 &&
med.takenBy.map((person) => (
<span
key={person}
className="taken-by-badge clickable"
onClick={(e) => {
e.stopPropagation();
openUserFilter(person);
}}
>
{person}
</span>
))}
{(med?.intakeRemindersEnabled || med?.notes) && (
<span className="med-icons">
{med?.intakeRemindersEnabled && (
<span
className="reminder-icon info-tooltip"
data-tooltip={t("tooltips.intakeReminders")}
>
🔔
</span>
)}
{med?.notes && (
<span className="notes-icon info-tooltip" data-tooltip={t("tooltips.hasNotes")}>
📝
</span>
)}
</span>
)}
</span>
<span data-label={t("table.fullBlisters")} className={textClass}>
{formatFullBlisters(stock.fullBlisters, t)}
</span>
<span data-label={t("table.openBlister")} className={textClass}>
{formatOpenBlisterAndLoose(
stock.openBlisterPills,
stock.loosePills,
med?.pillsPerBlister ?? 1,
t
)}
</span>
<span data-label={t("table.days")} className={textClass}>
{formatNumber(row.daysLeft)}
</span>
<span data-label={t("table.status")} className={`status-chip ${status.className}`}>
{t(status.label)}
</span>
<span data-label={t("table.runsOut")}>{row.depletionDate ?? "-"}</span>
<span data-label={t("table.autoRemind")} className="next-reminder-date">
{getNextReminderForMed(row, settings.reminderDaysBefore, getSystemLocale(i18n.language))}
</span>
</div>
);
})}
</div>
{(settings.emailEnabled || settings.shoutrrrEnabled) && (
<div className="email-send-action">
<button
type="button"
className="ghost"
onClick={sendReminderEmail}
disabled={sendingReminderEmail}
>
{sendingReminderEmail ? t("common.sending") : t("dashboard.reorder.sendReminder")}
</button>
{reminderEmailResult && (
<span className={reminderEmailResult.success ? "success-text" : "danger-text"}>
{reminderEmailResult.message}
<p>
{t("dashboard.reorder.lowWarningPrefix")}{" "}
{lowStockMeds.map((c, idx) => {
const med = meds.find((m) => m.name === c.name);
const status = getStockStatus(c.daysLeft, c.medsLeft, stockThresholds);
const textClass =
status.className === "danger"
? "danger-text"
: status.className === "warning"
? "warning-text"
: "";
return (
<span key={c.name}>
{idx > 0 && ", "}
<span className={`med-link clickable ${textClass}`} onClick={() => med && openMedDetail(med)}>
{c.name}
</span>
)}
</div>
)}
</>
<span className={`reminder-days-left ${textClass}`}>
{" "}
({t("dashboard.reminders.daysLeft", { count: c.daysLeft ?? 0, days: c.daysLeft ?? 0 })})
</span>
</span>
);
})}{" "}
{t("dashboard.reorder.lowWarningSuffix", { count: lowStockCount })}
</p>
);
})()}
</article>
@@ -485,15 +363,15 @@ export function DashboardPage() {
<div className="table table-7">
<div className="table-head">
<span>{t("table.name")}</span>
<span>{t("table.fullBlisters")}</span>
<span>{t("table.openBlister")}</span>
<span>{t("table.stock")}</span>
<span>{t("table.stockDetails")}</span>
<span>{t("table.daysLeft")}</span>
<span>{t("table.runsOut")}</span>
<span>{t("table.expiry")}</span>
<span>{t("table.status")}</span>
</div>
{coverage.all.map((row) => {
const status = getStockStatus(row.daysLeft, row.medsLeft, settings);
const status = getStockStatus(row.daysLeft, row.medsLeft, stockThresholds);
const med = meds.find((m) => m.name === row.name);
const expiryClass = getExpiryClass(med?.expiryDate, settings.expiryWarningDays);
const textClass =
@@ -544,11 +422,20 @@ export function DashboardPage() {
</span>
)}
</span>
<span data-label={t("table.fullBlisters")} className={textClass}>
{formatFullBlisters(stock.fullBlisters, t)}
<span data-label={t("table.stock")} className={textClass}>
{med?.packageType === "bottle"
? t("table.pillsCount", { count: Math.round(row.medsLeft) })
: formatFullBlisters(stock.fullBlisters, t)}
</span>
<span data-label={t("table.openBlister")} className={textClass}>
{formatOpenBlisterAndLoose(stock.openBlisterPills, stock.loosePills, med?.pillsPerBlister ?? 1, t)}
<span data-label={t("table.stockDetails")} className={textClass}>
{med?.packageType === "bottle"
? "-"
: formatOpenBlisterAndLoose(
stock.openBlisterPills,
stock.loosePills,
med?.pillsPerBlister ?? 1,
t
)}
</span>
<span data-label={t("table.daysLeft")} className={textClass}>
{formatNumber(row.daysLeft)}
@@ -605,9 +492,7 @@ export function DashboardPage() {
const missedCount = missedPastDoseIds.length;
const totalPastDoses = pastDays.flatMap((d) =>
d.meds.flatMap((m) =>
m.doses.flatMap((dose) =>
(dose.takenBy || []).length > 0 ? dose.takenBy.map((p) => `${dose.id}-${p}`) : [dose.id]
)
m.doses.flatMap((dose) => (dose.takenBy ? [`${dose.id}-${dose.takenBy}`] : [dose.id]))
)
);
return (
@@ -656,9 +541,7 @@ export function DashboardPage() {
{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]
)
item.doses.flatMap((d) => (d.takenBy ? [`${d.id}-${d.takenBy}`] : [d.id]))
);
const allDayTaken =
allDoseIds.length > 0 && allDoseIds.every((id) => takenDoses.has(id) || dismissedDoses.has(id));
@@ -703,9 +586,7 @@ export function DashboardPage() {
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 itemDoseIds = item.doses.flatMap((d) => (d.takenBy ? [`${d.id}-${d.takenBy}`] : [d.id]));
const allTaken = itemDoseIds.every((id) => takenDoses.has(id));
return (
<div key={`${day.dateStr}-${item.medName}`} className={`time-row ${allTaken ? "taken" : ""}`}>
@@ -736,13 +617,14 @@ export function DashboardPage() {
<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 people = dose.takenBy ? [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)`}
{med?.pillWeightMg &&
` (${dose.usage * med.pillWeightMg} ${med.doseUnit ?? "mg"})`}
</span>
<div className="dose-checks">
{people.map((person) => {
@@ -795,9 +677,7 @@ export function DashboardPage() {
(() => {
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]
)
item.doses.flatMap((d) => (d.takenBy ? [`${d.id}-${d.takenBy}`] : [d.id]))
);
const allDayTaken = allDoseIds.length > 0 && allDoseIds.every((id) => takenDoses.has(id));
const takenCount = allDoseIds.filter((id) => takenDoses.has(id)).length;
@@ -808,7 +688,7 @@ export function DashboardPage() {
const willBeOutOfStock = typeof depletionTime === "number" && item.lastWhen > depletionTime;
if (willBeOutOfStock) return "danger";
if (!medCoverage) return "success";
const status = getStockStatus(medCoverage.daysLeft, medCoverage.medsLeft, settings);
const status = getStockStatus(medCoverage.daysLeft, medCoverage.medsLeft, stockThresholds);
return status.className;
});
const worstStatus = dayStockStatuses.includes("danger")
@@ -855,11 +735,9 @@ export function DashboardPage() {
const status = willBeOutOfStock
? { className: "danger", label: "status.outOfStock" }
: medCoverage
? getStockStatus(medCoverage.daysLeft, medCoverage.medsLeft, settings)
? getStockStatus(medCoverage.daysLeft, medCoverage.medsLeft, stockThresholds)
: null;
const itemDoseIds = item.doses.flatMap((d) =>
(d.takenBy || []).length > 0 ? d.takenBy.map((p) => `${d.id}-${p}`) : [d.id]
);
const itemDoseIds = item.doses.flatMap((d) => (d.takenBy ? [`${d.id}-${d.takenBy}`] : [d.id]));
const allTaken = itemDoseIds.every((id) => takenDoses.has(id));
return (
<div key={`${day.dateStr}-${item.medName}`} className={`time-row ${allTaken ? "taken" : ""}`}>
@@ -891,7 +769,7 @@ export function DashboardPage() {
<div className="doses-col">
{item.doses.map((dose) => {
const isOverdue = dose.when < Date.now();
const people = (dose.takenBy || []).length > 0 ? dose.takenBy : [null];
const people = dose.takenBy ? [dose.takenBy] : [null];
const allTaken = people.every((person) => takenDoses.has(getDoseId(dose.id, person)));
return (
<div
@@ -901,7 +779,8 @@ export function DashboardPage() {
<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)`}
{med?.pillWeightMg &&
` (${dose.usage * med.pillWeightMg} ${med.doseUnit ?? "mg"})`}
</span>
<div className="dose-checks">
{people.map((person) => {
@@ -954,9 +833,7 @@ export function DashboardPage() {
(() => {
const totalFutureDoses = futureDays.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) => (dose.takenBy ? [`${dose.id}-${dose.takenBy}`] : [dose.id]))
)
);
const takenFutureDoses = totalFutureDoses.filter((id) => takenDoses.has(id)).length;
@@ -988,9 +865,7 @@ export function DashboardPage() {
{showFutureDays &&
futureDays.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]
)
item.doses.flatMap((d) => (d.takenBy ? [`${d.id}-${d.takenBy}`] : [d.id]))
);
const allDayTaken = allDoseIds.length > 0 && allDoseIds.every((id) => takenDoses.has(id));
const takenCount = allDoseIds.filter((id) => takenDoses.has(id)).length;
@@ -1001,7 +876,7 @@ export function DashboardPage() {
const willBeOutOfStock = typeof depletionTime === "number" && item.lastWhen > depletionTime;
if (willBeOutOfStock) return "danger";
if (!medCoverage) return "success";
const status = getStockStatus(medCoverage.daysLeft, medCoverage.medsLeft, settings);
const status = getStockStatus(medCoverage.daysLeft, medCoverage.medsLeft, stockThresholds);
return status.className;
});
const worstStatus = dayStockStatuses.includes("danger")
@@ -1047,11 +922,9 @@ export function DashboardPage() {
const status = willBeOutOfStock
? { className: "danger", label: "status.outOfStock" }
: medCoverage
? getStockStatus(medCoverage.daysLeft, medCoverage.medsLeft, settings)
? getStockStatus(medCoverage.daysLeft, medCoverage.medsLeft, stockThresholds)
: null;
const itemDoseIds = item.doses.flatMap((d) =>
(d.takenBy || []).length > 0 ? d.takenBy.map((p) => `${d.id}-${p}`) : [d.id]
);
const itemDoseIds = item.doses.flatMap((d) => (d.takenBy ? [`${d.id}-${d.takenBy}`] : [d.id]));
const allTaken = itemDoseIds.every((id) => takenDoses.has(id));
return (
<div key={`${day.dateStr}-${item.medName}`} className={`time-row ${allTaken ? "taken" : ""}`}>
@@ -1082,14 +955,15 @@ export function DashboardPage() {
</div>
<div className="doses-col">
{item.doses.map((dose) => {
const people = (dose.takenBy || []).length > 0 ? dose.takenBy : [null];
const people = dose.takenBy ? [dose.takenBy] : [null];
const allTaken = people.every((person) => takenDoses.has(getDoseId(dose.id, person)));
return (
<div key={dose.id} className={`dose-item 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)`}
{med?.pillWeightMg &&
` (${dose.usage * med.pillWeightMg} ${med.doseUnit ?? "mg"})`}
</span>
<div className="dose-checks">
{people.map((person) => {
+160 -72
View File
@@ -3,8 +3,8 @@ import { useTranslation } from "react-i18next";
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 type { DoseUnit, Medication } from "../types";
import { DOSE_UNITS, FIELD_LIMITS, getPackageSize } from "../types";
import { combineDateAndTime, formatDateTime, formatNumber } from "../utils/formatters";
export function MedicationsPage() {
@@ -25,6 +25,7 @@ export function MedicationsPage() {
setRefillLoose,
refillSaving,
submitRefill,
coverageByMed,
} = useAppContext();
// Use the medication form hook
@@ -47,6 +48,9 @@ export function MedicationsPage() {
addBlister,
removeBlister,
setBlisterValue,
addIntake,
removeIntake,
setIntakeValue,
resetForm,
startEdit,
} = useMedicationForm();
@@ -87,12 +91,17 @@ export function MedicationsPage() {
// Calculate total tablets
const totalTablets = useMemo(() => {
if (form.packageType === "bottle") {
// For bottle type, looseTablets is the current stock
return Number(form.looseTablets) || 0;
}
// For blister type
const packCount = Number(form.packCount) || 0;
const blistersPerPack = Number(form.blistersPerPack) || 0;
const pillsPerBlister = Number(form.pillsPerBlister) || 1;
const looseTablets = Number(form.looseTablets) || 0;
return packCount * blistersPerPack * pillsPerBlister + looseTablets;
}, [form.packCount, form.blistersPerPack, form.pillsPerBlister, form.looseTablets]);
}, [form.packageType, form.packCount, form.blistersPerPack, form.pillsPerBlister, form.looseTablets]);
// Open mobile edit modal
function openEditModal() {
@@ -158,26 +167,39 @@ export function MedicationsPage() {
if (saving) return;
setSaving(true);
// Prepare medication data
const blisters = form.blisters.map((b) => ({
usage: Number(b.usage) || 1,
every: Number(b.every) || 1,
start: combineDateAndTime(b.startDate, b.startTime),
// Prepare intakes data with per-intake takenBy
const intakes = form.intakes.map((intake) => ({
usage: Number(intake.usage) || 1,
every: Number(intake.every) || 1,
start: combineDateAndTime(intake.startDate, intake.startTime),
takenBy: intake.takenBy.trim() || null, // Empty string becomes null
intakeRemindersEnabled: intake.intakeRemindersEnabled,
}));
// Also prepare legacy blisters for backward compatibility
const blisters = intakes.map((i) => ({
usage: i.usage,
every: i.every,
start: i.start,
}));
const body = {
name: form.name.trim(),
genericName: form.genericName.trim() || null,
takenBy: form.takenBy.length > 0 ? form.takenBy : [],
packageType: form.packageType,
packCount: Number(form.packCount) || 0,
blistersPerPack: Number(form.blistersPerPack) || 1,
pillsPerBlister: Number(form.pillsPerBlister) || 1,
totalPills: Number(form.totalPills) || null,
looseTablets: Number(form.looseTablets) || 0,
pillWeightMg: Number(form.pillWeightMg) || null,
doseUnit: form.doseUnit,
expiryDate: form.expiryDate || null,
notes: form.notes.trim() || null,
intakeRemindersEnabled: form.intakeRemindersEnabled,
blisters,
blisters, // Legacy format for backward compatibility
intakes, // New format with per-intake takenBy
};
try {
@@ -331,7 +353,9 @@ export function MedicationsPage() {
</span>
</div>
<div className="med-total">
{t("medications.details.total")}: {getPackageSize(med)} {t("common.pills")}
{t("medications.details.stock")}:{" "}
{coverageByMed[med.name] ? Math.round(coverageByMed[med.name].medsLeft) : getPackageSize(med)} /{" "}
{getPackageSize(med)} {t("common.pills")}
</div>
</div>
<div className="med-actions">
@@ -431,50 +455,100 @@ export function MedicationsPage() {
{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.packageType")}
<select
className="package-type-select"
value={form.packageType}
onChange={(e) => handleValueChange("packageType", e.target.value)}
>
<option value="blister">{t("form.packageTypeBlister")}</option>
<option value="bottle">{t("form.packageTypeBottle")}</option>
</select>
</label>
{form.packageType === "blister" ? (
<>
<label>
{t("form.packs")}
<input
type="number"
min="0"
value={form.packCount}
onChange={(e) => handleValueChange("packCount", e.target.value)}
/>
</label>
<label>
{t("form.blistersPerPack")}
<input
type="number"
min="1"
value={form.blistersPerPack}
onChange={(e) => handleValueChange("blistersPerPack", e.target.value)}
/>
</label>
<label>
{t("form.pillsPerBlister")}
<input
type="number"
min="1"
value={form.pillsPerBlister}
onChange={(e) => handleValueChange("pillsPerBlister", e.target.value)}
/>
</label>
<label>
{t("form.loosePills")}
<input
type="number"
min="0"
value={form.looseTablets}
onChange={(e) => handleValueChange("looseTablets", e.target.value)}
/>
</label>
</>
) : (
<>
<label>
{t("form.totalCapacity")}
<input
type="number"
min="1"
value={form.totalPills}
onChange={(e) => handleValueChange("totalPills", e.target.value)}
/>
</label>
<label>
{t("form.currentPills")}
<input
type="number"
min="0"
value={form.looseTablets}
onChange={(e) => handleValueChange("looseTablets", e.target.value)}
/>
</label>
</>
)}
<label>
{t("form.blistersPerPack")}
<input
type="number"
min="1"
value={form.blistersPerPack}
onChange={(e) => handleValueChange("blistersPerPack", e.target.value)}
/>
</label>
<label>
{t("form.pillsPerBlister")}
<input
type="number"
min="1"
value={form.pillsPerBlister}
onChange={(e) => handleValueChange("pillsPerBlister", e.target.value)}
/>
</label>
<label>
{t("form.loosePills")}
<input
type="number"
min="0"
value={form.looseTablets}
onChange={(e) => handleValueChange("looseTablets", e.target.value)}
/>
</label>
<label>
{t("form.pillWeight")}
<input
type="number"
min="1"
value={form.pillWeightMg}
onChange={(e) => handleValueChange("pillWeightMg", e.target.value)}
placeholder={t("form.placeholders.weight")}
/>
{t("form.pillWeight")} ({form.doseUnit})
<div className="dose-input-group">
<input
type="number"
min="0"
step="0.1"
value={form.pillWeightMg}
onChange={(e) => handleValueChange("pillWeightMg", e.target.value)}
placeholder={t("form.placeholders.weight")}
/>
<select
value={form.doseUnit}
onChange={(e) => handleValueChange("doseUnit", e.target.value as DoseUnit)}
className="dose-unit-select"
>
{DOSE_UNITS.map((unit) => (
<option key={unit.value} value={unit.value}>
{unit.label}
</option>
))}
</select>
</div>
</label>
<label>
{t("form.total")}
@@ -558,20 +632,12 @@ export function MedicationsPage() {
<div className="card-head">
<h3>{t("form.blisters.title")}</h3>
<div className="blisters-actions">
<label className="inline-checkbox" title={t("form.blisters.remindTooltip")}>
<input
type="checkbox"
checked={form.intakeRemindersEnabled}
onChange={(e) => setForm((prev) => ({ ...prev, intakeRemindersEnabled: e.target.checked }))}
/>
<span>🔔 {t("form.blisters.remind")}</span>
</label>
<button type="button" className="primary" onClick={addBlister}>
<button type="button" className="primary" onClick={() => addIntake()}>
+ {t("form.blisters.addIntake")}
</button>
</div>
</div>
{form.blisters.map((s, idx) => (
{form.intakes.map((intake, idx) => (
<div key={idx} className="blister-row">
<div className="blister-inputs">
<label>
@@ -580,8 +646,8 @@ export function MedicationsPage() {
type="number"
min="0"
step="0.1"
value={s.usage}
onChange={(e) => setBlisterValue(idx, "usage", e.target.value)}
value={intake.usage}
onChange={(e) => setIntakeValue(idx, "usage", e.target.value)}
/>
</label>
<label>
@@ -589,29 +655,48 @@ export function MedicationsPage() {
<input
type="number"
min="1"
value={s.every}
onChange={(e) => setBlisterValue(idx, "every", e.target.value)}
value={intake.every}
onChange={(e) => setIntakeValue(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)}
value={intake.startDate}
onChange={(e) => setIntakeValue(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)}
value={intake.startTime}
onChange={(e) => setIntakeValue(idx, "startTime", e.target.value)}
/>
</label>
<label title={t("form.blisters.takenByTooltip")}>
{t("form.blisters.takenByIntake")}
<select value={intake.takenBy} onChange={(e) => setIntakeValue(idx, "takenBy", e.target.value)}>
<option value="">{t("form.blisters.takenByEveryone")}</option>
{existingPeople.map((person) => (
<option key={person} value={person}>
{person}
</option>
))}
</select>
</label>
<label className="inline-checkbox" title={t("form.blisters.remindTooltip")}>
<input
type="checkbox"
checked={intake.intakeRemindersEnabled}
onChange={(e) => setIntakeValue(idx, "intakeRemindersEnabled", e.target.checked)}
/>
<span>🔔</span>
</label>
</div>
{form.blisters.length > 1 && (
<button type="button" className="danger" onClick={() => removeBlister(idx)}>
{form.intakes.length > 1 && (
<button type="button" className="danger" onClick={() => removeIntake(idx)}>
{t("common.remove")}
</button>
)}
@@ -716,6 +801,9 @@ export function MedicationsPage() {
onSetBlisterValue={setBlisterValue}
onAddBlister={addBlister}
onRemoveBlister={removeBlister}
onSetIntakeValue={setIntakeValue}
onAddIntake={addIntake}
onRemoveIntake={removeIntake}
onHandleValueChange={handleValueChange}
refillPacks={refillPacks}
onRefillPacksChange={setRefillPacks}
+16 -8
View File
@@ -9,15 +9,23 @@ function userStorageKey(userId: number | undefined, key: string): string {
return userId ? `user_${userId}_${key}` : key;
}
// Helper function to get stock status
// Helper function to get stock status based on thresholds
function getStockStatus(
daysLeft: number | null,
medsLeft: number,
settings: { lowStockDays: number; normalStockDays: number; highStockDays: number }
settings: { lowStockDays: number; normalStockDays: number; highStockDays: number; reminderDaysBefore: 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" };
// Out of stock or completely depleted = danger (red)
if (medsLeft <= 0 || daysLeft === 0) return { className: "danger", label: "status.outOfStock" };
// No schedule, but has stock = normal
if (daysLeft === null) return { className: "success", label: "status.noSchedule" };
// Critical: at or below reminder threshold = danger (red)
if (daysLeft <= settings.reminderDaysBefore) return { className: "danger", label: "status.criticalStock" };
// Low: below low stock threshold = warning (yellow)
if (daysLeft < settings.lowStockDays) return { className: "warning", label: "status.lowStock" };
// High stock
if (daysLeft >= settings.highStockDays) return { className: "high", label: "status.highStock" };
// Normal stock
return { className: "success", label: "status.normal" };
}
@@ -25,7 +33,7 @@ function getStockStatus(
function getDayStockStatus(
dayMeds: Array<{ medName: string }>,
coverageByMed: Record<string, Coverage>,
settings: { lowStockDays: number; normalStockDays: number; highStockDays: number }
settings: { lowStockDays: number; normalStockDays: number; highStockDays: number; reminderDaysBefore: number }
): string {
let worstLevel = 3; // 3=success, 2=warning, 1=danger
for (const item of dayMeds) {
@@ -197,7 +205,7 @@ export function SchedulePage() {
<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)`}
{med?.pillWeightMg && ` (${dose.usage * med.pillWeightMg} ${med.doseUnit ?? "mg"})`}
</span>
<div className="dose-checks">
{people.map((person) => {
@@ -301,7 +309,7 @@ export function SchedulePage() {
<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)`}
{med?.pillWeightMg && ` (${dose.usage * med.pillWeightMg} ${med.doseUnit ?? "mg"})`}
</span>
<div className="dose-checks">
{people.map((person) => {
+97 -33
View File
@@ -281,34 +281,13 @@ body.modal-open {
color: var(--text-secondary);
}
.reminder-low-stock-list {
display: flex;
flex-direction: column;
gap: 0.375rem;
padding-left: 1.75rem;
}
.reminder-low-stock-item {
display: flex;
align-items: center;
gap: 0.75rem;
font-size: 0.8rem;
color: var(--text-secondary);
}
.reminder-low-stock-item .reminder-med-name {
font-weight: 500;
color: var(--text-primary);
}
.reminder-low-stock-item .reminder-days-left {
.reminder-days-left {
color: var(--warning);
font-size: 0.75rem;
font-size: 0.8rem;
}
.reminder-low-stock-item.critical .reminder-days-left {
.critical .reminder-days-left {
color: var(--danger);
font-weight: 500;
}
.med-link {
@@ -994,6 +973,27 @@ textarea.auto-resize {
gap: 0.75rem;
}
/* Package type selector - simple dropdown style */
.package-type-select {
width: 100%;
padding: 0.6rem 2rem 0.6rem 0.75rem;
font-size: 0.9rem;
background: var(--bg-secondary);
border: 1px solid var(--border-primary);
border-radius: 8px;
color: var(--text-primary);
cursor: pointer;
appearance: none;
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 12 12'%3E%3Cpath fill='%236b7280' d='M2 4l4 4 4-4'/%3E%3C/svg%3E");
background-repeat: no-repeat;
background-position: right 0.75rem center;
}
.package-type-select:focus {
outline: none;
border-color: var(--accent);
}
/* Form field validation */
.form-grid label.has-error input,
.form-grid label.has-error textarea {
@@ -1015,6 +1015,40 @@ textarea.auto-resize {
margin-top: 0.25rem;
}
/* Dose input with unit selector */
.dose-input-group {
display: flex;
gap: 0.5rem;
align-items: stretch;
}
.dose-input-group input {
flex: 1;
min-width: 0;
}
.dose-unit-select {
width: auto;
min-width: 80px;
padding: 0.5rem 0.75rem;
background: var(--bg-primary);
border: 1px solid var(--border-primary);
border-radius: var(--input-radius);
color: var(--text-primary);
font-size: 0.9rem;
cursor: pointer;
transition: border-color 150ms ease;
}
.dose-unit-select:focus {
outline: none;
border-color: var(--accent);
}
.dose-unit-select:hover {
border-color: var(--accent);
}
/* Tag input for multi-value fields (e.g., Taken By) */
.tag-input-container {
display: flex;
@@ -3922,6 +3956,41 @@ h3 .reminder-icon.info-tooltip {
cursor: pointer;
}
/* Password Input with Toggle */
.password-input-wrapper {
position: relative;
display: flex;
align-items: center;
}
.password-input-wrapper input {
width: 100%;
padding-right: 2.5rem;
}
.password-toggle-btn {
position: absolute;
right: 0.5rem;
background: none;
border: none;
padding: 0.25rem;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
color: var(--text-tertiary);
transition: color 0.15s ease;
}
.password-toggle-btn:hover {
color: var(--text-secondary);
}
.password-toggle-btn svg {
width: 1.25rem;
height: 1.25rem;
}
.auth-links {
display: flex;
flex-direction: column;
@@ -4336,8 +4405,7 @@ h3 .reminder-icon.info-tooltip {
text-transform: uppercase;
letter-spacing: 0.5px;
margin: 0 0 1rem 0;
padding-bottom: 0.5rem;
border-bottom: 1px solid var(--border-primary);
padding-bottom: 0;
}
.profile-form .form-group {
@@ -4376,9 +4444,8 @@ h3 .reminder-icon.info-tooltip {
display: flex;
gap: 0.75rem;
justify-content: flex-end;
padding-top: 0.5rem;
border-top: 1px solid var(--border-primary);
margin-top: 1rem;
padding-top: 0;
margin-top: 0.5rem;
}
.profile-actions .btn {
@@ -4418,13 +4485,10 @@ h3 .reminder-icon.info-tooltip {
/* Profile danger zone */
.profile-danger-zone {
margin: 0 1.5rem 1.5rem;
padding-top: 1.5rem;
border-top: 1px solid var(--border-primary);
padding-top: 1rem;
}
.profile-danger-zone .profile-section-title {
border-bottom: none;
padding-bottom: 0;
margin-bottom: 0.75rem;
}
@@ -7,6 +7,8 @@ const defaultSettings: StockThresholds = {
lowStockDays: 7,
normalStockDays: 30,
highStockDays: 90,
criticalStockDays: 3,
expiryWarningDays: 30,
};
const mockMedication: Medication = {
@@ -7,10 +7,12 @@ const defaultForm: FormState = {
name: "",
genericName: "",
takenBy: [],
packageType: "blister",
packCount: "1",
blistersPerPack: "1",
pillsPerBlister: "1",
looseTablets: "0",
totalPills: "",
pillWeightMg: "",
expiryDate: "",
notes: "",
@@ -23,6 +25,16 @@ const defaultForm: FormState = {
startTime: "09:00",
},
],
intakes: [
{
usage: "1",
every: "1",
startDate: "2024-01-01",
startTime: "09:00",
takenBy: "",
intakeRemindersEnabled: false,
},
],
};
const defaultProps = {
@@ -44,6 +56,9 @@ const defaultProps = {
onSetBlisterValue: vi.fn(),
onAddBlister: vi.fn(),
onRemoveBlister: vi.fn(),
onSetIntakeValue: vi.fn(),
onAddIntake: vi.fn(),
onRemoveIntake: vi.fn(),
onHandleValueChange: vi.fn(),
refillPacks: 0,
onRefillPacksChange: vi.fn(),
@@ -185,14 +200,14 @@ describe("MobileEditModal", () => {
expect(screen.getByText(/form\.blisters\.addIntake/i)).toBeInTheDocument();
});
it("calls onAddBlister when add intake clicked", () => {
const onAddBlister = vi.fn();
render(<MobileEditModal {...defaultProps} onAddBlister={onAddBlister} />);
it("calls onAddIntake when add intake clicked", () => {
const onAddIntake = vi.fn();
render(<MobileEditModal {...defaultProps} onAddIntake={onAddIntake} />);
const addBtn = screen.getByText(/form\.blisters\.addIntake/i);
fireEvent.click(addBtn);
expect(onAddBlister).toHaveBeenCalledTimes(1);
expect(onAddIntake).toHaveBeenCalledTimes(1);
});
it("renders modal content", () => {
@@ -261,6 +276,24 @@ describe("MobileEditModal blister management", () => {
{ usage: "1", every: "1", startDate: "2024-01-01", startTime: "09:00" },
{ usage: "2", every: "7", startDate: "2024-01-01", startTime: "10:00" },
],
intakes: [
{
usage: "1",
every: "1",
startDate: "2024-01-01",
startTime: "09:00",
takenBy: "",
intakeRemindersEnabled: false,
},
{
usage: "2",
every: "7",
startDate: "2024-01-01",
startTime: "10:00",
takenBy: "",
intakeRemindersEnabled: false,
},
],
};
render(<MobileEditModal {...defaultProps} form={form} />);
@@ -269,34 +302,52 @@ describe("MobileEditModal blister management", () => {
expect(blisterRows.length).toBe(2);
});
it("calls onRemoveBlister when remove button clicked", () => {
const onRemoveBlister = vi.fn();
it("calls onRemoveIntake when remove button clicked", () => {
const onRemoveIntake = vi.fn();
const form = {
...defaultForm,
blisters: [
{ usage: "1", every: "1", startDate: "2024-01-01", startTime: "09:00" },
{ usage: "2", every: "7", startDate: "2024-01-01", startTime: "10:00" },
],
intakes: [
{
usage: "1",
every: "1",
startDate: "2024-01-01",
startTime: "09:00",
takenBy: "",
intakeRemindersEnabled: false,
},
{
usage: "2",
every: "7",
startDate: "2024-01-01",
startTime: "10:00",
takenBy: "",
intakeRemindersEnabled: false,
},
],
};
render(<MobileEditModal {...defaultProps} form={form} onRemoveBlister={onRemoveBlister} />);
render(<MobileEditModal {...defaultProps} form={form} onRemoveIntake={onRemoveIntake} />);
const removeButtons = document.querySelectorAll(".blister-row button.danger");
if (removeButtons.length > 0) {
fireEvent.click(removeButtons[0]);
expect(onRemoveBlister).toHaveBeenCalled();
expect(onRemoveIntake).toHaveBeenCalled();
}
});
it("calls onSetBlisterValue when changing blister field", () => {
const onSetBlisterValue = vi.fn();
it("calls onSetIntakeValue when changing blister field", () => {
const onSetIntakeValue = vi.fn();
render(<MobileEditModal {...defaultProps} onSetBlisterValue={onSetBlisterValue} />);
render(<MobileEditModal {...defaultProps} onSetIntakeValue={onSetIntakeValue} />);
const usageInputs = document.querySelectorAll('.blister-row input[type="number"]');
if (usageInputs.length > 0) {
fireEvent.change(usageInputs[0], { target: { value: "2" } });
expect(onSetBlisterValue).toHaveBeenCalled();
expect(onSetIntakeValue).toHaveBeenCalled();
}
});
});
@@ -0,0 +1,89 @@
import { fireEvent, render, screen } from "@testing-library/react";
import { describe, expect, it, vi } from "vitest";
import { PasswordInput } from "../../components/PasswordInput";
describe("PasswordInput", () => {
it("renders password input with hidden text by default", () => {
render(<PasswordInput id="test-password" value="secret123" onChange={() => {}} />);
const input = document.getElementById("test-password") as HTMLInputElement;
expect(input).toBeInTheDocument();
expect(input.type).toBe("password");
});
it("toggles password visibility when eye button is clicked", () => {
render(<PasswordInput id="test-password" value="secret123" onChange={() => {}} />);
const input = document.getElementById("test-password") as HTMLInputElement;
const toggleButton = screen.getByRole("button", { name: /show password/i });
// Initially password is hidden
expect(input.type).toBe("password");
// Click to show password
fireEvent.click(toggleButton);
expect(input.type).toBe("text");
// Click again to hide password
fireEvent.click(toggleButton);
expect(input.type).toBe("password");
});
it("calls onChange when input value changes", () => {
const handleChange = vi.fn();
render(<PasswordInput id="test-password" value="" onChange={handleChange} />);
const input = document.getElementById("test-password") as HTMLInputElement;
fireEvent.change(input, { target: { value: "newpassword" } });
expect(handleChange).toHaveBeenCalled();
});
it("passes through required attribute", () => {
render(<PasswordInput id="test-password" value="" onChange={() => {}} required />);
const input = document.getElementById("test-password") as HTMLInputElement;
expect(input.required).toBe(true);
});
it("passes through minLength and maxLength attributes", () => {
render(<PasswordInput id="test-password" value="" onChange={() => {}} minLength={8} maxLength={128} />);
const input = document.getElementById("test-password") as HTMLInputElement;
expect(input.minLength).toBe(8);
expect(input.maxLength).toBe(128);
});
it("passes through placeholder attribute", () => {
render(<PasswordInput id="test-password" value="" onChange={() => {}} placeholder="Enter password" />);
const input = document.getElementById("test-password") as HTMLInputElement;
expect(input.placeholder).toBe("Enter password");
});
it("passes through autoComplete attribute", () => {
render(<PasswordInput id="test-password" value="" onChange={() => {}} autoComplete="new-password" />);
const input = document.getElementById("test-password") as HTMLInputElement;
expect(input.autocomplete).toBe("new-password");
});
it("toggle button has correct aria-label", () => {
render(<PasswordInput id="test-password" value="" onChange={() => {}} />);
const toggleButton = screen.getByRole("button", { name: /show password/i });
expect(toggleButton).toBeInTheDocument();
fireEvent.click(toggleButton);
const hideButton = screen.getByRole("button", { name: /hide password/i });
expect(hideButton).toBeInTheDocument();
});
it("toggle button has tabIndex -1 to prevent focus during form navigation", () => {
render(<PasswordInput id="test-password" value="" onChange={() => {}} />);
const toggleButton = screen.getByRole("button");
expect(toggleButton.tabIndex).toBe(-1);
});
});
@@ -7,6 +7,8 @@ const defaultSettings: StockThresholds = {
lowStockDays: 7,
normalStockDays: 30,
highStockDays: 90,
criticalStockDays: 3,
expiryWarningDays: 30,
};
const mockMedication: Medication = {
@@ -139,6 +139,13 @@ const createMockAppContext = (overrides = {}) => ({
coverage: { all: [], low: [] },
coverageByMed: {},
depletionByMed: {},
stockThresholds: {
lowStockDays: 7,
normalStockDays: 30,
highStockDays: 90,
criticalStockDays: 7,
expiryWarningDays: 30,
},
manuallyExpandedDays: new Set(),
manuallyCollapsedDays: new Set(),
toggleDayCollapse: vi.fn(),
@@ -400,8 +407,8 @@ describe("DashboardPage structure", () => {
// Should have all expected table columns
expect(screen.getByText(/table\.name/i)).toBeInTheDocument();
expect(screen.getByText(/table\.fullBlisters/i)).toBeInTheDocument();
expect(screen.getByText(/table\.openBlister/i)).toBeInTheDocument();
expect(screen.getByText(/table\.stock(?!Details)/i)).toBeInTheDocument();
expect(screen.getByText(/table\.stockDetails/i)).toBeInTheDocument();
expect(screen.getByText(/table\.daysLeft/i)).toBeInTheDocument();
expect(screen.getByText(/table\.runsOut/i)).toBeInTheDocument();
expect(screen.getByText(/table\.expiry/i)).toBeInTheDocument();
+105 -24
View File
@@ -57,6 +57,7 @@ const createMockContext = (overrides = {}) => ({
setRefillLoose: vi.fn(),
refillSaving: false,
submitRefill: vi.fn(),
coverageByMed: {},
...overrides,
});
@@ -65,12 +66,24 @@ const createMockFormHook = (overrides = {}) => ({
form: {
name: "",
genericName: "",
packageType: "blister" as const,
packCount: "0",
blistersPerPack: "0",
pillsPerBlister: "1",
looseTablets: "0",
totalPills: "",
takenBy: [],
blisters: [{ usage: "1", every: "1", startDate: new Date().toISOString().slice(0, 10), startTime: "09:00" }],
intakes: [
{
usage: "1",
every: "1",
startDate: new Date().toISOString().slice(0, 10),
startTime: "09:00",
takenBy: "",
intakeRemindersEnabled: false,
},
],
expiryDate: "",
notes: "",
pillWeightMg: "",
@@ -93,6 +106,9 @@ const createMockFormHook = (overrides = {}) => ({
addBlister: vi.fn(),
removeBlister: vi.fn(),
setBlisterValue: vi.fn(),
addIntake: vi.fn(),
removeIntake: vi.fn(),
setIntakeValue: vi.fn(),
resetForm: vi.fn(),
startEdit: vi.fn(),
showEditModal: false,
@@ -328,9 +344,9 @@ describe("MedicationsPage form interactions", () => {
}
});
it("calls addBlister when clicking add schedule button", () => {
const addBlister = vi.fn();
mockFormHookValue = createMockFormHook({ addBlister });
it("calls addIntake when clicking add schedule button", () => {
const addIntake = vi.fn();
mockFormHookValue = createMockFormHook({ addIntake });
render(
<MemoryRouter>
@@ -338,11 +354,11 @@ describe("MedicationsPage form interactions", () => {
</MemoryRouter>
);
// Find add blister button
// Find add intake button
const addBtn = screen.queryByText(/form\.blisters\.add/i) || screen.queryByText(/\+/);
if (addBtn) {
fireEvent.click(addBtn);
expect(addBlister).toHaveBeenCalled();
expect(addIntake).toHaveBeenCalled();
}
});
});
@@ -393,12 +409,24 @@ describe("MedicationsPage editing", () => {
form: {
name: "Aspirin",
genericName: "Acetylsalicylic acid",
packageType: "blister" as const,
packCount: "1",
blistersPerPack: "2",
pillsPerBlister: "10",
looseTablets: "5",
totalPills: "",
takenBy: ["John"],
blisters: [{ usage: "1", every: "1", startDate: "2024-01-01", startTime: "09:00" }],
intakes: [
{
usage: "1",
every: "1",
startDate: "2024-01-01",
startTime: "09:00",
takenBy: "",
intakeRemindersEnabled: false,
},
],
expiryDate: "2025-12-31",
notes: "Take with food",
pillWeightMg: "",
@@ -558,14 +586,24 @@ describe("MedicationsPage blister management", () => {
expect(blisterSections.length).toBeGreaterThan(0);
});
it("calls setBlisterValue when changing blister field", () => {
const setBlisterValue = vi.fn();
it("calls setIntakeValue when changing blister field", () => {
const setIntakeValue = vi.fn();
mockFormHookValue = createMockFormHook({
form: {
...createMockFormHook().form,
blisters: [{ usage: "1", every: "1", startDate: "2024-01-01", startTime: "09:00" }],
intakes: [
{
usage: "1",
every: "1",
startDate: "2024-01-01",
startTime: "09:00",
takenBy: "",
intakeRemindersEnabled: false,
},
],
},
setBlisterValue,
setIntakeValue,
});
render(
@@ -578,7 +616,7 @@ describe("MedicationsPage blister management", () => {
const blisterInputs = document.querySelectorAll('.blister-inputs input[type="number"]');
if (blisterInputs.length > 0) {
fireEvent.change(blisterInputs[0], { target: { value: "2" } });
expect(setBlisterValue).toHaveBeenCalled();
expect(setIntakeValue).toHaveBeenCalled();
}
});
});
@@ -591,9 +629,9 @@ describe("MedicationsPage add blister", () => {
mockFormHookValue = createMockFormHook();
});
it("calls addBlister when clicking add intake button", () => {
const addBlister = vi.fn();
mockFormHookValue = createMockFormHook({ addBlister });
it("calls addIntake when clicking add intake button", () => {
const addIntake = vi.fn();
mockFormHookValue = createMockFormHook({ addIntake });
render(
<MemoryRouter>
@@ -603,7 +641,7 @@ describe("MedicationsPage add blister", () => {
const addIntakeBtn = screen.getByRole("button", { name: /form\.blisters\.addIntake/i });
fireEvent.click(addIntakeBtn);
expect(addBlister).toHaveBeenCalled();
expect(addIntake).toHaveBeenCalled();
});
});
@@ -619,6 +657,24 @@ describe("MedicationsPage remove blister", () => {
{ usage: "1", every: "1", startDate: "2024-01-01", startTime: "09:00" },
{ usage: "2", every: "7", startDate: "2024-01-01", startTime: "20:00" },
],
intakes: [
{
usage: "1",
every: "1",
startDate: "2024-01-01",
startTime: "09:00",
takenBy: "",
intakeRemindersEnabled: false,
},
{
usage: "2",
every: "7",
startDate: "2024-01-01",
startTime: "20:00",
takenBy: "",
intakeRemindersEnabled: false,
},
],
},
});
});
@@ -635,8 +691,8 @@ describe("MedicationsPage remove blister", () => {
expect(removeButtons.length).toBeGreaterThan(0);
});
it("calls removeBlister when clicking remove button", () => {
const removeBlister = vi.fn();
it("calls removeIntake when clicking remove button", () => {
const removeIntake = vi.fn();
mockFormHookValue = createMockFormHook({
form: {
...createMockFormHook().form,
@@ -644,8 +700,26 @@ describe("MedicationsPage remove blister", () => {
{ usage: "1", every: "1", startDate: "2024-01-01", startTime: "09:00" },
{ usage: "2", every: "7", startDate: "2024-01-01", startTime: "20:00" },
],
intakes: [
{
usage: "1",
every: "1",
startDate: "2024-01-01",
startTime: "09:00",
takenBy: "",
intakeRemindersEnabled: false,
},
{
usage: "2",
every: "7",
startDate: "2024-01-01",
startTime: "20:00",
takenBy: "",
intakeRemindersEnabled: false,
},
],
},
removeBlister,
removeIntake,
});
render(
@@ -657,7 +731,7 @@ describe("MedicationsPage remove blister", () => {
const removeButtons = document.querySelectorAll(".blister-row .danger");
if (removeButtons.length > 0) {
fireEvent.click(removeButtons[0]);
expect(removeBlister).toHaveBeenCalled();
expect(removeIntake).toHaveBeenCalled();
}
});
});
@@ -670,19 +744,25 @@ describe("MedicationsPage intake reminders toggle", () => {
mockFormHookValue = createMockFormHook();
});
it("renders intake reminders checkbox", () => {
it("renders intake reminders checkbox per intake", () => {
render(
<MemoryRouter>
<MedicationsPage />
</MemoryRouter>
);
expect(screen.getByText(/form\.blisters\.remind/i)).toBeInTheDocument();
// Now each intake row has its own reminder checkbox with the bell icon
// Desktop form uses class "full blisters" container
const blistersContainer = document.querySelector(".blisters");
expect(blistersContainer).toBeInTheDocument();
// Check for the inline-checkbox that controls intake reminders in each blister row
const intakeCheckbox = document.querySelector(".blister-row .inline-checkbox");
expect(intakeCheckbox).toBeInTheDocument();
});
it("can toggle intake reminders", () => {
const setForm = vi.fn();
mockFormHookValue = createMockFormHook({ setForm });
it("can toggle intake reminders per intake", () => {
const setIntakeValue = vi.fn();
mockFormHookValue = createMockFormHook({ setIntakeValue });
render(
<MemoryRouter>
@@ -690,10 +770,11 @@ describe("MedicationsPage intake reminders toggle", () => {
</MemoryRouter>
);
const checkbox = document.querySelector('.inline-checkbox input[type="checkbox"]');
// Each blister row has inline-checkbox for intake reminders
const checkbox = document.querySelector('.blister-row .inline-checkbox input[type="checkbox"]');
if (checkbox) {
fireEvent.click(checkbox);
expect(setForm).toHaveBeenCalled();
expect(setIntakeValue).toHaveBeenCalled();
}
});
});
+71 -12
View File
@@ -2,29 +2,57 @@
// Core Types for MedAssist
// =============================================================================
export type PackageType = "blister" | "bottle";
// Common medication dose units
export type DoseUnit = "mg" | "g" | "mcg" | "ml";
export const DOSE_UNITS: { value: DoseUnit; label: string }[] = [
{ value: "mg", label: "mg" },
{ value: "g", label: "g" },
{ value: "mcg", label: "mcg (µg)" },
{ value: "ml", label: "ml" },
];
export type Blister = {
usage: number;
every: number;
start: string;
};
/**
* Intake with per-intake takenBy support.
* Extends Blister with per-intake user assignment.
*/
export type Intake = {
usage: number;
every: number;
start: string;
takenBy: string | null; // Per-intake user assignment (single person or null)
intakeRemindersEnabled: boolean;
};
export type Medication = {
id: number;
name: string;
genericName?: string | null;
takenBy: string[];
takenBy: string[]; // Medication-level takenBy (legacy, still used for filtering)
packageType: PackageType;
packCount: number;
blistersPerPack: number;
pillsPerBlister: number;
looseTablets: number;
totalPills?: number | null; // For bottle type: total capacity of the container
looseTablets: number; // For blister: extra loose pills; for bottle: current stock
stockAdjustment?: number;
lastStockCorrectionAt?: string | null;
pillWeightMg?: number | null;
blisters: Blister[];
doseUnit?: DoseUnit | null; // Unit for the dose (mg, g, mcg, ml, IU, etc.)
blisters: Blister[]; // Legacy array format
intakes?: Intake[]; // New intake format with per-intake takenBy
imageUrl?: string | null;
expiryDate?: string | null;
notes?: string | null;
intakeRemindersEnabled?: boolean;
intakeRemindersEnabled?: boolean; // Medication-level setting (deprecated, use per-intake)
dismissedUntil?: string | null; // ISO date string (YYYY-MM-DD) - all past doses until this date are dismissed
updatedAt: string | number | null;
};
@@ -55,19 +83,35 @@ export type FormBlister = {
startTime: string;
};
/**
* Form state for intake entry with per-intake takenBy support.
*/
export type FormIntake = {
usage: string;
every: string;
startDate: string;
startTime: string;
takenBy: string; // Single person or empty string (empty = null for everyone)
intakeRemindersEnabled: boolean;
};
export type FormState = {
name: string;
genericName: string;
takenBy: string[];
takenBy: string[]; // Medication-level takenBy (legacy/compatibility)
packageType: PackageType;
packCount: string;
blistersPerPack: string;
pillsPerBlister: string;
looseTablets: string;
totalPills: string; // For bottle type: total capacity
looseTablets: string; // For blister: extra loose pills; for bottle: current stock
pillWeightMg: string;
doseUnit: DoseUnit; // Unit for the dose (mg, g, mcg, ml, IU, etc.)
expiryDate: string;
notes: string;
intakeRemindersEnabled: boolean;
blisters: FormBlister[];
intakeRemindersEnabled: boolean; // Deprecated, kept for backward compat
blisters: FormBlister[]; // Legacy form format
intakes: FormIntake[]; // New form format with per-intake takenBy
};
export type FieldErrors = {
@@ -87,7 +131,7 @@ export type Coverage = {
};
export type StockStatus = {
level: "out-of-stock" | "low" | "normal" | "high";
level: "out-of-stock" | "critical" | "low" | "normal" | "high";
className: string;
label: string;
};
@@ -96,6 +140,8 @@ export type StockThresholds = {
lowStockDays: number;
normalStockDays: number;
highStockDays: number;
criticalStockDays: number; // Threshold for critical/danger status (typically reminderDaysBefore)
expiryWarningDays: number; // Days before expiry to show warning
};
export type ScheduleEvent = {
@@ -106,7 +152,7 @@ export type ScheduleEvent = {
usage: number;
when: number;
isPast: boolean;
takenBy: string[];
takenBy: string | null; // Per-intake takenBy (single person or null)
};
export type BlisterStock = {
@@ -121,14 +167,16 @@ export type SharedMedication = {
name: string;
genericName?: string | null;
pillWeightMg?: number | null;
doseUnit?: DoseUnit | null;
imageUrl?: string | null;
totalPills: number;
packCount: number;
blistersPerPack: number;
looseTablets: number;
pillsPerBlister: number;
takenBy: string[];
blisters: Blister[];
takenBy: string[]; // Medication-level takenBy (legacy)
blisters: Blister[]; // Legacy array format
intakes?: Intake[]; // New intake format with per-intake takenBy
dismissedUntil?: string | null;
updatedAt?: string | number | null; // For filtering out doses from previous schedule configurations
};
@@ -165,14 +213,25 @@ export const FIELD_LIMITS = {
type MedLike = Pick<Medication, "packCount" | "blistersPerPack" | "pillsPerBlister" | "looseTablets"> & {
stockAdjustment?: number;
packageType?: PackageType;
};
/** Calculate total pills including stockAdjustment */
export function getMedTotal(med: MedLike): number {
// For bottle type, looseTablets IS the current stock
if (med.packageType === "bottle") {
return med.looseTablets + (med.stockAdjustment ?? 0);
}
// For blister type, calculate from packs + loose
return med.packCount * med.blistersPerPack * med.pillsPerBlister + med.looseTablets + (med.stockAdjustment ?? 0);
}
/** Get the base package size (without stockAdjustment) */
export function getPackageSize(med: MedLike): number {
// For bottle type, looseTablets IS the current stock
if (med.packageType === "bottle") {
return med.looseTablets;
}
// For blister type, calculate from packs + loose
return med.packCount * med.blistersPerPack * med.pillsPerBlister + med.looseTablets;
}
+119 -16
View File
@@ -2,9 +2,37 @@
// Schedule Building and Coverage Calculations
// =============================================================================
import type { Coverage, Medication, ScheduleEvent, StockStatus, StockThresholds } from "../types";
import type { Blister, Coverage, Intake, Medication, ScheduleEvent, StockStatus, StockThresholds } from "../types";
import { getMedTotal } from "../types";
/**
* Get intakes for a medication, preferring new intakes format over legacy blisters
*/
function getIntakesForMed(med: Medication): Intake[] {
// Use new intakes array if available and non-empty
if (med.intakes && med.intakes.length > 0) {
return med.intakes;
}
// Fallback to legacy blisters (convert to Intake format)
return med.blisters.map((b) => ({
usage: b.usage,
every: b.every,
start: b.start,
takenBy: null, // Legacy format has no per-intake takenBy
intakeRemindersEnabled: med.intakeRemindersEnabled ?? false,
}));
}
/**
* Get blisters for a medication (for backward compatibility with coverage calculations)
*/
function getBlistersForMed(med: Medication): Blister[] {
if (med.intakes && med.intakes.length > 0) {
return med.intakes.map((i) => ({ usage: i.usage, every: i.every, start: i.start }));
}
return med.blisters;
}
/**
* Build schedule preview events for medications
*/
@@ -22,10 +50,11 @@ export function buildSchedulePreview(
end.setDate(end.getDate() + 180); // 6 months horizon
meds.forEach((med) => {
med.blisters.forEach((blister, idx) => {
const start = new Date(blister.start);
const intakes = getIntakesForMed(med);
intakes.forEach((intake, idx) => {
const start = new Date(intake.start);
if (Number.isNaN(start.getTime())) return;
for (let d = new Date(start); d <= end; d.setDate(d.getDate() + blister.every)) {
for (let d = new Date(start); d <= end; d.setDate(d.getDate() + intake.every)) {
const isPast = d < todayStart;
if (isPast && !includePast) continue;
const whenMs = d.getTime();
@@ -35,8 +64,8 @@ export function buildSchedulePreview(
events.push({
id: `${med.id}-${idx}-${dateOnlyMs}`,
medName: med.name,
takenBy: med.takenBy || [],
usage: blister.usage,
takenBy: intake.takenBy, // Per-intake takenBy (string | null)
usage: intake.usage,
when: whenMs,
isPast,
timeStr: d.toLocaleTimeString(locale, { hour: "2-digit", minute: "2-digit" }),
@@ -58,7 +87,7 @@ export function buildSchedulePreview(
events,
today: todayCount,
nextThree: events.length,
totalBlisters: meds.reduce((acc, m) => acc + m.blisters.length, 0),
totalBlisters: meds.reduce((acc, m) => acc + getIntakesForMed(m).length, 0),
};
}
@@ -67,7 +96,7 @@ export function buildSchedulePreview(
*/
export function calculateCoverage(
meds: Medication[],
events: Array<{ medName: string; when: number }>,
events: Array<{ medName: string; when: number; id: string }>,
locale: string,
reminderDaysBefore: number,
stockCalculationMode: "automatic" | "manual",
@@ -77,32 +106,96 @@ export function calculateCoverage(
const now = Date.now();
const coverage: Coverage[] = meds.map((m) => {
const personCount = Math.max(1, m.takenBy?.length || 1);
const dailyRate = m.blisters.reduce((sum, s) => sum + (s.every > 0 ? s.usage / s.every : 0), 0) * personCount;
const intakes = getIntakesForMed(m);
const blisters = getBlistersForMed(m);
// Count unique people from all intakes (for per-intake takenBy)
const uniquePeople = new Set<string>();
intakes.forEach((intake) => {
if (intake.takenBy) uniquePeople.add(intake.takenBy);
});
// Also add medication-level takenBy for backward compatibility
m.takenBy?.forEach((person) => uniquePeople.add(person));
const personCount = Math.max(1, uniquePeople.size || m.takenBy?.length || 1);
const dailyRate = blisters.reduce((sum, s) => sum + (s.every > 0 ? s.usage / s.every : 0), 0) * personCount;
let consumed = 0;
const stockCorrectionCutoff = m.lastStockCorrectionAt ? new Date(m.lastStockCorrectionAt).getTime() : 0;
if (stockCalculationMode === "automatic") {
m.blisters.forEach((s) => {
// In automatic mode, calculate expected consumption based on time
// but also account for manual corrections (doses marked as not taken)
blisters.forEach((s, blisterIdx) => {
const blisterStart = new Date(s.start).getTime();
const effectiveStart = Math.max(blisterStart, stockCorrectionCutoff);
if (Number.isNaN(effectiveStart) || effectiveStart > now) return;
const period = Math.max(1, s.every) * MS_PER_DAY;
const occurrences = Math.floor((now - effectiveStart) / period) + 1;
consumed += occurrences * s.usage * personCount;
const intake = intakes[blisterIdx];
const intakePerson = intake?.takenBy;
// For per-intake takenBy, only count for that person
// For legacy (no takenBy), count for all people in medication takenBy
const peopleForThisIntake = intakePerson ? [intakePerson] : m.takenBy?.length > 0 ? m.takenBy : [null];
const expectedConsumed = occurrences * s.usage * peopleForThisIntake.length;
// Count how many doses were actually marked as taken for this blister
let actualConsumed = 0;
// Generate all expected dose IDs for this blister up to now
for (let i = 0; i < occurrences; i++) {
const doseDate = new Date(effectiveStart + i * period);
const dateOnlyMs = new Date(doseDate.getFullYear(), doseDate.getMonth(), doseDate.getDate()).getTime();
const baseDoseId = `${m.id}-${blisterIdx}-${dateOnlyMs}`;
// Check if each person has taken this dose
for (const person of peopleForThisIntake) {
const doseId = person ? `${baseDoseId}-${person}` : baseDoseId;
if (takenDoses.has(doseId)) {
actualConsumed += s.usage;
}
}
}
// If we have tracking data (any doses marked), use actual consumed
// Otherwise fall back to expected (for backwards compatibility)
const hasTrackingData = Array.from(takenDoses).some((id) => {
const parts = id.split("-");
return parts.length >= 3 && parseInt(parts[0], 10) === m.id && parseInt(parts[1], 10) === blisterIdx;
});
consumed += hasTrackingData ? actualConsumed : expectedConsumed;
});
} else {
// In manual mode, only count doses that are explicitly marked as taken
takenDoses.forEach((doseId) => {
const parts = doseId.split("-");
if (parts.length >= 3) {
const medId = parseInt(parts[0], 10);
const blisterIdx = parseInt(parts[1], 10);
const doseTimestamp = parseInt(parts[2], 10);
if (medId === m.id && m.blisters[blisterIdx]) {
const blisterStart = new Date(m.blisters[blisterIdx].start).getTime();
if (!Number.isNaN(blisterStart) && doseTimestamp >= blisterStart && doseTimestamp > stockCorrectionCutoff) {
consumed += m.blisters[blisterIdx].usage;
if (medId === m.id && blisters[blisterIdx]) {
// Convert blister start to date-only for comparison (dose timestamps are date-only)
const blisterStartDate = new Date(blisters[blisterIdx].start);
const blisterStartDateOnly = new Date(
blisterStartDate.getFullYear(),
blisterStartDate.getMonth(),
blisterStartDate.getDate()
).getTime();
// Convert stock correction cutoff to date-only as well
const stockCorrectionDateOnly =
stockCorrectionCutoff > 0
? new Date(
new Date(stockCorrectionCutoff).getFullYear(),
new Date(stockCorrectionCutoff).getMonth(),
new Date(stockCorrectionCutoff).getDate()
).getTime()
: 0;
if (
!Number.isNaN(blisterStartDateOnly) &&
doseTimestamp >= blisterStartDateOnly &&
doseTimestamp >= stockCorrectionDateOnly
) {
consumed += blisters[blisterIdx].usage;
}
}
}
@@ -146,22 +239,32 @@ export function calculateCoverage(
* Get stock status based on days left and thresholds
*/
export function getStockStatus(daysLeft: number | null, medsLeft: number, thresholds: StockThresholds): StockStatus {
// Out of stock or completely depleted = danger (red)
if (medsLeft <= 0 || daysLeft === 0) {
return { level: "out-of-stock", className: "danger", label: "status.outOfStock" };
}
// No schedule, but has stock = normal
if (daysLeft === null) {
return { level: "normal", className: "success", label: "status.noSchedule" };
}
// High stock
if (daysLeft > thresholds.highStockDays) {
return { level: "high", className: "high", label: "status.highStock" };
}
// Normal stock
if (daysLeft >= thresholds.lowStockDays) {
return { level: "normal", className: "success", label: "status.normal" };
}
// Critical: at or below critical threshold = danger (red)
if (daysLeft <= thresholds.criticalStockDays) {
return { level: "critical", className: "danger", label: "status.criticalStock" };
}
// Low stock: below lowStockDays but above critical = warning (yellow)
return { level: "low", className: "warning", label: "status.lowStock" };
}