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
+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";