feat: enable weekday-based medication scheduling

Closes #463
This commit is contained in:
Daniel Volz
2026-03-20 14:58:25 +01:00
committed by GitHub
parent 29f4c4e48d
commit 68ab79c713
35 changed files with 1856 additions and 841 deletions
+10 -25
View File
@@ -20,11 +20,14 @@ import {
getMedDisplayName,
getMedTotal,
getPackageSize,
type IntakeUnit,
isAmountBasedPackageType,
isLiquidContainerPackageType,
isTubePackageType,
} from "../types";
import { formatNumber, generateICS, getExpiryClass, getSystemLocale } from "../utils";
import { getIntakeFrequencyText, getMedicationIntakes } from "../utils/intake-schedule";
import { getLiquidCountUnitLabel } from "../utils/intake-units";
import { getStockStatus } from "../utils/schedule";
import { splitCurrentBlisterStock } from "../utils/stock";
@@ -254,32 +257,16 @@ export function MedDetailModal({
const isCountBasedAmountRefillPackage = isLiquidRefillPackage || isTubeRefillPackage;
const liquidRefillAmountPerBottle = Math.max(1, Math.round(Number.isFinite(amountPerPackage) ? amountPerPackage : 1));
const amountRefillPackageCount = Math.max(0, Math.round(refillLoose / liquidRefillAmountPerBottle));
const getScheduleUsageLabel = (usage: number, intakeUnit?: "ml" | "tsp" | "tbsp" | null) => {
const getScheduleUsageLabel = (usage: number, intakeUnit?: IntakeUnit | null) => {
if (isLiquidContainerPackageType(selectedMed.packageType)) {
if (intakeUnit === "tsp") {
return `${usage} ${t("form.blisters.teaspoons", { count: Math.abs(usage) })}`;
}
if (intakeUnit === "tbsp") {
return `${usage} ${t("form.blisters.tablespoons", { count: Math.abs(usage) })}`;
}
return `${usage} ${t("form.packageAmountUnitMl")}`;
return `${usage} ${getLiquidCountUnitLabel(intakeUnit, usage, t)}`;
}
if (isTubePackageType(selectedMed.packageType)) {
return `${usage} ${t("form.blisters.applications", { count: Math.abs(usage) })}`;
}
return `${usage} ${usage !== 1 ? t("common.pills") : t("common.pill")}`;
};
const scheduleIntakes =
selectedMed.intakes && selectedMed.intakes.length > 0
? selectedMed.intakes
: selectedMed.blisters.map((blister) => ({
usage: blister.usage,
every: blister.every,
start: blister.start,
takenBy: null,
intakeRemindersEnabled: false,
intakeUnit: null,
}));
const scheduleIntakes = getMedicationIntakes(selectedMed);
const hasAnyIntakeReminder = scheduleIntakes.some((intake) => intake.intakeRemindersEnabled === true);
const normalizeBlisterStock = (nextFull: number, nextPartial: number, nextLoose: number) => {
let normalizedFull = Math.max(0, nextFull);
@@ -969,7 +956,7 @@ export function MedDetailModal({
</div>
{/* Intake Schedule Section */}
{selectedMed.blisters.length > 0 && (
{scheduleIntakes.length > 0 && (
<div className="med-detail-section">
<h3>
{t("modal.intakeSchedule")}{" "}
@@ -985,7 +972,7 @@ export function MedDetailModal({
const personCount = Math.max(1, selectedMed.takenBy?.length ?? 0);
const totalUsage = hasPerIntakeTakenBy ? intake.usage : intake.usage * personCount;
const showIntakeBell = intake.intakeRemindersEnabled === true;
const intakeKey = `${intake.start}-${intake.usage}-${intake.every}-${intake.takenBy ?? ""}-${intake.intakeRemindersEnabled ? "reminder" : "silent"}`;
const intakeKey = `${intake.start}-${intake.usage}-${intake.every}-${intake.scheduleMode ?? "interval"}-${(intake.weekdays ?? []).join("")}-${intake.takenBy ?? ""}-${intake.intakeRemindersEnabled ? "reminder" : "silent"}`;
return (
<div key={intakeKey} className="med-schedule-row blister-row-simple">
@@ -993,9 +980,7 @@ export function MedDetailModal({
{getScheduleUsageLabel(totalUsage, intake.intakeUnit)}
{showPillWeightDetails && ` (${totalUsage * pillWeightMg} ${selectedMed.doseUnit ?? "mg"})`}
</span>
<span className="med-schedule-freq">
{intake.every === 1 ? t("common.daily") : t("common.everyNDays", { count: intake.every })}
</span>
<span className="med-schedule-freq">{getIntakeFrequencyText(intake, t)}</span>
{hasPerIntakeTakenBy && <span className="med-schedule-person">{intake.takenBy}</span>}
<span className="med-schedule-time">
{t("modal.at")}{" "}
@@ -1166,7 +1151,7 @@ export function MedDetailModal({
<FilePenLine size={18} aria-hidden="true" />
</button>
)}
{selectedMed.blisters.length > 0 && (
{scheduleIntakes.length > 0 && (
<button
className="secondary icon-only tooltip-trigger"
onClick={() => generateICS(selectedMed)}
+85 -11
View File
@@ -19,6 +19,13 @@ import {
PACKAGE_PROFILES,
} from "../types";
import { deriveTotal } from "../utils";
import {
getIntakeScheduleMode,
getWeekdayLabel,
hasSelectedWeekdays,
toggleWeekdaySelection,
WEEKDAY_CODES,
} from "../utils/intake-schedule";
import { DateInput } from "./DateInput";
import { FormNumberStepper } from "./FormNumberStepper";
@@ -57,7 +64,7 @@ export interface MobileEditModalProps {
onAddBlister: () => void;
onRemoveBlister: (idx: number) => void;
// Intake helpers (new - with per-intake takenBy)
onSetIntakeValue: (idx: number, field: keyof FormIntake, value: string | boolean) => void;
onSetIntakeValue: <K extends keyof FormIntake>(idx: number, field: K, value: FormIntake[K]) => void;
onAddIntake: (takenBy?: string) => void;
onRemoveIntake: (idx: number) => void;
// Value change handler for numeric fields
@@ -158,6 +165,24 @@ export function MobileEditModal({
const totalCapacityLabel = usesAmountLabels ? t("form.totalAmount") : t("form.totalCapacity");
const currentStockLabel = usesAmountLabels ? t("form.currentAmount") : t("form.currentPills");
const totalLabel = usesAmountLabels ? t("form.totalAmountLabel") : t("form.total");
const weekdayOptions = useMemo(
() =>
WEEKDAY_CODES.map((day) => ({
value: day,
shortLabel: getWeekdayLabel(day, t, "short"),
longLabel: getWeekdayLabel(day, t, "long"),
})),
[t]
);
const hasWeekdaySelectionError = useCallback(
(intake: (typeof form.intakes)[number]) =>
getIntakeScheduleMode(intake) === "weekdays" && !hasSelectedWeekdays(intake.weekdays),
[]
);
const hasWeekdayScheduleError = useMemo(
() => form.intakes.some((intake) => hasWeekdaySelectionError(intake)),
[form.intakes, hasWeekdaySelectionError]
);
// Reset tab when modal opens
useEffect(() => {
@@ -815,7 +840,9 @@ export function MobileEditModal({
)}
</div>
{form.intakes.map((intake, idx) => {
const intakeKey = `${intake.startDate}-${intake.startTime}-${intake.usage}-${intake.every}-${intake.takenBy ?? ""}-${intake.intakeUnit ?? "unit"}-${intake.intakeRemindersEnabled ? "reminder" : "silent"}`;
const scheduleMode = getIntakeScheduleMode(intake);
const selectedWeekdays = intake.weekdays ?? [];
const intakeKey = `${intake.startDate}-${intake.startTime}-${intake.usage}-${intake.every}-${scheduleMode}-${selectedWeekdays.join("")}-${intake.takenBy ?? ""}-${intake.intakeUnit ?? "unit"}-${intake.intakeRemindersEnabled ? "reminder" : "silent"}`;
return (
<div key={intakeKey} className="blister-row">
<label className="compact">
@@ -831,15 +858,60 @@ export function MobileEditModal({
/>
</label>
<label className="compact">
<span>{t("form.blisters.everyDays")}</span>
<FormNumberStepper
value={intake.every}
onChange={(nextValue) => onSetIntakeValue(idx, "every", nextValue)}
min={1}
decrementLabel={decrementValueLabel}
incrementLabel={incrementValueLabel}
/>
<span>{t("form.blisters.scheduleMode")}</span>
<select
className="select-field"
value={scheduleMode}
onChange={(e) =>
onSetIntakeValue(idx, "scheduleMode", e.target.value as "interval" | "weekdays")
}
>
<option value="interval">{t("form.blisters.scheduleModeInterval")}</option>
<option value="weekdays">{t("form.blisters.scheduleModeWeekdays")}</option>
</select>
</label>
{scheduleMode === "interval" ? (
<label className="compact">
<span>{t("form.blisters.everyDays")}</span>
<FormNumberStepper
value={intake.every}
onChange={(nextValue) => onSetIntakeValue(idx, "every", nextValue)}
min={1}
decrementLabel={decrementValueLabel}
incrementLabel={incrementValueLabel}
/>
</label>
) : (
<label className="compact full-row">
<span>{t("form.blisters.weekdays")}</span>
<div className="badges">
{weekdayOptions.map((weekday) => {
const isSelected = selectedWeekdays.includes(weekday.value);
return (
<button
key={weekday.value}
type="button"
className={isSelected ? "pill clickable" : "pill clickable neutral"}
aria-pressed={isSelected}
title={weekday.longLabel}
onClick={() =>
onSetIntakeValue(
idx,
"weekdays",
toggleWeekdaySelection(selectedWeekdays, weekday.value)
)
}
>
{weekday.shortLabel}
</button>
);
})}
</div>
{!readOnlyMode && hasWeekdaySelectionError(intake) && (
<span className="field-error">{t("form.blisters.weekdaysRequired")}</span>
)}
</label>
)}
<label className="compact full-row">
<span>{t("form.blisters.startDate")}</span>
<DateInput
@@ -984,7 +1056,9 @@ export function MobileEditModal({
<button
type="submit"
disabled={saving || (!formChanged && (formSaved || !!editingId))}
className={hasValidationErrors || dateConsistencyError ? "has-validation-error" : ""}
className={
hasValidationErrors || dateConsistencyError || hasWeekdayScheduleError ? "has-validation-error" : ""
}
>
{formSaved && !formChanged ? t("common.saved") : t("common.save")}
</button>
+30 -44
View File
@@ -10,6 +10,8 @@ import {
isLiquidContainerPackageType,
isTubePackageType,
} from "../types";
import { formatDate, formatDateTime } from "../utils/formatters";
import { getIntakeFrequencyText, getMedicationIntakes } from "../utils/intake-schedule";
import { MedicationAvatar } from "./MedicationAvatar";
type ReportFormat = "txt" | "md" | "pdf";
@@ -290,20 +292,6 @@ export function ReportModal({ isOpen, onClose, medications }: ReportModalProps)
type TFn = (key: string, opts?: Record<string, unknown>) => string;
function fmtDate(iso: string | null | undefined): string {
if (!iso) return "-";
const m = iso.match(/^(\d{4})-(\d{2})-(\d{2})/);
if (!m) return "-";
return `${m[3]}.${m[2]}.${m[1]}`;
}
function fmtDateTime(iso: string | null | undefined): string {
if (!iso) return "-";
const m = iso.match(/^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2})/);
if (!m) return `${fmtDate(iso)}`;
return `${m[3]}.${m[2]}.${m[1]} ${m[4]}:${m[5]}`;
}
function getTubeUnitKey(med: Medication): "form.ml" | "blisters.applications" {
if (isLiquidContainerPackageType(med.packageType)) return "form.ml";
return med.medicationForm === "liquid" ? "form.ml" : "blisters.applications";
@@ -353,7 +341,7 @@ function generateTextReport(
const item = (label: string, value: string) => (fmt === "md" ? `- ${bold(label)}: ${value}` : ` ${label}: ${value}`);
lines.push(h1(t("report.docTitle")));
lines.push(`${t("report.docGenerated")}: ${fmtDate(new Date().toISOString())}`);
lines.push(`${t("report.docGenerated")}: ${formatDate(new Date().toISOString())}`);
lines.push("");
for (const med of meds) {
@@ -373,8 +361,8 @@ function generateTextReport(
lines.push(
item(t("report.docStatus"), med.isObsolete ? t("report.docStatusObsolete") : t("report.docStatusActive"))
);
if (med.medicationStartDate) lines.push(item(t("report.docStartDate"), fmtDate(med.medicationStartDate)));
if (med.isObsolete && med.obsoleteAt) lines.push(item(t("report.docObsoleteSince"), fmtDate(med.obsoleteAt)));
if (med.medicationStartDate) lines.push(item(t("report.docStartDate"), formatDate(med.medicationStartDate)));
if (med.isObsolete && med.obsoleteAt) lines.push(item(t("report.docObsoleteSince"), formatDate(med.obsoleteAt)));
lines.push("");
// Package / Stock
@@ -391,24 +379,23 @@ function generateTextReport(
lines.push(item(t("report.docCurrentStock"), getCurrentStockText(med, t)));
if (!isTubePackageType(med.packageType) && !isLiquidContainerPackageType(med.packageType) && med.pillWeightMg)
lines.push(item(t("report.docDosePerPill"), `${med.pillWeightMg} ${med.doseUnit ?? "mg"}`));
if (med.expiryDate) lines.push(item(t("report.docExpiryDate"), fmtDate(med.expiryDate)));
if (med.expiryDate) lines.push(item(t("report.docExpiryDate"), formatDate(med.expiryDate)));
if (med.notes) lines.push(item(t("report.docNotes"), med.notes));
lines.push("");
// Intake Schedule
const allIntakes = med.intakes ?? med.blisters;
const allIntakes = getMedicationIntakes(med);
const intakes = personFilter
? allIntakes?.filter((i) => "takenBy" in i && personFilter.includes(i.takenBy as string))
? allIntakes?.filter((intake) => intake.takenBy && personFilter.includes(intake.takenBy))
: allIntakes;
if (intakes?.length) {
lines.push(h3(t("report.docIntakeSchedule")));
for (const intake of intakes) {
let entry = getUsageText(med, intake.usage, t);
entry += ` ${intake.every === 1 ? t("common.daily") : t("common.everyNDays", { count: intake.every })}`;
entry += ` ${t("form.blisters.from")} ${fmtDateTime(intake.start)}`;
if ("takenBy" in intake && intake.takenBy)
entry += ` (${t("report.docIntakeTakenBy", { person: intake.takenBy })})`;
if ("intakeRemindersEnabled" in intake && intake.intakeRemindersEnabled) entry += ` 🔔`;
entry += ` ${getIntakeFrequencyText(intake, t)}`;
entry += ` ${t("form.blisters.from")} ${formatDateTime(intake.start)}`;
if (intake.takenBy) entry += ` (${t("report.docIntakeTakenBy", { person: intake.takenBy })})`;
if (intake.intakeRemindersEnabled) entry += ` 🔔`;
lines.push(fmt === "md" ? `- ${entry}` : `${entry}`);
}
lines.push("");
@@ -420,7 +407,7 @@ function generateTextReport(
lines.push(item(t("report.docAuthorizedRefills"), String(med.prescriptionAuthorizedRefills ?? 0)));
lines.push(item(t("report.docRemainingRefills"), String(med.prescriptionRemainingRefills ?? 0)));
if (med.prescriptionExpiryDate)
lines.push(item(t("report.docPrescriptionExpiry"), fmtDate(med.prescriptionExpiryDate)));
lines.push(item(t("report.docPrescriptionExpiry"), formatDate(med.prescriptionExpiryDate)));
lines.push("");
}
@@ -434,8 +421,8 @@ function generateTextReport(
lines.push(item(`🤖 ${t("report.docDosesTakenAutomatic")}`, String(data.automaticDosesTaken)));
}
if (data.dosesDismissed > 0) lines.push(item(t("report.docDosesDismissed"), String(data.dosesDismissed)));
if (data.firstDoseAt) lines.push(item(t("report.docFirstDose"), fmtDate(data.firstDoseAt)));
if (data.lastDoseAt) lines.push(item(t("report.docLastDose"), fmtDate(data.lastDoseAt)));
if (data.firstDoseAt) lines.push(item(t("report.docFirstDose"), formatDate(data.firstDoseAt)));
if (data.lastDoseAt) lines.push(item(t("report.docLastDose"), formatDate(data.lastDoseAt)));
} else {
lines.push(fmt === "md" ? `- ${t("report.docNoDoses")}` : ` ${t("report.docNoDoses")}`);
}
@@ -445,7 +432,7 @@ function generateTextReport(
if (data.refills.length > 0) {
lines.push(h3(t("report.docRefillHistory")));
for (const r of data.refills) {
let entry = `${fmtDate(r.refillDate)}: +${r.packsAdded} ${t("report.docPacks")}, +${r.loosePillsAdded} ${isTubePackageType(med.packageType) || isLiquidContainerPackageType(med.packageType) ? t(getTubeUnitKey(med)) : t("common.pills")}`;
let entry = `${formatDate(r.refillDate)}: +${r.packsAdded} ${t("report.docPacks")}, +${r.loosePillsAdded} ${isTubePackageType(med.packageType) || isLiquidContainerPackageType(med.packageType) ? t(getTubeUnitKey(med)) : t("common.pills")}`;
if (r.usedPrescription) entry += ` ${t("report.docRefillPrescription")}`;
lines.push(fmt === "md" ? `- ${entry}` : `${entry}`);
}
@@ -528,7 +515,7 @@ function buildPrintHtml(
for (const med of meds) {
const data = reportData[med.id];
const intakes = med.intakes ?? med.blisters;
const intakes = getMedicationIntakes(med);
const displayName = getMedDisplayName(med);
const title = med.isObsolete
? `${escHtml(displayName)} <span class="obsolete-badge">${escHtml(t("report.docStatusObsolete"))}</span>`
@@ -560,11 +547,11 @@ function buildPrintHtml(
);
if (med.medicationStartDate)
generalRows.push(
`<tr><td class="label">${escHtml(t("report.docStartDate"))}</td><td>${fmtDate(med.medicationStartDate)}</td></tr>`
`<tr><td class="label">${escHtml(t("report.docStartDate"))}</td><td>${formatDate(med.medicationStartDate)}</td></tr>`
);
if (med.isObsolete && med.obsoleteAt)
generalRows.push(
`<tr><td class="label">${escHtml(t("report.docObsoleteSince"))}</td><td>${fmtDate(med.obsoleteAt)}</td></tr>`
`<tr><td class="label">${escHtml(t("report.docObsoleteSince"))}</td><td>${formatDate(med.obsoleteAt)}</td></tr>`
);
const generalTable = `<h3>${escHtml(t("report.docGeneral"))}</h3><table><tbody>${generalRows.join("")}</tbody></table>`;
@@ -591,7 +578,7 @@ function buildPrintHtml(
if (!isTubePackageType(med.packageType) && !isLiquidContainerPackageType(med.packageType) && med.pillWeightMg)
s += `<tr><td class="label">${escHtml(t("report.docDosePerPill"))}</td><td>${med.pillWeightMg} ${escHtml(med.doseUnit ?? "mg")}</td></tr>`;
if (med.expiryDate)
s += `<tr><td class="label">${escHtml(t("report.docExpiryDate"))}</td><td>${fmtDate(med.expiryDate)}</td></tr>`;
s += `<tr><td class="label">${escHtml(t("report.docExpiryDate"))}</td><td>${formatDate(med.expiryDate)}</td></tr>`;
if (med.notes)
s += `<tr><td class="label">${escHtml(t("report.docNotes"))}</td><td>${escHtml(med.notes)}</td></tr>`;
s += `</tbody></table>`;
@@ -599,18 +586,17 @@ function buildPrintHtml(
// Intake Schedule
const allPrintIntakes = intakes;
const filteredPrintIntakes = personFilter
? allPrintIntakes?.filter((i) => "takenBy" in i && personFilter.includes(i.takenBy as string))
? allPrintIntakes?.filter((intake) => intake.takenBy && personFilter.includes(intake.takenBy))
: allPrintIntakes;
if (filteredPrintIntakes?.length) {
s += `<h3>${escHtml(t("report.docIntakeSchedule"))}</h3>`;
s += `<ul>`;
for (const intake of filteredPrintIntakes) {
let entry = escHtml(getUsageText(med, intake.usage, t));
entry += ` ${escHtml(intake.every === 1 ? t("common.daily") : t("common.everyNDays", { count: intake.every }))}`;
entry += ` ${escHtml(t("form.blisters.from"))} ${fmtDateTime(intake.start)}`;
if ("takenBy" in intake && intake.takenBy)
entry += ` <em>(${escHtml(t("report.docIntakeTakenBy", { person: intake.takenBy }))})</em>`;
if ("intakeRemindersEnabled" in intake && intake.intakeRemindersEnabled) entry += ` 🔔`;
entry += ` ${escHtml(getIntakeFrequencyText(intake, t))}`;
entry += ` ${escHtml(t("form.blisters.from"))} ${formatDateTime(intake.start)}`;
if (intake.takenBy) entry += ` <em>(${escHtml(t("report.docIntakeTakenBy", { person: intake.takenBy }))})</em>`;
if (intake.intakeRemindersEnabled) entry += ` 🔔`;
s += `<li>${entry}</li>`;
}
s += `</ul>`;
@@ -623,7 +609,7 @@ function buildPrintHtml(
s += `<tr><td class="label">${escHtml(t("report.docAuthorizedRefills"))}</td><td>${med.prescriptionAuthorizedRefills ?? 0}</td></tr>`;
s += `<tr><td class="label">${escHtml(t("report.docRemainingRefills"))}</td><td>${med.prescriptionRemainingRefills ?? 0}</td></tr>`;
if (med.prescriptionExpiryDate)
s += `<tr><td class="label">${escHtml(t("report.docPrescriptionExpiry"))}</td><td>${fmtDate(med.prescriptionExpiryDate)}</td></tr>`;
s += `<tr><td class="label">${escHtml(t("report.docPrescriptionExpiry"))}</td><td>${formatDate(med.prescriptionExpiryDate)}</td></tr>`;
s += `</tbody></table>`;
}
@@ -639,9 +625,9 @@ function buildPrintHtml(
if (data.dosesDismissed > 0)
s += `<tr><td class="label">${escHtml(t("report.docDosesDismissed"))}</td><td>${data.dosesDismissed}</td></tr>`;
if (data.firstDoseAt)
s += `<tr><td class="label">${escHtml(t("report.docFirstDose"))}</td><td>${fmtDate(data.firstDoseAt)}</td></tr>`;
s += `<tr><td class="label">${escHtml(t("report.docFirstDose"))}</td><td>${formatDate(data.firstDoseAt)}</td></tr>`;
if (data.lastDoseAt)
s += `<tr><td class="label">${escHtml(t("report.docLastDose"))}</td><td>${fmtDate(data.lastDoseAt)}</td></tr>`;
s += `<tr><td class="label">${escHtml(t("report.docLastDose"))}</td><td>${formatDate(data.lastDoseAt)}</td></tr>`;
s += `</tbody></table>`;
} else {
s += `<p class="no-data">${escHtml(t("report.docNoDoses"))}</p>`;
@@ -652,7 +638,7 @@ function buildPrintHtml(
s += `<h3>${escHtml(t("report.docRefillHistory"))}</h3>`;
s += `<ul>`;
for (const r of data.refills) {
let entry = `${fmtDate(r.refillDate)}: +${r.packsAdded} ${escHtml(t("report.docPacks"))}, +${r.loosePillsAdded} ${escHtml(isTubePackageType(med.packageType) || isLiquidContainerPackageType(med.packageType) ? t(getTubeUnitKey(med)) : t("common.pills"))}`;
let entry = `${formatDate(r.refillDate)}: +${r.packsAdded} ${escHtml(t("report.docPacks"))}, +${r.loosePillsAdded} ${escHtml(isTubePackageType(med.packageType) || isLiquidContainerPackageType(med.packageType) ? t(getTubeUnitKey(med)) : t("common.pills"))}`;
if (r.usedPrescription) entry += ` <em>${escHtml(t("report.docRefillPrescription"))}</em>`;
s += `<li>${entry}</li>`;
}
@@ -708,7 +694,7 @@ function buildPrintHtml(
<body>
<div class="no-print print-hint">${escHtml(t("report.docPrintInstruction"))}</div>
<h1>${escHtml(t("report.docTitle"))}</h1>
<p class="subtitle">${escHtml(t("report.docGenerated"))}: ${fmtDate(new Date().toISOString())}</p>
<p class="subtitle">${escHtml(t("report.docGenerated"))}: ${formatDate(new Date().toISOString())}</p>
${sections.join("\n")}
</body>
</html>`;
+24 -62
View File
@@ -13,11 +13,14 @@ import {
allowsPillFormSelection,
getMedDisplayName,
getMedTotal,
type IntakeUnit,
isLiquidContainerPackageType,
isTubePackageType,
type StockThresholds,
} from "../types";
import { getSystemLocale } from "../utils/formatters";
import { getIntakeDailyRate, getMedicationIntakes, iterateIntakeOccurrences } from "../utils/intake-schedule";
import { convertLiquidUsageToMl, getLiquidCountUnitLabel } from "../utils/intake-units";
import { getStockStatus, isDoseDismissed, parseLocalDateTime } from "../utils/schedule";
import { loadCollapsedDaysFromStorage } from "../utils/storage";
import { MedicationAvatar } from "./MedicationAvatar";
@@ -40,16 +43,10 @@ export function SharedSchedule() {
const isLiquidContainerMed = (med: SharedScheduleData["medications"][number] | undefined) =>
isLiquidContainerPackageType(med?.packageType);
const convertLiquidUsageToMl = (usage: number, unit: "ml" | "tsp" | "tbsp" | null | undefined): number => {
if (unit === "tsp") return usage * 5;
if (unit === "tbsp") return usage * 15;
return usage;
};
const convertUsageForStock = (
usage: number,
med: SharedScheduleData["medications"][number] | undefined,
unit: "ml" | "tsp" | "tbsp" | null | undefined
unit: IntakeUnit | null | undefined
): number => {
if (isTubePackageType(med?.packageType)) return 0;
if (!isLiquidContainerMed(med)) return usage;
@@ -61,13 +58,7 @@ export function SharedSchedule() {
return String(rounded);
};
const getLiquidCountUnitLabel = (unit: "ml" | "tsp" | "tbsp" | null | undefined, usage: number): string => {
if (unit === "tsp") return t("form.blisters.teaspoons", { count: Math.abs(usage) });
if (unit === "tbsp") return t("form.blisters.tablespoons", { count: Math.abs(usage) });
return t("form.packageAmountUnitMl");
};
const formatLiquidUsageLabel = (usage: number, unit: "ml" | "tsp" | "tbsp" | null | undefined): string => {
const formatLiquidUsageLabel = (usage: number, unit: IntakeUnit | null | undefined): string => {
const normalizedUsage = Number(usage);
if (!Number.isFinite(normalizedUsage) || normalizedUsage <= 0) {
return `0 ${t("form.packageAmountUnitMl")}`;
@@ -78,13 +69,13 @@ export function SharedSchedule() {
}
const mlTotal = convertLiquidUsageToMl(normalizedUsage, unit);
return `${formatAmount(normalizedUsage)} ${getLiquidCountUnitLabel(unit, normalizedUsage)} ${formatAmount(mlTotal)} ${t("form.packageAmountUnitMl")}`;
return `${formatAmount(normalizedUsage)} ${getLiquidCountUnitLabel(unit, normalizedUsage, t)} ${formatAmount(mlTotal)} ${t("form.packageAmountUnitMl")}`;
};
const formatDoseUsageLabel = (
med: SharedScheduleData["medications"][number] | undefined,
usage: number,
intakeUnit?: "ml" | "tsp" | "tbsp" | null
intakeUnit?: IntakeUnit | null
) => {
if (isLiquidContainerMed(med)) {
return formatLiquidUsageLabel(usage, intakeUnit);
@@ -95,7 +86,7 @@ export function SharedSchedule() {
const formatTotalUsageLabel = (
med: SharedScheduleData["medications"][number] | undefined,
total: number,
doses?: Array<{ usage: number; intakeUnit?: "ml" | "tsp" | "tbsp" | null }>
doses?: Array<{ usage: number; intakeUnit?: IntakeUnit | null }>
) => {
if (isLiquidContainerMed(med)) {
if (doses && doses.length > 0) {
@@ -418,7 +409,7 @@ export function SharedSchedule() {
when: number;
medName: string;
usage: number;
intakeUnit?: "ml" | "tsp" | "tbsp" | null;
intakeUnit?: IntakeUnit | null;
timeStr: string;
isPast: boolean;
takenBy: string | null; // Per-intake takenBy (single person or null)
@@ -426,15 +417,7 @@ export function SharedSchedule() {
}[] = [];
for (const med of data.medications) {
// Use intakes (with per-intake takenBy) if available, fallback to blisters (legacy)
const intakes =
med.intakes ||
med.blisters.map((b) => ({
...b,
intakeUnit: null,
takenBy: null as string | null,
intakeRemindersEnabled: false,
}));
const intakes = getMedicationIntakes(med);
intakes.forEach((intake, intakeIdx) => {
// Filter: for person-specific shares, include matching intakes plus shared-for-everyone intakes.
@@ -443,9 +426,7 @@ export function SharedSchedule() {
const startDate = parseLocalDateTime(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() + intake.every)) {
iterateIntakeOccurrences(intake, startDate, end, (d) => {
const t = d.getTime();
const isPast = d < todayStart;
// Use date-only timestamp for stable ID (immune to time changes)
@@ -470,7 +451,7 @@ export function SharedSchedule() {
month: "short",
}),
});
}
});
});
}
@@ -544,20 +525,12 @@ export function SharedSchedule() {
// Uses time-based automatic consumption (same as DashboardPage) for accurate stock levels
const coverageByMed = useMemo(() => {
if (!data) return {};
const MS_PER_DAY = 86_400_000;
const now = Date.now();
const calcMode = data.stockCalculationMode ?? "automatic";
const coverage: Record<string, { daysLeft: number | null; medsLeft: number; dailyUsage: number }> = {};
for (const med of data.medications) {
const intakes =
med.intakes ||
med.blisters.map((b) => ({
...b,
intakeUnit: null,
takenBy: null as string | null,
intakeRemindersEnabled: false,
}));
const intakes = getMedicationIntakes(med);
// Count unique people from all intakes (for per-intake takenBy)
const uniquePeople = new Set<string>();
@@ -571,7 +544,7 @@ export function SharedSchedule() {
let dailyRate = 0;
intakes.forEach((intake) => {
const usageForStock = convertUsageForStock(intake.usage, med, intake.intakeUnit ?? "ml");
const baseRate = intake.every > 0 ? usageForStock / intake.every : 0;
const baseRate = usageForStock * getIntakeDailyRate(intake);
if (intake?.takenBy) {
dailyRate += baseRate; // Per-intake takenBy: 1 person
} else {
@@ -586,18 +559,8 @@ export function SharedSchedule() {
// Time-based: every scheduled dose counts as consumed once its time has passed
intakes.forEach((intake, blisterIdx) => {
const usageForStock = convertUsageForStock(intake.usage, med, intake.intakeUnit ?? "ml");
const blisterStart = parseLocalDateTime(intake.start).getTime();
const period = Math.max(1, intake.every) * MS_PER_DAY;
let effectiveStart: number;
if (stockCorrectionCutoff > 0 && stockCorrectionCutoff >= blisterStart) {
const elapsedSinceStart = stockCorrectionCutoff - blisterStart;
const periodsElapsed = Math.floor(elapsedSinceStart / period);
effectiveStart = blisterStart + (periodsElapsed + 1) * period;
} else {
effectiveStart = blisterStart;
}
if (Number.isNaN(effectiveStart)) return;
const intakeStart = parseLocalDateTime(intake.start);
if (Number.isNaN(intakeStart.getTime())) return;
const intakePerson = intake?.takenBy;
const fallbackPeople = med.takenBy?.length > 0 ? med.takenBy : [null];
@@ -606,16 +569,15 @@ export function SharedSchedule() {
let timeBasedConsumed = 0;
let lastAutoConsumedDateMs = 0;
if (effectiveStart <= now) {
const occurrences = Math.floor((now - effectiveStart) / period) + 1;
timeBasedConsumed = occurrences * usageForStock * peopleForThisIntake.length;
const lastDoseTime = new Date(effectiveStart + (occurrences - 1) * period);
iterateIntakeOccurrences(intake, intakeStart, new Date(now), (occurrence) => {
if (occurrence.getTime() <= stockCorrectionCutoff) return;
timeBasedConsumed += usageForStock * peopleForThisIntake.length;
lastAutoConsumedDateMs = new Date(
lastDoseTime.getFullYear(),
lastDoseTime.getMonth(),
lastDoseTime.getDate()
occurrence.getFullYear(),
occurrence.getMonth(),
occurrence.getDate()
).getTime();
}
});
// Early intakes: future doses already marked as taken
const stockCorrectionDateOnly =
@@ -727,7 +689,7 @@ export function SharedSchedule() {
const renderDoseUsage = (
med: SharedScheduleData["medications"][number] | undefined,
dose: { usage: number; intakeUnit?: "ml" | "tsp" | "tbsp" | null }
dose: { usage: number; intakeUnit?: IntakeUnit | null }
) => formatDoseUsageLabel(med, dose.usage, dose.intakeUnit);
// Helper: check if a dose is "done" (taken, per-dose dismissed, or med-level dismissed)
+10 -24
View File
@@ -5,11 +5,13 @@
import { useTranslation } from "react-i18next";
import { MedicationAvatar } from "../components";
import { useEscapeKey } from "../hooks/useEscapeKey";
import type { Coverage, Medication, StockThresholds } from "../types";
import type { Coverage, IntakeUnit, Medication, StockThresholds } from "../types";
import { getMedDisplayName, getMedTotal, getPackageSize } from "../types";
import { allowsPillFormSelection, isLiquidContainerPackageType, isTubePackageType } from "../types/package-profiles";
import { formatNumber } from "../utils";
import { getSystemLocale } from "../utils/formatters";
import { getIntakeFrequencyText, getMedicationIntakes } from "../utils/intake-schedule";
import { getLiquidCountUnitLabel } from "../utils/intake-units";
import { getStockStatus } from "../utils/schedule";
export interface UserFilterModalProps {
@@ -40,19 +42,9 @@ export function UserFilterModal({
);
};
const getLiquidCountUnitLabel = (unit: "ml" | "tsp" | "tbsp" | null | undefined, usage: number): string => {
if (unit === "tsp") return t("form.blisters.teaspoons", { count: Math.abs(usage) });
if (unit === "tbsp") return t("form.blisters.tablespoons", { count: Math.abs(usage) });
return t("form.packageAmountUnitMl");
};
const formatIntakeUsageLabel = (
med: Medication,
usage: number,
intakeUnit?: "ml" | "tsp" | "tbsp" | null
): string => {
const formatIntakeUsageLabel = (med: Medication, usage: number, intakeUnit?: IntakeUnit | null): string => {
if (isLiquidMedication(med)) {
return `${formatNumber(usage)} ${getLiquidCountUnitLabel(intakeUnit, usage)}`;
return `${formatNumber(usage)} ${getLiquidCountUnitLabel(intakeUnit, usage, t)}`;
}
if (isTubePackageType(med.packageType)) {
return `${formatNumber(usage)} ${t("form.blisters.applications", { count: usage })}`;
@@ -111,14 +103,9 @@ export function UserFilterModal({
const currentStock = medCoverage ? medCoverage.medsLeft : getMedTotal(med);
// Get intakes relevant to this person
const personIntakes = (
med.intakes ||
med.blisters.map((b) => ({
...b,
takenBy: null as string | null,
intakeRemindersEnabled: false,
}))
).filter((intake) => intake.takenBy === null || intake.takenBy === selectedUser);
const personIntakes = getMedicationIntakes(med).filter(
(intake) => intake.takenBy === null || intake.takenBy === selectedUser
);
return (
<div
@@ -146,7 +133,7 @@ export function UserFilterModal({
hour: "2-digit",
minute: "2-digit",
});
const intakeKey = `${intake.start}-${intake.usage}-${intake.every}-${intake.takenBy ?? ""}`;
const intakeKey = `${intake.start}-${intake.usage}-${intake.every}-${intake.scheduleMode ?? "interval"}-${(intake.weekdays ?? []).join("")}-${intake.takenBy ?? ""}`;
const intakeUnit = "intakeUnit" in intake ? intake.intakeUnit : undefined;
return (
<span key={intakeKey} className="user-med-intake-item">
@@ -154,8 +141,7 @@ export function UserFilterModal({
{allowsPillFormSelection(med.packageType) &&
med.pillWeightMg != null &&
` (${intake.usage * med.pillWeightMg} ${med.doseUnit ?? "mg"})`}{" "}
{intake.every === 1 ? t("common.daily") : t("common.everyNDays", { count: intake.every })}{" "}
{t("modal.at")} {timeStr}
{getIntakeFrequencyText(intake, t)} {t("modal.at")} {timeStr}
</span>
);
})}