@@ -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)}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>`;
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
})}
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
normalizePackageType,
|
||||
} from "../types";
|
||||
import { toDateValue, toTimeValue } from "../utils/formatters";
|
||||
import { normalizeWeekdays } from "../utils/intake-schedule";
|
||||
|
||||
export const defaultBlister = (): FormBlister => {
|
||||
const now = new Date();
|
||||
@@ -30,6 +31,8 @@ export const defaultIntake = (takenBy: string = ""): FormIntake => {
|
||||
every: "1",
|
||||
startDate: toDateValue(now),
|
||||
startTime: toTimeValue(now),
|
||||
scheduleMode: "interval",
|
||||
weekdays: [],
|
||||
intakeUnit: "ml",
|
||||
takenBy, // Per-intake user assignment (empty string = null/everyone)
|
||||
intakeRemindersEnabled: false,
|
||||
@@ -93,7 +96,7 @@ export interface UseMedicationFormReturn {
|
||||
addBlister: () => void;
|
||||
removeBlister: (idx: number) => void;
|
||||
// Intake management with per-intake takenBy
|
||||
setIntakeValue: (idx: number, field: keyof FormIntake, value: string | boolean) => void;
|
||||
setIntakeValue: <K extends keyof FormIntake>(idx: number, field: K, value: FormIntake[K]) => void;
|
||||
addIntake: (takenBy?: string) => void;
|
||||
removeIntake: (idx: number) => void;
|
||||
startEdit: (med: Medication, openEditModal: () => void) => void;
|
||||
@@ -189,7 +192,7 @@ export function useMedicationForm(): UseMedicationFormReturn {
|
||||
}, []);
|
||||
|
||||
// Intake management with per-intake takenBy
|
||||
const setIntakeValue = useCallback((idx: number, field: keyof FormIntake, value: string | boolean) => {
|
||||
const setIntakeValue = useCallback(<K extends keyof FormIntake>(idx: number, field: K, value: FormIntake[K]) => {
|
||||
setForm((prev) => {
|
||||
const next = [...prev.intakes];
|
||||
next[idx] = { ...next[idx], [field]: value };
|
||||
@@ -219,6 +222,8 @@ export function useMedicationForm(): UseMedicationFormReturn {
|
||||
every: String(i.every),
|
||||
startDate: toDateValue(i.start),
|
||||
startTime: toTimeValue(i.start),
|
||||
scheduleMode: (i.scheduleMode === "weekdays" ? "weekdays" : "interval") as FormIntake["scheduleMode"],
|
||||
weekdays: normalizeWeekdays(i.weekdays),
|
||||
intakeUnit: (i.intakeUnit ?? "ml") as FormIntake["intakeUnit"],
|
||||
takenBy: i.takenBy ?? "", // Convert null to empty string for form
|
||||
intakeRemindersEnabled: i.intakeRemindersEnabled,
|
||||
@@ -228,6 +233,8 @@ export function useMedicationForm(): UseMedicationFormReturn {
|
||||
every: String(s.every),
|
||||
startDate: toDateValue(s.start),
|
||||
startTime: toTimeValue(s.start),
|
||||
scheduleMode: "interval" as const,
|
||||
weekdays: [],
|
||||
intakeUnit: "ml" as const,
|
||||
takenBy: "", // Legacy blisters have no per-intake takenBy
|
||||
intakeRemindersEnabled: med.intakeRemindersEnabled ?? false,
|
||||
|
||||
@@ -257,8 +257,31 @@
|
||||
"applications": "Anwendungen",
|
||||
"applications_one": "Anwendung",
|
||||
"applications_other": "Anwendungen",
|
||||
"scheduleMode": "Planmodus",
|
||||
"scheduleModeInterval": "Alle X Tage",
|
||||
"scheduleModeWeekdays": "Bestimmte Wochentage",
|
||||
"everyDays": "Alle (Tage)",
|
||||
"every": "alle",
|
||||
"weekdays": "Wochentage",
|
||||
"weekdaysRequired": "Waehle mindestens einen Wochentag aus",
|
||||
"weekdaysShort": {
|
||||
"mon": "Mo",
|
||||
"tue": "Di",
|
||||
"wed": "Mi",
|
||||
"thu": "Do",
|
||||
"fri": "Fr",
|
||||
"sat": "Sa",
|
||||
"sun": "So"
|
||||
},
|
||||
"weekdaysLong": {
|
||||
"mon": "Montag",
|
||||
"tue": "Dienstag",
|
||||
"wed": "Mittwoch",
|
||||
"thu": "Donnerstag",
|
||||
"fri": "Freitag",
|
||||
"sat": "Samstag",
|
||||
"sun": "Sonntag"
|
||||
},
|
||||
"from": "ab",
|
||||
"startDate": "Datum",
|
||||
"startTime": "Uhrzeit",
|
||||
|
||||
@@ -257,8 +257,31 @@
|
||||
"applications": "applications",
|
||||
"applications_one": "application",
|
||||
"applications_other": "applications",
|
||||
"scheduleMode": "Schedule mode",
|
||||
"scheduleModeInterval": "Every X days",
|
||||
"scheduleModeWeekdays": "Specific weekdays",
|
||||
"everyDays": "Every (days)",
|
||||
"every": "every",
|
||||
"weekdays": "Weekdays",
|
||||
"weekdaysRequired": "Select at least one weekday",
|
||||
"weekdaysShort": {
|
||||
"mon": "Mon",
|
||||
"tue": "Tue",
|
||||
"wed": "Wed",
|
||||
"thu": "Thu",
|
||||
"fri": "Fri",
|
||||
"sat": "Sat",
|
||||
"sun": "Sun"
|
||||
},
|
||||
"weekdaysLong": {
|
||||
"mon": "Monday",
|
||||
"tue": "Tuesday",
|
||||
"wed": "Wednesday",
|
||||
"thu": "Thursday",
|
||||
"fri": "Friday",
|
||||
"sat": "Saturday",
|
||||
"sun": "Sunday"
|
||||
},
|
||||
"from": "from",
|
||||
"startDate": "Date",
|
||||
"startTime": "Time",
|
||||
|
||||
@@ -9,12 +9,15 @@ import {
|
||||
allowsPillFormSelection,
|
||||
type Coverage,
|
||||
getMedDisplayName,
|
||||
type IntakeUnit,
|
||||
isAmountBasedPackageType,
|
||||
isLiquidContainerPackageType,
|
||||
isTubePackageType,
|
||||
} from "../types";
|
||||
import { formatNumber, getExpiryClass, getSystemLocale } from "../utils/formatters";
|
||||
import { expandDoseIds, getStockStatus, isDoseDismissed } from "../utils/schedule";
|
||||
import { getIntakeDailyRate, getMedicationIntakes } from "../utils/intake-schedule";
|
||||
import { convertLiquidUsageToMl, getLiquidCountUnitLabel } from "../utils/intake-units";
|
||||
import { buildClearMissedPayload, expandDoseIds, getStockStatus, isDoseDismissed } from "../utils/schedule";
|
||||
import {
|
||||
formatFullBlisters,
|
||||
formatOpenBlisterAndLoose,
|
||||
@@ -141,41 +144,8 @@ export function DashboardPage() {
|
||||
|
||||
const prescriptionEmptyCount = prescriptionLowMeds.filter((med) => med.remainingRefills <= 0).length;
|
||||
|
||||
const getClearMissedPayload = () => {
|
||||
const medicationIds = new Set<number>();
|
||||
let latestMissedDate: string | null = null;
|
||||
|
||||
for (const day of pastDays) {
|
||||
for (const item of day.meds) {
|
||||
const med = meds.find((candidate) => getMedDisplayName(candidate) === item.medName);
|
||||
if (!med) continue;
|
||||
|
||||
const dismissedUntilDate = med.dismissedUntil ?? undefined;
|
||||
const hasMissedDose = item.doses.some((dose) => {
|
||||
if (isDoseDismissed(dose.id, dismissedUntilDate)) return false;
|
||||
const takenByArray = Array.isArray(dose.takenBy) ? dose.takenBy : [];
|
||||
const ids = takenByArray.length > 0 ? takenByArray.map((person) => `${dose.id}-${person}`) : [dose.id];
|
||||
return ids.some((doseId) => !isDoseTakenForDisplay(doseId) && !dismissedDoses.has(doseId));
|
||||
});
|
||||
|
||||
if (!hasMissedDose) continue;
|
||||
|
||||
medicationIds.add(med.id);
|
||||
const dayDate = day.date.toISOString().slice(0, 10);
|
||||
if (!latestMissedDate || dayDate > latestMissedDate) {
|
||||
latestMissedDate = dayDate;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
medicationIds: [...medicationIds],
|
||||
until: latestMissedDate,
|
||||
};
|
||||
};
|
||||
|
||||
const clearMissedDoses = async (missedCount: number) => {
|
||||
const payload = getClearMissedPayload();
|
||||
const payload = buildClearMissedPayload(pastDays, meds, takenDoses, dismissedDoses);
|
||||
if (payload.medicationIds.length === 0 || !payload.until) {
|
||||
setShowClearMissedConfirm(false);
|
||||
return;
|
||||
@@ -245,19 +215,7 @@ export function DashboardPage() {
|
||||
return t("table.pillsCount", { count: Math.round(medsLeft) });
|
||||
};
|
||||
|
||||
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 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")}`;
|
||||
@@ -268,13 +226,13 @@ export function DashboardPage() {
|
||||
}
|
||||
|
||||
const mlTotal = convertLiquidUsageToMl(normalizedUsage, unit);
|
||||
return `${formatNumber(normalizedUsage)} ${getLiquidCountUnitLabel(unit, normalizedUsage)} ${formatNumber(mlTotal)} ${t("form.packageAmountUnitMl")}`;
|
||||
return `${formatNumber(normalizedUsage)} ${getLiquidCountUnitLabel(unit, normalizedUsage, t)} ${formatNumber(mlTotal)} ${t("form.packageAmountUnitMl")}`;
|
||||
};
|
||||
|
||||
const formatDoseUsageLabel = (
|
||||
med: (typeof meds)[number] | undefined,
|
||||
usage: number,
|
||||
intakeUnit?: "ml" | "tsp" | "tbsp" | null
|
||||
intakeUnit?: IntakeUnit | null
|
||||
) => {
|
||||
if (isLiquidContainerPackageType(med?.packageType)) {
|
||||
return formatLiquidUsageLabel(usage, intakeUnit);
|
||||
@@ -288,8 +246,8 @@ export function DashboardPage() {
|
||||
const formatTotalUsageLabel = (
|
||||
med: (typeof meds)[number] | undefined,
|
||||
total: number,
|
||||
intakeUnit?: "ml" | "tsp" | "tbsp" | null,
|
||||
doses?: Array<{ usage: number; intakeUnit?: "ml" | "tsp" | "tbsp" | null }>
|
||||
intakeUnit?: IntakeUnit | null,
|
||||
doses?: Array<{ usage: number; intakeUnit?: IntakeUnit | null }>
|
||||
) => {
|
||||
if (isLiquidContainerPackageType(med?.packageType)) {
|
||||
if (doses && doses.length > 0) {
|
||||
@@ -322,27 +280,18 @@ export function DashboardPage() {
|
||||
const formatDailyConsumption = (med: (typeof meds)[number] | undefined) => {
|
||||
if (!med) return "-";
|
||||
|
||||
const intakes =
|
||||
med.intakes && med.intakes.length > 0
|
||||
? med.intakes
|
||||
: med.blisters.map((blister) => ({
|
||||
usage: blister.usage,
|
||||
every: blister.every,
|
||||
intakeUnit: null as "ml" | "tsp" | "tbsp" | null,
|
||||
takenBy: null as string | null,
|
||||
}));
|
||||
const intakes = getMedicationIntakes(med);
|
||||
|
||||
if (intakes.length === 0) return "-";
|
||||
|
||||
let dailyTotal = 0;
|
||||
for (const intake of intakes) {
|
||||
const usage = Number(intake.usage);
|
||||
const every = Math.max(1, Number(intake.every) || 1);
|
||||
if (!Number.isFinite(usage) || usage <= 0) continue;
|
||||
|
||||
const hasPerIntakeTakenBy = typeof intake.takenBy === "string" && intake.takenBy.trim().length > 0;
|
||||
const personMultiplier = hasPerIntakeTakenBy ? 1 : Math.max(1, med.takenBy?.length ?? 0);
|
||||
const normalizedUsage = (usage * personMultiplier) / every;
|
||||
const normalizedUsage = usage * personMultiplier * getIntakeDailyRate(intake);
|
||||
|
||||
if (isLiquidContainerPackageType(med.packageType)) {
|
||||
dailyTotal += convertLiquidUsageToMl(normalizedUsage, intake.intakeUnit ?? "ml");
|
||||
|
||||
@@ -33,6 +33,15 @@ import {
|
||||
} from "../types";
|
||||
import { combineDateAndTime, formatDate, formatDateTime, formatNumber } from "../utils/formatters";
|
||||
import { MAX_IMAGE_UPLOAD_BYTES, resolveImageUploadError } from "../utils/image-upload";
|
||||
import {
|
||||
getIntakeFrequencyText,
|
||||
getIntakeScheduleMode,
|
||||
getMedicationIntakes,
|
||||
getWeekdayLabel,
|
||||
hasSelectedWeekdays,
|
||||
toggleWeekdaySelection,
|
||||
WEEKDAY_CODES,
|
||||
} from "../utils/intake-schedule";
|
||||
import { log } from "../utils/logger";
|
||||
|
||||
function userStorageKey(userId: number | undefined, key: string): string {
|
||||
@@ -311,6 +320,24 @@ export function MedicationsPage() {
|
||||
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]
|
||||
);
|
||||
|
||||
const getMedicationPackageTypeLabel = useCallback(
|
||||
(med: Medication) => {
|
||||
@@ -512,7 +539,7 @@ export function MedicationsPage() {
|
||||
async function saveMedication(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
if (readOnlyView) return;
|
||||
if (hasValidationErrors || dateConsistencyError) {
|
||||
if (hasValidationErrors || dateConsistencyError || hasWeekdayScheduleError) {
|
||||
setShowNameValidation(true);
|
||||
// Scroll to first visible error so the user sees what's wrong
|
||||
const firstError = document.querySelector(".field-error");
|
||||
@@ -534,8 +561,10 @@ export function MedicationsPage() {
|
||||
// Prepare intakes data with per-intake takenBy
|
||||
const intakes = form.intakes.map((intake) => ({
|
||||
usage: Number(intake.usage) || 1,
|
||||
every: Number(intake.every) || 1,
|
||||
every: getIntakeScheduleMode(intake) === "weekdays" ? 1 : Number(intake.every) || 1,
|
||||
start: combineDateAndTime(intake.startDate, intake.startTime),
|
||||
scheduleMode: getIntakeScheduleMode(intake),
|
||||
weekdays: getIntakeScheduleMode(intake) === "weekdays" ? [...(intake.weekdays ?? [])] : [],
|
||||
intakeUnit: isLiquidContainerPackageType(form.packageType) ? intake.intakeUnit : null,
|
||||
takenBy: intake.takenBy.trim() || null, // Empty string becomes null
|
||||
intakeRemindersEnabled: intake.intakeRemindersEnabled,
|
||||
@@ -1050,15 +1079,12 @@ export function MedicationsPage() {
|
||||
</div>
|
||||
</div>
|
||||
<div className="blister-list">
|
||||
{(med.intakes ?? med.blisters).map((s, idx) => (
|
||||
{getMedicationIntakes(med).map((s, idx) => (
|
||||
<div key={`${med.id}-${idx}`} className="blister-row-simple">
|
||||
{s.usage} {getMedicationUsageUnitLabel(med, s.usage)} ·{" "}
|
||||
{s.every === 1 ? t("common.daily") : t("common.everyNDays", { count: s.every })} ·{" "}
|
||||
{s.usage} {getMedicationUsageUnitLabel(med, s.usage)} · {getIntakeFrequencyText(s, t)} ·{" "}
|
||||
{t("form.blisters.from")} {formatDateTime(s.start)}
|
||||
{"takenBy" in s && (s as import("../types").Intake).takenBy && (
|
||||
<span className="blister-taken-by"> · {(s as import("../types").Intake).takenBy}</span>
|
||||
)}
|
||||
{"intakeRemindersEnabled" in s && (s as import("../types").Intake).intakeRemindersEnabled && (
|
||||
{s.takenBy && <span className="blister-taken-by"> · {s.takenBy}</span>}
|
||||
{s.intakeRemindersEnabled && (
|
||||
<span className="blister-reminder-icon" title={t("form.blisters.remindTooltip")}>
|
||||
{" "}
|
||||
<Bell size={12} aria-hidden="true" />
|
||||
@@ -1734,105 +1760,154 @@ export function MedicationsPage() {
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{form.intakes.map((intake, idx) => (
|
||||
<div key={idx} className="blister-row">
|
||||
<div className="blister-inputs">
|
||||
<label>
|
||||
{getUsageLabel(intake.intakeUnit ?? "ml")}
|
||||
<FormNumberStepper
|
||||
value={intake.usage}
|
||||
onChange={(nextValue) => setIntakeValue(idx, "usage", nextValue)}
|
||||
min={allowFractionalIntake ? 0.5 : 1}
|
||||
step={allowFractionalIntake ? 0.5 : 1}
|
||||
allowDecimal={allowFractionalIntake}
|
||||
decrementLabel={decrementValueLabel}
|
||||
incrementLabel={incrementValueLabel}
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
{t("form.blisters.everyDays")}
|
||||
<FormNumberStepper
|
||||
value={intake.every}
|
||||
onChange={(nextValue) => setIntakeValue(idx, "every", nextValue)}
|
||||
min={1}
|
||||
decrementLabel={decrementValueLabel}
|
||||
incrementLabel={incrementValueLabel}
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
{t("form.blisters.startDate")}
|
||||
<DateInput
|
||||
value={intake.startDate}
|
||||
onChange={(e) => setIntakeValue(idx, "startDate", e.target.value)}
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
{t("form.blisters.startTime")}
|
||||
<input
|
||||
type="time"
|
||||
value={intake.startTime}
|
||||
onChange={(e) => setIntakeValue(idx, "startTime", e.target.value)}
|
||||
/>
|
||||
</label>
|
||||
{isLiquidContainerPackageType(form.packageType) && (
|
||||
{form.intakes.map((intake, idx) => {
|
||||
const scheduleMode = getIntakeScheduleMode(intake);
|
||||
const selectedWeekdays = intake.weekdays ?? [];
|
||||
return (
|
||||
<div key={idx} className="blister-row">
|
||||
<div className="blister-inputs">
|
||||
<label>
|
||||
{t("form.blisters.intakeUnit")}
|
||||
{getUsageLabel(intake.intakeUnit ?? "ml")}
|
||||
<FormNumberStepper
|
||||
value={intake.usage}
|
||||
onChange={(nextValue) => setIntakeValue(idx, "usage", nextValue)}
|
||||
min={allowFractionalIntake ? 0.5 : 1}
|
||||
step={allowFractionalIntake ? 0.5 : 1}
|
||||
allowDecimal={allowFractionalIntake}
|
||||
decrementLabel={decrementValueLabel}
|
||||
incrementLabel={incrementValueLabel}
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
{t("form.blisters.scheduleMode")}
|
||||
<select
|
||||
className="select-field"
|
||||
value={intake.intakeUnit}
|
||||
value={scheduleMode}
|
||||
onChange={(e) =>
|
||||
setIntakeValue(idx, "intakeUnit", e.target.value as "ml" | "tsp" | "tbsp")
|
||||
setIntakeValue(idx, "scheduleMode", e.target.value as "interval" | "weekdays")
|
||||
}
|
||||
>
|
||||
<option value="ml">{t("form.blisters.intakeUnitMl")}</option>
|
||||
<option value="tsp">{t("form.blisters.intakeUnitTsp")}</option>
|
||||
<option value="tbsp">{t("form.blisters.intakeUnitTbsp")}</option>
|
||||
<option value="interval">{t("form.blisters.scheduleModeInterval")}</option>
|
||||
<option value="weekdays">{t("form.blisters.scheduleModeWeekdays")}</option>
|
||||
</select>
|
||||
</label>
|
||||
)}
|
||||
{form.takenBy.length === 0 ? null : (
|
||||
<label className="taken-by-field" title={t("form.blisters.takenByTooltip")}>
|
||||
{t("form.blisters.takenByIntake")}
|
||||
<select
|
||||
className="select-field"
|
||||
value={intake.takenBy}
|
||||
onChange={(e) => setIntakeValue(idx, "takenBy", e.target.value)}
|
||||
>
|
||||
{form.takenBy.map((person) => (
|
||||
<option key={person} value={person}>
|
||||
{person}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
)}
|
||||
<div className="remind-toggle-row" title={t("form.blisters.remindTooltip")}>
|
||||
<span className="blister-reminder-icon">
|
||||
<Bell size={14} aria-hidden="true" />
|
||||
</span>
|
||||
<label className="toggle-switch small">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={intake.intakeRemindersEnabled}
|
||||
onChange={(e) => setIntakeValue(idx, "intakeRemindersEnabled", e.target.checked)}
|
||||
{scheduleMode === "interval" ? (
|
||||
<label>
|
||||
{t("form.blisters.everyDays")}
|
||||
<FormNumberStepper
|
||||
value={intake.every}
|
||||
onChange={(nextValue) => setIntakeValue(idx, "every", nextValue)}
|
||||
min={1}
|
||||
decrementLabel={decrementValueLabel}
|
||||
incrementLabel={incrementValueLabel}
|
||||
/>
|
||||
</label>
|
||||
) : (
|
||||
<label className="taken-by-field">
|
||||
{t("form.blisters.weekdays")}
|
||||
<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={() =>
|
||||
setIntakeValue(
|
||||
idx,
|
||||
"weekdays",
|
||||
toggleWeekdaySelection(selectedWeekdays, weekday.value)
|
||||
)
|
||||
}
|
||||
>
|
||||
{weekday.shortLabel}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
{!readOnlyView && hasWeekdaySelectionError(intake) && (
|
||||
<span className="field-error">{t("form.blisters.weekdaysRequired")}</span>
|
||||
)}
|
||||
</label>
|
||||
)}
|
||||
<label>
|
||||
{t("form.blisters.startDate")}
|
||||
<DateInput
|
||||
value={intake.startDate}
|
||||
onChange={(e) => setIntakeValue(idx, "startDate", e.target.value)}
|
||||
/>
|
||||
<span className="toggle-slider"></span>
|
||||
</label>
|
||||
<label>
|
||||
{t("form.blisters.startTime")}
|
||||
<input
|
||||
type="time"
|
||||
value={intake.startTime}
|
||||
onChange={(e) => setIntakeValue(idx, "startTime", e.target.value)}
|
||||
/>
|
||||
</label>
|
||||
{isLiquidContainerPackageType(form.packageType) && (
|
||||
<label>
|
||||
{t("form.blisters.intakeUnit")}
|
||||
<select
|
||||
className="select-field"
|
||||
value={intake.intakeUnit}
|
||||
onChange={(e) =>
|
||||
setIntakeValue(idx, "intakeUnit", e.target.value as "ml" | "tsp" | "tbsp")
|
||||
}
|
||||
>
|
||||
<option value="ml">{t("form.blisters.intakeUnitMl")}</option>
|
||||
<option value="tsp">{t("form.blisters.intakeUnitTsp")}</option>
|
||||
<option value="tbsp">{t("form.blisters.intakeUnitTbsp")}</option>
|
||||
</select>
|
||||
</label>
|
||||
)}
|
||||
{form.takenBy.length === 0 ? null : (
|
||||
<label className="taken-by-field" title={t("form.blisters.takenByTooltip")}>
|
||||
{t("form.blisters.takenByIntake")}
|
||||
<select
|
||||
className="select-field"
|
||||
value={intake.takenBy}
|
||||
onChange={(e) => setIntakeValue(idx, "takenBy", e.target.value)}
|
||||
>
|
||||
{form.takenBy.map((person) => (
|
||||
<option key={person} value={person}>
|
||||
{person}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
)}
|
||||
<div className="remind-toggle-row" title={t("form.blisters.remindTooltip")}>
|
||||
<span className="blister-reminder-icon">
|
||||
<Bell size={14} aria-hidden="true" />
|
||||
</span>
|
||||
<label className="toggle-switch small">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={intake.intakeRemindersEnabled}
|
||||
onChange={(e) => setIntakeValue(idx, "intakeRemindersEnabled", e.target.checked)}
|
||||
/>
|
||||
<span className="toggle-slider"></span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
{!readOnlyView && form.intakes.length > 1 && (
|
||||
<button
|
||||
type="button"
|
||||
className="danger icon-only tooltip-trigger"
|
||||
onClick={() => removeIntake(idx)}
|
||||
aria-label={t("common.remove")}
|
||||
data-tooltip={t("common.remove")}
|
||||
>
|
||||
<Minus size={18} aria-hidden="true" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{!readOnlyView && form.intakes.length > 1 && (
|
||||
<button
|
||||
type="button"
|
||||
className="danger icon-only tooltip-trigger"
|
||||
onClick={() => removeIntake(idx)}
|
||||
aria-label={t("common.remove")}
|
||||
data-tooltip={t("common.remove")}
|
||||
>
|
||||
<Minus size={18} aria-hidden="true" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
{/* end schedule tab */}
|
||||
@@ -1845,7 +1920,9 @@ export function MedicationsPage() {
|
||||
<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>
|
||||
|
||||
@@ -5,10 +5,11 @@ 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 type { Coverage, IntakeUnit } from "../types";
|
||||
import { getMedDisplayName, isLiquidContainerPackageType, isTubePackageType } from "../types";
|
||||
import { formatNumber } from "../utils/formatters";
|
||||
import { isDoseDismissed } from "../utils/schedule";
|
||||
import { convertLiquidUsageToMl, getLiquidCountUnitLabel } from "../utils/intake-units";
|
||||
import { buildClearMissedPayload, isDoseDismissed } from "../utils/schedule";
|
||||
|
||||
// Helper for user-specific localStorage keys
|
||||
function userStorageKey(userId: number | undefined, key: string): string {
|
||||
@@ -105,41 +106,8 @@ export function SchedulePage() {
|
||||
status: { className: string; label: string } | null
|
||||
) => isTubePackageType(med?.packageType) && status?.label === "status.noSchedule";
|
||||
|
||||
const getClearMissedPayload = () => {
|
||||
const medicationIds = new Set<number>();
|
||||
let latestMissedDate: string | null = null;
|
||||
|
||||
for (const day of pastDays) {
|
||||
for (const item of day.meds) {
|
||||
const med = meds.find((candidate) => getMedDisplayName(candidate) === item.medName);
|
||||
if (!med) continue;
|
||||
|
||||
const dismissedUntilDate = med.dismissedUntil ?? undefined;
|
||||
const hasMissedDose = item.doses.some((dose) => {
|
||||
if (isDoseDismissed(dose.id, dismissedUntilDate)) return false;
|
||||
const takenByArray = Array.isArray(dose.takenBy) ? dose.takenBy : [];
|
||||
const ids = takenByArray.length > 0 ? takenByArray.map((person) => `${dose.id}-${person}`) : [dose.id];
|
||||
return ids.some((doseId) => !isDoseTakenForDisplay(doseId) && !dismissedDoses.has(doseId));
|
||||
});
|
||||
|
||||
if (!hasMissedDose) continue;
|
||||
|
||||
medicationIds.add(med.id);
|
||||
const dayDate = day.date.toISOString().slice(0, 10);
|
||||
if (!latestMissedDate || dayDate > latestMissedDate) {
|
||||
latestMissedDate = dayDate;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
medicationIds: [...medicationIds],
|
||||
until: latestMissedDate,
|
||||
};
|
||||
};
|
||||
|
||||
const clearMissedDoses = async (missedCount: number) => {
|
||||
const payload = getClearMissedPayload();
|
||||
const payload = buildClearMissedPayload(pastDays, meds, takenDoses, dismissedDoses);
|
||||
if (payload.medicationIds.length === 0 || !payload.until) {
|
||||
setShowClearMissedConfirm(false);
|
||||
return;
|
||||
@@ -197,19 +165,7 @@ export function SchedulePage() {
|
||||
? t("form.packageAmountUnitMl")
|
||||
: t("form.blisters.applications", { count: Math.abs(value) });
|
||||
|
||||
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 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")}`;
|
||||
@@ -220,13 +176,13 @@ export function SchedulePage() {
|
||||
}
|
||||
|
||||
const mlTotal = convertLiquidUsageToMl(normalizedUsage, unit);
|
||||
return `${formatNumber(normalizedUsage)} ${getLiquidCountUnitLabel(unit, normalizedUsage)} ${formatNumber(mlTotal)} ${t("form.packageAmountUnitMl")}`;
|
||||
return `${formatNumber(normalizedUsage)} ${getLiquidCountUnitLabel(unit, normalizedUsage, t)} ${formatNumber(mlTotal)} ${t("form.packageAmountUnitMl")}`;
|
||||
};
|
||||
|
||||
const formatDoseUsageLabel = (
|
||||
med: (typeof meds)[number] | undefined,
|
||||
usage: number,
|
||||
intakeUnit?: "ml" | "tsp" | "tbsp" | null
|
||||
intakeUnit?: IntakeUnit | null
|
||||
) => {
|
||||
if (isLiquidContainerPackageType(med?.packageType)) {
|
||||
return formatLiquidUsageLabel(usage, intakeUnit);
|
||||
@@ -240,7 +196,7 @@ export function SchedulePage() {
|
||||
const formatTotalUsageLabel = (
|
||||
med: (typeof meds)[number] | undefined,
|
||||
total: number,
|
||||
doses?: Array<{ usage: number; intakeUnit?: "ml" | "tsp" | "tbsp" | null }>
|
||||
doses?: Array<{ usage: number; intakeUnit?: IntakeUnit | null }>
|
||||
) => {
|
||||
if (isLiquidContainerPackageType(med?.packageType)) {
|
||||
if (doses && doses.length > 0) {
|
||||
|
||||
@@ -2,7 +2,7 @@ import { fireEvent, render, screen } from "@testing-library/react";
|
||||
import type { FormEvent } from "react";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { MobileEditModal } from "../../components/MobileEditModal";
|
||||
import type { FormState } from "../../types";
|
||||
import type { FormState, WeekdayCode } from "../../types";
|
||||
|
||||
const defaultForm: FormState = {
|
||||
name: "",
|
||||
@@ -429,6 +429,61 @@ describe("MobileEditModal blister management", () => {
|
||||
expect(onSetIntakeValue).toHaveBeenCalled();
|
||||
}
|
||||
});
|
||||
|
||||
it("shows weekday controls and validation error for weekday schedules", () => {
|
||||
const form = {
|
||||
...defaultForm,
|
||||
name: "Weekday Med",
|
||||
intakes: [
|
||||
{
|
||||
usage: "1",
|
||||
every: "1",
|
||||
startDate: "2024-01-01",
|
||||
startTime: "09:00",
|
||||
scheduleMode: "weekdays" as const,
|
||||
weekdays: [],
|
||||
takenBy: "",
|
||||
intakeRemindersEnabled: false,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
render(<MobileEditModal {...defaultProps} form={form} formChanged={true} />);
|
||||
|
||||
fireEvent.click(screen.getByRole("tab", { name: "form.sections.schedule" }));
|
||||
|
||||
expect(screen.getByText("form.blisters.weekdaysRequired")).toBeInTheDocument();
|
||||
expect(screen.getByText("form.blisters.weekdays")).toBeInTheDocument();
|
||||
expect(screen.queryByLabelText("form.blisters.everyDays")).not.toBeInTheDocument();
|
||||
expect(document.querySelector('button[type="submit"]')).toHaveClass("has-validation-error");
|
||||
});
|
||||
|
||||
it("toggles weekday selections for weekday schedules", () => {
|
||||
const onSetIntakeValue = vi.fn();
|
||||
const form = {
|
||||
...defaultForm,
|
||||
name: "Weekday Med",
|
||||
intakes: [
|
||||
{
|
||||
usage: "1",
|
||||
every: "1",
|
||||
startDate: "2024-01-01",
|
||||
startTime: "09:00",
|
||||
scheduleMode: "weekdays" as const,
|
||||
weekdays: ["wed"] satisfies WeekdayCode[],
|
||||
takenBy: "",
|
||||
intakeRemindersEnabled: false,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
render(<MobileEditModal {...defaultProps} form={form} onSetIntakeValue={onSetIntakeValue} />);
|
||||
|
||||
fireEvent.click(screen.getByRole("tab", { name: "form.sections.schedule" }));
|
||||
fireEvent.click(screen.getByTitle("form.blisters.weekdaysLong.mon"));
|
||||
|
||||
expect(onSetIntakeValue).toHaveBeenCalledWith(0, "weekdays", ["mon", "wed"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("MobileEditModal form submission", () => {
|
||||
|
||||
@@ -2,6 +2,7 @@ import { fireEvent, render, screen, waitFor } from "@testing-library/react";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import ReportModal from "../../components/ReportModal";
|
||||
import type { Medication } from "../../types";
|
||||
import { formatDate, formatDateTime } from "../../utils/formatters";
|
||||
|
||||
function createMedication(overrides: Partial<Medication> = {}): Medication {
|
||||
return {
|
||||
@@ -65,6 +66,53 @@ describe("ReportModal", () => {
|
||||
expect(URL.createObjectURL).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("renders shared formatter output in exported text reports", async () => {
|
||||
const onClose = vi.fn();
|
||||
(global.fetch as ReturnType<typeof vi.fn>).mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
1: {
|
||||
dosesTaken: 1,
|
||||
automaticDosesTaken: 0,
|
||||
dosesDismissed: 0,
|
||||
firstDoseAt: "2026-02-03T12:00:00.000Z",
|
||||
lastDoseAt: null,
|
||||
refills: [],
|
||||
},
|
||||
}),
|
||||
});
|
||||
|
||||
render(
|
||||
<ReportModal
|
||||
isOpen={true}
|
||||
onClose={onClose}
|
||||
medications={[
|
||||
createMedication({
|
||||
medicationStartDate: "2026-02-01",
|
||||
blisters: [{ usage: 1, every: 1, start: "2026-02-02T08:30:00.000Z" }],
|
||||
}),
|
||||
]}
|
||||
/>
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByRole("radio", { name: /report\.formatTxt/i }));
|
||||
fireEvent.click(screen.getByRole("button", { name: /report\.generate/i }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(URL.createObjectURL).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
const [blob] = (URL.createObjectURL as ReturnType<typeof vi.fn>).mock.calls.at(-1) ?? [];
|
||||
expect(blob).toBeInstanceOf(Blob);
|
||||
|
||||
const content = await (blob as Blob).text();
|
||||
|
||||
expect(content).toContain(formatDate("2026-02-01"));
|
||||
expect(content).toContain(formatDateTime("2026-02-02T08:30:00.000Z"));
|
||||
expect(content).toContain(formatDate("2026-02-03T12:00:00.000Z"));
|
||||
expect(onClose).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("generates printable report when PDF format is selected", async () => {
|
||||
const onClose = vi.fn();
|
||||
const mockWrite = vi.fn();
|
||||
@@ -83,16 +131,35 @@ describe("ReportModal", () => {
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
1: {
|
||||
dosesTaken: 0,
|
||||
dosesTaken: 1,
|
||||
automaticDosesTaken: 0,
|
||||
dosesDismissed: 0,
|
||||
firstDoseAt: null,
|
||||
firstDoseAt: "2026-03-03T12:00:00.000Z",
|
||||
lastDoseAt: null,
|
||||
refills: [],
|
||||
refills: [
|
||||
{
|
||||
packsAdded: 1,
|
||||
loosePillsAdded: 0,
|
||||
usedPrescription: false,
|
||||
refillDate: "2026-03-04",
|
||||
},
|
||||
],
|
||||
},
|
||||
}),
|
||||
});
|
||||
|
||||
render(<ReportModal isOpen={true} onClose={onClose} medications={[createMedication()]} />);
|
||||
render(
|
||||
<ReportModal
|
||||
isOpen={true}
|
||||
onClose={onClose}
|
||||
medications={[
|
||||
createMedication({
|
||||
medicationStartDate: "2026-03-01",
|
||||
blisters: [{ usage: 1, every: 1, start: "2026-03-02T08:30:00.000Z" }],
|
||||
}),
|
||||
]}
|
||||
/>
|
||||
);
|
||||
fireEvent.click(screen.getByRole("button", { name: /report\.generate/i }));
|
||||
|
||||
await waitFor(() => {
|
||||
@@ -101,6 +168,11 @@ describe("ReportModal", () => {
|
||||
expect(mockClose).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
const [html] = mockWrite.mock.calls.at(-1) ?? [];
|
||||
expect(html).toContain(formatDate("2026-03-01"));
|
||||
expect(html).toContain(formatDateTime("2026-03-02T08:30:00.000Z"));
|
||||
expect(html).toContain(formatDate("2026-03-03T12:00:00.000Z"));
|
||||
expect(html).toContain(formatDate("2026-03-04"));
|
||||
expect(onClose).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
|
||||
@@ -253,6 +253,67 @@ describe("MedicationsPage", () => {
|
||||
expect(scheduleTab).toHaveAttribute("aria-selected", "true");
|
||||
});
|
||||
|
||||
it("shows weekday controls and validation error in the desktop schedule form", () => {
|
||||
mockFormHookValue = createMockFormHook({
|
||||
formChanged: true,
|
||||
form: {
|
||||
...createMockFormHook().form,
|
||||
name: "Weekday Med",
|
||||
intakes: [
|
||||
{
|
||||
usage: "1",
|
||||
every: "1",
|
||||
startDate: "2024-01-01",
|
||||
startTime: "09:00",
|
||||
scheduleMode: "weekdays" as const,
|
||||
weekdays: [],
|
||||
takenBy: "",
|
||||
intakeRemindersEnabled: false,
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
renderPage();
|
||||
openNewMedicationForm();
|
||||
fireEvent.click(screen.getByRole("tab", { name: "form.sections.schedule" }));
|
||||
|
||||
expect(screen.getByText("form.blisters.weekdaysRequired")).toBeInTheDocument();
|
||||
expect(screen.getByText("form.blisters.weekdays")).toBeInTheDocument();
|
||||
expect(screen.queryByLabelText("form.blisters.everyDays")).not.toBeInTheDocument();
|
||||
expect(document.querySelector('button[type="submit"]')).toHaveClass("has-validation-error");
|
||||
});
|
||||
|
||||
it("toggles weekday selections in the desktop schedule form", () => {
|
||||
const setIntakeValue = vi.fn();
|
||||
mockFormHookValue = createMockFormHook({
|
||||
setIntakeValue,
|
||||
form: {
|
||||
...createMockFormHook().form,
|
||||
name: "Weekday Med",
|
||||
intakes: [
|
||||
{
|
||||
usage: "1",
|
||||
every: "1",
|
||||
startDate: "2024-01-01",
|
||||
startTime: "09:00",
|
||||
scheduleMode: "weekdays" as const,
|
||||
weekdays: ["wed"] as const,
|
||||
takenBy: "",
|
||||
intakeRemindersEnabled: false,
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
renderPage();
|
||||
openNewMedicationForm();
|
||||
fireEvent.click(screen.getByRole("tab", { name: "form.sections.schedule" }));
|
||||
fireEvent.click(screen.getByTitle("form.blisters.weekdaysLong.mon"));
|
||||
|
||||
expect(setIntakeValue).toHaveBeenCalledWith(0, "weekdays", ["mon", "wed"]);
|
||||
});
|
||||
|
||||
it("opens report modal from list actions", () => {
|
||||
renderPage();
|
||||
fireEvent.click(screen.getByText("report.button"));
|
||||
|
||||
@@ -151,4 +151,54 @@ describe("generateICS", () => {
|
||||
expect(() => generateICS(dailyMed)).not.toThrow();
|
||||
expect(() => generateICS(weeklyMed)).not.toThrow();
|
||||
});
|
||||
|
||||
it("exports weekday schedules with a weekly BYDAY rule", async () => {
|
||||
const med = createTestMed({
|
||||
intakes: [
|
||||
{
|
||||
usage: 1,
|
||||
every: 1,
|
||||
start: "2024-03-18T09:00:00",
|
||||
scheduleMode: "weekdays",
|
||||
weekdays: ["mon", "wed", "fri"],
|
||||
intakeUnit: null,
|
||||
takenBy: null,
|
||||
intakeRemindersEnabled: false,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
generateICS(med);
|
||||
|
||||
const blobArg = mockCreateObjectURL.mock.calls[0][0] as Blob;
|
||||
const content = await blobArg.text();
|
||||
|
||||
expect(content).toContain("RRULE:FREQ=WEEKLY;BYDAY=MO,WE,FR");
|
||||
expect(content).not.toContain("RRULE:FREQ=DAILY;INTERVAL=1");
|
||||
});
|
||||
|
||||
it("keeps interval schedules exported as daily interval rules", async () => {
|
||||
const med = createTestMed({
|
||||
intakes: [
|
||||
{
|
||||
usage: 1,
|
||||
every: 2,
|
||||
start: "2024-03-15T09:00:00",
|
||||
scheduleMode: "interval",
|
||||
weekdays: [],
|
||||
intakeUnit: null,
|
||||
takenBy: null,
|
||||
intakeRemindersEnabled: false,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
generateICS(med);
|
||||
|
||||
const blobArg = mockCreateObjectURL.mock.calls[0][0] as Blob;
|
||||
const content = await blobArg.text();
|
||||
|
||||
expect(content).toContain("RRULE:FREQ=DAILY;INTERVAL=2");
|
||||
expect(content).not.toContain("BYDAY=");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { convertLiquidUsageToMl, getLiquidCountUnitLabel } from "../../utils/intake-units";
|
||||
|
||||
describe("intake-units", () => {
|
||||
it("keeps ml unchanged and converts teaspoon and tablespoon usage to ml", () => {
|
||||
expect(convertLiquidUsageToMl(12, "ml")).toBe(12);
|
||||
expect(convertLiquidUsageToMl(2, "tsp")).toBe(10);
|
||||
expect(convertLiquidUsageToMl(3, "tbsp")).toBe(45);
|
||||
});
|
||||
|
||||
it("returns the existing liquid usage labels for each intake unit", () => {
|
||||
const t = vi.fn((key: string) => key);
|
||||
|
||||
expect(getLiquidCountUnitLabel("ml", 2, t)).toBe("form.packageAmountUnitMl");
|
||||
expect(getLiquidCountUnitLabel("tsp", 2, t)).toBe("form.blisters.teaspoons");
|
||||
expect(getLiquidCountUnitLabel("tbsp", 3, t)).toBe("form.blisters.tablespoons");
|
||||
|
||||
expect(t).toHaveBeenNthCalledWith(1, "form.packageAmountUnitMl");
|
||||
expect(t).toHaveBeenNthCalledWith(2, "form.blisters.teaspoons", { count: 2 });
|
||||
expect(t).toHaveBeenNthCalledWith(3, "form.blisters.tablespoons", { count: 3 });
|
||||
});
|
||||
});
|
||||
@@ -1,6 +1,7 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { Coverage, Medication, StockThresholds } from "../../types";
|
||||
import {
|
||||
buildClearMissedPayload,
|
||||
buildSchedulePreview,
|
||||
calculateCoverage,
|
||||
computeMissedPastDoseIds,
|
||||
@@ -278,6 +279,33 @@ describe("buildSchedulePreview", () => {
|
||||
expect(zResult.events.map((event) => event.id)).toEqual(localResult.events.map((event) => event.id));
|
||||
expect(zResult.events.map((event) => event.when)).toEqual(localResult.events.map((event) => event.when));
|
||||
});
|
||||
|
||||
it("falls back legacy blisters to schedule events with a null intake unit", () => {
|
||||
const meds: Medication[] = [
|
||||
{
|
||||
id: 1,
|
||||
name: "Legacy Liquid",
|
||||
packCount: 0,
|
||||
blistersPerPack: 1,
|
||||
pillsPerBlister: 1,
|
||||
totalPills: 120,
|
||||
looseTablets: 120,
|
||||
takenBy: [],
|
||||
packageType: "liquid_container",
|
||||
medicationForm: "liquid",
|
||||
blisters: [{ usage: 2, every: 1, start: "2024-03-15T09:00:00" }],
|
||||
updatedAt: null,
|
||||
},
|
||||
];
|
||||
|
||||
const result = buildSchedulePreview(meds, "en", false);
|
||||
|
||||
expect(result.totalBlisters).toBe(1);
|
||||
expect(result.events[0]).toMatchObject({
|
||||
usage: 2,
|
||||
intakeUnit: null,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("calculateCoverage", () => {
|
||||
@@ -376,6 +404,41 @@ describe("calculateCoverage", () => {
|
||||
expect(result.all[0].daysLeft).toBe(9); // 18 pills / 2 per day = 9 days
|
||||
});
|
||||
|
||||
it("converts liquid intake units to ml for automatic coverage calculations", () => {
|
||||
const meds: Medication[] = [
|
||||
{
|
||||
id: 1,
|
||||
name: "Liquid Med",
|
||||
packCount: 0,
|
||||
blistersPerPack: 1,
|
||||
pillsPerBlister: 1,
|
||||
totalPills: 120,
|
||||
looseTablets: 120,
|
||||
takenBy: [],
|
||||
packageType: "liquid_container",
|
||||
medicationForm: "liquid",
|
||||
blisters: [],
|
||||
intakes: [
|
||||
{
|
||||
usage: 2,
|
||||
every: 1,
|
||||
start: "2024-03-14T09:00:00",
|
||||
intakeUnit: "tbsp",
|
||||
takenBy: null,
|
||||
intakeRemindersEnabled: false,
|
||||
},
|
||||
],
|
||||
updatedAt: null,
|
||||
},
|
||||
];
|
||||
|
||||
const result = calculateCoverage(meds, [], "en", 7, "automatic", new Set());
|
||||
|
||||
expect(result.all).toHaveLength(1);
|
||||
expect(result.all[0].medsLeft).toBe(60);
|
||||
expect(result.all[0].daysLeft).toBe(2);
|
||||
});
|
||||
|
||||
it("per-intake takenBy counts person correctly in automatic mode", () => {
|
||||
// When intakes have per-intake takenBy, each person-intake pair is counted
|
||||
const meds: Medication[] = [
|
||||
@@ -1987,6 +2050,83 @@ describe("dose tracking survives medication edits (regression)", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildClearMissedPayload", () => {
|
||||
it("collects unique missed medication ids and the latest missed day", () => {
|
||||
const march10 = new Date("2024-03-10T09:00:00Z");
|
||||
const march11 = new Date("2024-03-11T09:00:00Z");
|
||||
const aspirinDoseMarch10 = "1-0-1710061200000";
|
||||
const aspirinDoseMarch11 = "1-0-1710147600000";
|
||||
const vitaminDDoseMarch11 = "2-0-1710147600000";
|
||||
const calciumDoseMarch11 = "3-0-1710147600000";
|
||||
|
||||
const pastDays = [
|
||||
{
|
||||
date: march10,
|
||||
meds: [{ medName: "Aspirin", doses: [{ id: aspirinDoseMarch10, takenBy: ["John"] }] }],
|
||||
},
|
||||
{
|
||||
date: march11,
|
||||
meds: [
|
||||
{ medName: "Aspirin", doses: [{ id: aspirinDoseMarch11, takenBy: ["John"] }] },
|
||||
{ medName: "Vitamin D", doses: [{ id: vitaminDDoseMarch11, takenBy: [] }] },
|
||||
{ medName: "Calcium", doses: [{ id: calciumDoseMarch11, takenBy: [] }] },
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
const medications = [
|
||||
{ id: 1, name: "Aspirin", dismissedUntil: null },
|
||||
{ id: 2, name: "Vitamin D", dismissedUntil: null },
|
||||
{ id: 3, name: "Calcium", dismissedUntil: "2024-03-11" },
|
||||
];
|
||||
|
||||
const payload = buildClearMissedPayload(
|
||||
pastDays,
|
||||
medications,
|
||||
new Set<string>(),
|
||||
new Set<string>([`${aspirinDoseMarch11}-John`])
|
||||
);
|
||||
|
||||
expect(payload).toEqual({
|
||||
medicationIds: [1, 2],
|
||||
until: "2024-03-11",
|
||||
});
|
||||
});
|
||||
|
||||
it("returns an empty payload when every remaining missed dose is already resolved", () => {
|
||||
const march10 = new Date("2024-03-10T09:00:00Z");
|
||||
const aspirinDoseMarch10 = "1-0-1710061200000";
|
||||
const vitaminDDoseMarch10 = "2-0-1710061200000";
|
||||
|
||||
const pastDays = [
|
||||
{
|
||||
date: march10,
|
||||
meds: [
|
||||
{ medName: "Aspirin", doses: [{ id: aspirinDoseMarch10, takenBy: ["Alice"] }] },
|
||||
{ medName: "Vitamin D", doses: [{ id: vitaminDDoseMarch10, takenBy: [] }] },
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
const medications = [
|
||||
{ id: 1, name: "Aspirin", dismissedUntil: null },
|
||||
{ id: 2, name: "Vitamin D", dismissedUntil: "2024-03-10" },
|
||||
];
|
||||
|
||||
const payload = buildClearMissedPayload(
|
||||
pastDays,
|
||||
medications,
|
||||
new Set<string>([`${aspirinDoseMarch10}-Alice`]),
|
||||
new Set<string>()
|
||||
);
|
||||
|
||||
expect(payload).toEqual({
|
||||
medicationIds: [],
|
||||
until: null,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// =============================================================================
|
||||
// Test Helpers
|
||||
// =============================================================================
|
||||
@@ -2322,3 +2462,86 @@ describe("past schedule windowing", () => {
|
||||
expect(past180.length).toBeGreaterThan(past90.length);
|
||||
});
|
||||
});
|
||||
|
||||
describe("weekday intake schedules", () => {
|
||||
beforeEach(() => {
|
||||
vi.setSystemTime(new Date("2024-03-18T12:00:00Z"));
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it("builds preview events only on selected weekdays", () => {
|
||||
const meds: Medication[] = [
|
||||
{
|
||||
id: 1,
|
||||
name: "Weekday Med",
|
||||
packCount: 1,
|
||||
blistersPerPack: 1,
|
||||
pillsPerBlister: 30,
|
||||
looseTablets: 0,
|
||||
takenBy: [],
|
||||
packageType: "blister",
|
||||
blisters: [],
|
||||
intakes: [
|
||||
{
|
||||
usage: 1,
|
||||
every: 1,
|
||||
start: "2024-03-18T09:00:00",
|
||||
scheduleMode: "weekdays",
|
||||
weekdays: ["mon", "wed", "fri"],
|
||||
intakeUnit: null,
|
||||
takenBy: null,
|
||||
intakeRemindersEnabled: false,
|
||||
},
|
||||
],
|
||||
updatedAt: null,
|
||||
},
|
||||
];
|
||||
|
||||
const result = buildSchedulePreview(meds, "en", false);
|
||||
const weekdayDateStrings = result.events.slice(0, 3).map((event) => event.dateStr);
|
||||
|
||||
expect(weekdayDateStrings).toEqual(["Mon, Mar 18", "Wed, Mar 20", "Fri, Mar 22"]);
|
||||
expect(result.totalBlisters).toBe(1);
|
||||
});
|
||||
|
||||
it("uses weekday schedules when calculating coverage", () => {
|
||||
const meds: Medication[] = [
|
||||
{
|
||||
id: 1,
|
||||
name: "Weekday Med",
|
||||
packCount: 1,
|
||||
blistersPerPack: 1,
|
||||
pillsPerBlister: 10,
|
||||
looseTablets: 0,
|
||||
takenBy: [],
|
||||
packageType: "blister",
|
||||
blisters: [],
|
||||
intakes: [
|
||||
{
|
||||
usage: 1,
|
||||
every: 1,
|
||||
start: "2024-03-18T09:00:00",
|
||||
scheduleMode: "weekdays",
|
||||
weekdays: ["mon", "wed", "fri"],
|
||||
intakeUnit: null,
|
||||
takenBy: null,
|
||||
intakeRemindersEnabled: false,
|
||||
},
|
||||
],
|
||||
updatedAt: null,
|
||||
},
|
||||
];
|
||||
|
||||
const preview = buildSchedulePreview(meds, "en", false);
|
||||
const coverage = calculateCoverage(meds, preview.events, "en", 7, "automatic", new Set());
|
||||
|
||||
expect(coverage.all[0]).toMatchObject({
|
||||
name: "Weekday Med",
|
||||
medsLeft: 9,
|
||||
daysLeft: 21,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -19,6 +19,8 @@ import { isAmountBasedPackageType } from "./package-profiles";
|
||||
|
||||
// Common medication dose units
|
||||
export type DoseUnit = "mg" | "g" | "mcg" | "ml" | "units";
|
||||
export type ScheduleMode = "interval" | "weekdays";
|
||||
export type WeekdayCode = "mon" | "tue" | "wed" | "thu" | "fri" | "sat" | "sun";
|
||||
|
||||
export type MedicationForm = "tablet" | "capsule" | "topical" | "liquid";
|
||||
export type PillForm = "tablet" | "capsule";
|
||||
@@ -49,6 +51,8 @@ export type Intake = {
|
||||
usage: number;
|
||||
every: number;
|
||||
start: string;
|
||||
scheduleMode?: ScheduleMode | null;
|
||||
weekdays?: WeekdayCode[] | null;
|
||||
intakeUnit?: IntakeUnit | null;
|
||||
takenBy: string | null; // Per-intake user assignment (single person or null)
|
||||
intakeRemindersEnabled: boolean;
|
||||
@@ -131,6 +135,8 @@ export type FormIntake = {
|
||||
every: string;
|
||||
startDate: string;
|
||||
startTime: string;
|
||||
scheduleMode?: ScheduleMode;
|
||||
weekdays?: WeekdayCode[];
|
||||
intakeUnit?: IntakeUnit;
|
||||
takenBy: string; // Single person or empty string (empty = null for everyone)
|
||||
intakeRemindersEnabled: boolean;
|
||||
|
||||
@@ -4,6 +4,13 @@
|
||||
|
||||
import type { Medication } from "../types";
|
||||
import { getMedDisplayName } from "../types";
|
||||
import {
|
||||
getIntakeFrequencyText,
|
||||
getIntakeScheduleMode,
|
||||
getMedicationIntakes,
|
||||
getWeekdayIcsCode,
|
||||
normalizeWeekdays,
|
||||
} from "./intake-schedule";
|
||||
|
||||
/**
|
||||
* Format a Date for ICS format (YYYYMMDDTHHMMSSZ)
|
||||
@@ -20,20 +27,33 @@ function formatICSDate(date: Date): string {
|
||||
*/
|
||||
export function generateICS(med: Medication): void {
|
||||
const displayName = getMedDisplayName(med);
|
||||
const events = med.blisters
|
||||
.map((blister, idx) => {
|
||||
const start = new Date(blister.start);
|
||||
const events = getMedicationIntakes(med)
|
||||
.map((intake, idx) => {
|
||||
const start = new Date(intake.start);
|
||||
const end = new Date(start.getTime() + 15 * 60 * 1000); // 15 min duration
|
||||
const interval = blister.every;
|
||||
const interval = intake.every;
|
||||
|
||||
const pillInfo = `${blister.usage} pill${blister.usage !== 1 ? "s" : ""}${med.pillWeightMg ? ` (${blister.usage * med.pillWeightMg} mg)` : ""}`;
|
||||
const pillInfo = `${intake.usage} pill${intake.usage !== 1 ? "s" : ""}${med.pillWeightMg ? ` (${intake.usage * med.pillWeightMg} mg)` : ""}`;
|
||||
const summary = `💊 ${displayName} - ${pillInfo}`;
|
||||
const weekdayCodes = normalizeWeekdays(intake.weekdays);
|
||||
const frequencyText =
|
||||
getIntakeScheduleMode(intake) === "weekdays"
|
||||
? weekdayCodes.map(getWeekdayIcsCode).join(", ")
|
||||
: getIntakeFrequencyText(intake, (key, options) => {
|
||||
if (key === "common.daily") return "daily";
|
||||
if (key === "common.everyNDays") return `every ${options?.count ?? interval} days`;
|
||||
return key;
|
||||
});
|
||||
const rrule =
|
||||
getIntakeScheduleMode(intake) === "weekdays" && weekdayCodes.length > 0
|
||||
? `RRULE:FREQ=WEEKLY;BYDAY=${weekdayCodes.map(getWeekdayIcsCode).join(",")}`
|
||||
: `RRULE:FREQ=DAILY;INTERVAL=${interval}`;
|
||||
const description = [
|
||||
`Medication: ${displayName}`,
|
||||
med.genericName ? `Generic: ${med.genericName}` : "",
|
||||
med.takenBy && med.takenBy.length > 0 ? `For: ${med.takenBy.join(", ")}` : "",
|
||||
`Dosage: ${pillInfo}`,
|
||||
`Frequency: every ${interval} day${interval !== 1 ? "s" : ""}`,
|
||||
`Frequency: ${frequencyText}`,
|
||||
med.notes ? `Notes: ${med.notes}` : "",
|
||||
]
|
||||
.filter(Boolean)
|
||||
@@ -44,7 +64,7 @@ UID:medassist-ng-${med.id}-${idx}@medassist-ng
|
||||
DTSTAMP:${formatICSDate(new Date())}
|
||||
DTSTART:${formatICSDate(start)}
|
||||
DTEND:${formatICSDate(end)}
|
||||
RRULE:FREQ=DAILY;INTERVAL=${interval}
|
||||
${rrule}
|
||||
SUMMARY:${summary}
|
||||
DESCRIPTION:${description}
|
||||
BEGIN:VALARM
|
||||
|
||||
@@ -0,0 +1,139 @@
|
||||
import type { Blister, Intake, ScheduleMode, WeekdayCode } from "../types";
|
||||
|
||||
type MedicationScheduleSource = {
|
||||
intakes?: Intake[] | null;
|
||||
blisters: Blister[];
|
||||
intakeRemindersEnabled?: boolean;
|
||||
};
|
||||
|
||||
type IntakeScheduleLike = {
|
||||
every?: number | string | null;
|
||||
scheduleMode?: ScheduleMode | null;
|
||||
weekdays?: ReadonlyArray<WeekdayCode> | null;
|
||||
};
|
||||
|
||||
type Translate = (key: string, options?: Record<string, unknown>) => string;
|
||||
|
||||
export const WEEKDAY_CODES: WeekdayCode[] = ["mon", "tue", "wed", "thu", "fri", "sat", "sun"];
|
||||
|
||||
const WEEKDAY_LABELS: Record<WeekdayCode, { short: string; long: string; ics: string }> = {
|
||||
mon: { short: "form.blisters.weekdaysShort.mon", long: "form.blisters.weekdaysLong.mon", ics: "MO" },
|
||||
tue: { short: "form.blisters.weekdaysShort.tue", long: "form.blisters.weekdaysLong.tue", ics: "TU" },
|
||||
wed: { short: "form.blisters.weekdaysShort.wed", long: "form.blisters.weekdaysLong.wed", ics: "WE" },
|
||||
thu: { short: "form.blisters.weekdaysShort.thu", long: "form.blisters.weekdaysLong.thu", ics: "TH" },
|
||||
fri: { short: "form.blisters.weekdaysShort.fri", long: "form.blisters.weekdaysLong.fri", ics: "FR" },
|
||||
sat: { short: "form.blisters.weekdaysShort.sat", long: "form.blisters.weekdaysLong.sat", ics: "SA" },
|
||||
sun: { short: "form.blisters.weekdaysShort.sun", long: "form.blisters.weekdaysLong.sun", ics: "SU" },
|
||||
};
|
||||
|
||||
export function normalizeWeekdays(weekdays?: ReadonlyArray<WeekdayCode> | null): WeekdayCode[] {
|
||||
if (!Array.isArray(weekdays) || weekdays.length === 0) return [];
|
||||
const normalizedSet = new Set<WeekdayCode>();
|
||||
for (const day of weekdays) {
|
||||
if (WEEKDAY_CODES.includes(day)) {
|
||||
normalizedSet.add(day);
|
||||
}
|
||||
}
|
||||
return WEEKDAY_CODES.filter((day) => normalizedSet.has(day));
|
||||
}
|
||||
|
||||
export function hasSelectedWeekdays(weekdays?: ReadonlyArray<WeekdayCode> | null): boolean {
|
||||
return normalizeWeekdays(weekdays).length > 0;
|
||||
}
|
||||
|
||||
export function getIntakeScheduleMode(schedule: IntakeScheduleLike): ScheduleMode {
|
||||
return schedule.scheduleMode === "weekdays" ? "weekdays" : "interval";
|
||||
}
|
||||
|
||||
export function getNormalizedInterval(schedule: IntakeScheduleLike): number {
|
||||
const parsedEvery = Number(schedule.every);
|
||||
if (!Number.isFinite(parsedEvery) || parsedEvery <= 0) return 1;
|
||||
return Math.floor(parsedEvery);
|
||||
}
|
||||
|
||||
export function getWeekdayCode(date: Date): WeekdayCode {
|
||||
return WEEKDAY_CODES[(date.getDay() + 6) % 7];
|
||||
}
|
||||
|
||||
export function getWeekdayLabel(day: WeekdayCode, t: Translate, format: "short" | "long" = "short"): string {
|
||||
return t(WEEKDAY_LABELS[day][format]);
|
||||
}
|
||||
|
||||
export function getWeekdayIcsCode(day: WeekdayCode): string {
|
||||
return WEEKDAY_LABELS[day].ics;
|
||||
}
|
||||
|
||||
export function toggleWeekdaySelection(
|
||||
weekdays: ReadonlyArray<WeekdayCode> | null | undefined,
|
||||
day: WeekdayCode
|
||||
): WeekdayCode[] {
|
||||
const normalized = normalizeWeekdays(weekdays);
|
||||
if (normalized.includes(day)) {
|
||||
return normalized.filter((entry) => entry !== day);
|
||||
}
|
||||
return normalizeWeekdays([...normalized, day]);
|
||||
}
|
||||
|
||||
export function getMedicationIntakes(med: MedicationScheduleSource): Intake[] {
|
||||
if (med.intakes && med.intakes.length > 0) {
|
||||
return med.intakes;
|
||||
}
|
||||
return med.blisters.map((blister) => ({
|
||||
usage: blister.usage,
|
||||
every: blister.every,
|
||||
start: blister.start,
|
||||
scheduleMode: "interval",
|
||||
weekdays: [],
|
||||
intakeUnit: null,
|
||||
takenBy: null,
|
||||
intakeRemindersEnabled: med.intakeRemindersEnabled ?? false,
|
||||
}));
|
||||
}
|
||||
|
||||
export function iterateIntakeOccurrences(
|
||||
intake: IntakeScheduleLike,
|
||||
start: Date,
|
||||
end: Date,
|
||||
callback: (occurrence: Date) => void
|
||||
): void {
|
||||
if (start > end) return;
|
||||
|
||||
if (getIntakeScheduleMode(intake) === "weekdays") {
|
||||
const weekdays = normalizeWeekdays(intake.weekdays);
|
||||
if (weekdays.length === 0) return;
|
||||
|
||||
const cursor = new Date(start);
|
||||
while (cursor <= end) {
|
||||
if (weekdays.includes(getWeekdayCode(cursor))) {
|
||||
callback(new Date(cursor));
|
||||
}
|
||||
cursor.setDate(cursor.getDate() + 1);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const interval = getNormalizedInterval(intake);
|
||||
const cursor = new Date(start);
|
||||
while (cursor <= end) {
|
||||
callback(new Date(cursor));
|
||||
cursor.setDate(cursor.getDate() + interval);
|
||||
}
|
||||
}
|
||||
|
||||
export function getIntakeDailyRate(schedule: IntakeScheduleLike): number {
|
||||
if (getIntakeScheduleMode(schedule) === "weekdays") {
|
||||
return normalizeWeekdays(schedule.weekdays).length / 7;
|
||||
}
|
||||
return 1 / getNormalizedInterval(schedule);
|
||||
}
|
||||
|
||||
export function getIntakeFrequencyText(schedule: IntakeScheduleLike, t: Translate): string {
|
||||
if (getIntakeScheduleMode(schedule) === "weekdays") {
|
||||
return normalizeWeekdays(schedule.weekdays)
|
||||
.map((day) => getWeekdayLabel(day, t, "short"))
|
||||
.join(", ");
|
||||
}
|
||||
|
||||
const every = getNormalizedInterval(schedule);
|
||||
return every === 1 ? t("common.daily") : t("common.everyNDays", { count: every });
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
import type { IntakeUnit } from "../types";
|
||||
|
||||
type Translate = (key: string, options?: Record<string, unknown>) => string;
|
||||
|
||||
export function convertLiquidUsageToMl(usage: number, unit: IntakeUnit | null | undefined): number {
|
||||
if (unit === "tsp") return usage * 5;
|
||||
if (unit === "tbsp") return usage * 15;
|
||||
return usage;
|
||||
}
|
||||
|
||||
export function getLiquidCountUnitLabel(unit: IntakeUnit | null | undefined, usage: number, t: Translate): 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");
|
||||
}
|
||||
@@ -2,17 +2,10 @@
|
||||
// Schedule Building and Coverage Calculations
|
||||
// =============================================================================
|
||||
|
||||
import type {
|
||||
Blister,
|
||||
Coverage,
|
||||
Intake,
|
||||
Medication,
|
||||
PackageType,
|
||||
ScheduleEvent,
|
||||
StockStatus,
|
||||
StockThresholds,
|
||||
} from "../types";
|
||||
import type { Coverage, Intake, Medication, PackageType, ScheduleEvent, StockStatus, StockThresholds } from "../types";
|
||||
import { getMedDisplayName, getMedTotal, isLiquidContainerPackageType, isTubePackageType } from "../types";
|
||||
import { getIntakeDailyRate, getMedicationIntakes, iterateIntakeOccurrences } from "./intake-schedule";
|
||||
import { convertLiquidUsageToMl } from "./intake-units";
|
||||
|
||||
export function parseLocalDateTime(isoString: string): Date {
|
||||
const match = isoString.match(/^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2}):?(\d{2})?/);
|
||||
@@ -39,38 +32,7 @@ function normalizeIntakeUsageForStock(intake: Intake, med: Medication): number {
|
||||
const isLiquidStock = isLiquidContainerPackageType(med.packageType) || med.medicationForm === "liquid";
|
||||
if (!isLiquidStock) return usage;
|
||||
|
||||
if (intake.intakeUnit === "tsp") return usage * 5;
|
||||
if (intake.intakeUnit === "tbsp") return usage * 15;
|
||||
return usage;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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,
|
||||
intakeUnit: null,
|
||||
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;
|
||||
return convertLiquidUsageToMl(usage, intake.intakeUnit);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -90,13 +52,13 @@ export function buildSchedulePreview(
|
||||
end.setDate(end.getDate() + 180); // 6 months horizon
|
||||
|
||||
meds.forEach((med) => {
|
||||
const intakes = getIntakesForMed(med);
|
||||
const intakes = getMedicationIntakes(med);
|
||||
intakes.forEach((intake, idx) => {
|
||||
const start = parseLocalDateTime(intake.start);
|
||||
if (Number.isNaN(start.getTime())) return;
|
||||
for (let d = new Date(start); d <= end; d.setDate(d.getDate() + intake.every)) {
|
||||
iterateIntakeOccurrences(intake, start, end, (d) => {
|
||||
const isPast = d < todayStart;
|
||||
if (isPast && !includePast) continue;
|
||||
if (isPast && !includePast) return;
|
||||
const whenMs = d.getTime();
|
||||
// Use date-only timestamp for stable ID (immune to time changes)
|
||||
// This ensures changing intake times doesn't invalidate past dose tracking
|
||||
@@ -113,7 +75,7 @@ export function buildSchedulePreview(
|
||||
dateStr: d.toLocaleDateString(locale, { weekday: "short", day: "2-digit", month: "short" }),
|
||||
intakeRemindersEnabled: intake.intakeRemindersEnabled,
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -129,7 +91,7 @@ export function buildSchedulePreview(
|
||||
events,
|
||||
today: todayCount,
|
||||
nextThree: events.length,
|
||||
totalBlisters: meds.reduce((acc, m) => acc + getIntakesForMed(m).length, 0),
|
||||
totalBlisters: meds.reduce((acc, med) => acc + getMedicationIntakes(med).length, 0),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -147,10 +109,10 @@ export function calculateCoverage(
|
||||
): { low: Coverage[]; all: Coverage[] } {
|
||||
const MS_PER_DAY = 86_400_000;
|
||||
const now = Date.now();
|
||||
const nowDate = new Date(now);
|
||||
|
||||
const coverage: Coverage[] = meds.map((m) => {
|
||||
const intakes = getIntakesForMed(m);
|
||||
const blisters = getBlistersForMed(m);
|
||||
const intakes = getMedicationIntakes(m);
|
||||
// Count unique people from all intakes (for per-intake takenBy)
|
||||
const uniquePeople = new Set<string>();
|
||||
intakes.forEach((intake) => {
|
||||
@@ -165,11 +127,9 @@ export function calculateCoverage(
|
||||
// one person's dose — do NOT multiply by personCount again.
|
||||
// For legacy intakes (no takenBy), the intake applies to ALL people.
|
||||
let dailyRate = 0;
|
||||
blisters.forEach((_s, idx) => {
|
||||
const intake = intakes[idx];
|
||||
if (!intake) return;
|
||||
intakes.forEach((intake) => {
|
||||
const usageForStock = normalizeIntakeUsageForStock(intake, m);
|
||||
const baseRate = intake.every > 0 ? usageForStock / intake.every : 0;
|
||||
const baseRate = usageForStock * getIntakeDailyRate(intake);
|
||||
if (intake?.takenBy) {
|
||||
// Per-intake takenBy: this intake is for exactly 1 person
|
||||
dailyRate += baseRate;
|
||||
@@ -189,29 +149,11 @@ export function calculateCoverage(
|
||||
// time (early intake), that dose is also counted as consumed immediately.
|
||||
// This prevents double-counting: once the scheduled time arrives, the dose
|
||||
// was already counted via the early-taken path, not again via time.
|
||||
blisters.forEach((s, blisterIdx) => {
|
||||
const blisterStart = parseLocalDateTime(s.start).getTime();
|
||||
const period = Math.max(1, s.every) * MS_PER_DAY;
|
||||
const intake = intakes[blisterIdx];
|
||||
if (!intake) return;
|
||||
intakes.forEach((intake, blisterIdx) => {
|
||||
const intakeStart = parseLocalDateTime(intake.start);
|
||||
if (Number.isNaN(intakeStart.getTime())) return;
|
||||
const usageForStock = normalizeIntakeUsageForStock(intake, m);
|
||||
|
||||
// After a stock correction, start counting consumption from the NEXT
|
||||
// scheduled dose on this blister's grid, because the user's pill count
|
||||
// already reflects all consumption up to the correction time.
|
||||
// We align to the schedule grid so that e.g. correction at 15:40 with
|
||||
// a daily 15:42 dose counts today's 15:42 dose (2 min later), not
|
||||
// tomorrow's dose (24h later as the old code did).
|
||||
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 intakePerson = intake?.takenBy;
|
||||
|
||||
// For per-intake takenBy, only count for that person
|
||||
@@ -223,18 +165,15 @@ export function calculateCoverage(
|
||||
let timeBasedConsumed = 0;
|
||||
let lastAutoConsumedDateMs = 0;
|
||||
|
||||
if (effectiveStart <= now) {
|
||||
const occurrences = Math.floor((now - effectiveStart) / period) + 1;
|
||||
timeBasedConsumed = occurrences * usageForStock * peopleForThisIntake.length;
|
||||
|
||||
// Date-only timestamp of the last auto-consumed dose
|
||||
const lastDoseTime = new Date(effectiveStart + (occurrences - 1) * period);
|
||||
iterateIntakeOccurrences(intake, intakeStart, nowDate, (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: count future doses already marked as taken.
|
||||
// The cutoff is the later of: last auto-consumed date or stock correction date.
|
||||
@@ -276,16 +215,15 @@ export function calculateCoverage(
|
||||
const medId = parseInt(parts[0], 10);
|
||||
const blisterIdx = parseInt(parts[1], 10);
|
||||
const doseTimestamp = parseInt(parts[2], 10);
|
||||
if (medId === m.id && blisters[blisterIdx]) {
|
||||
const intake = intakes[blisterIdx];
|
||||
if (!intake) return;
|
||||
const intake = intakes[blisterIdx];
|
||||
if (medId === m.id && intake) {
|
||||
const usageForStock = normalizeIntakeUsageForStock(intake, m);
|
||||
// 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()
|
||||
const intakeStartDate = new Date(intake.start);
|
||||
const intakeStartDateOnly = new Date(
|
||||
intakeStartDate.getFullYear(),
|
||||
intakeStartDate.getMonth(),
|
||||
intakeStartDate.getDate()
|
||||
).getTime();
|
||||
|
||||
// Use actual takenAt timestamp for stock correction comparison.
|
||||
@@ -295,8 +233,8 @@ export function calculateCoverage(
|
||||
const afterCorrectionOrNoCorrectionMs = stockCorrectionCutoff === 0 || takenAt > stockCorrectionCutoff;
|
||||
|
||||
if (
|
||||
!Number.isNaN(blisterStartDateOnly) &&
|
||||
doseTimestamp >= blisterStartDateOnly &&
|
||||
!Number.isNaN(intakeStartDateOnly) &&
|
||||
doseTimestamp >= intakeStartDateOnly &&
|
||||
afterCorrectionOrNoCorrectionMs
|
||||
) {
|
||||
consumed += usageForStock;
|
||||
@@ -618,3 +556,48 @@ export function computeMissedPastDoseIds(
|
||||
);
|
||||
return totalPastDoses.filter((id) => !takenDoses.has(id) && !dismissedDoses.has(id));
|
||||
}
|
||||
|
||||
export function buildClearMissedPayload(
|
||||
pastDays: ReadonlyArray<{
|
||||
date: Date;
|
||||
meds: ReadonlyArray<{
|
||||
medName: string;
|
||||
doses: ReadonlyArray<{ id: string; takenBy: string[] }>;
|
||||
}>;
|
||||
}>,
|
||||
medications: ReadonlyArray<{ id: number; name: string; genericName?: string | null; dismissedUntil?: string | null }>,
|
||||
takenDoses: Set<string>,
|
||||
dismissedDoses: Set<string>
|
||||
): { medicationIds: number[]; until: string | null } {
|
||||
const medicationIds = new Set<number>();
|
||||
let latestMissedDate: string | null = null;
|
||||
|
||||
for (const day of pastDays) {
|
||||
for (const item of day.meds) {
|
||||
const med = medications.find((candidate) => getMedDisplayName(candidate as Medication) === item.medName);
|
||||
if (!med) continue;
|
||||
|
||||
const dismissedUntilDate = med.dismissedUntil ?? undefined;
|
||||
const hasMissedDose = item.doses.some((dose) => {
|
||||
if (isDoseDismissed(dose.id, dismissedUntilDate)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return expandDoseIds([dose]).some((doseId) => !takenDoses.has(doseId) && !dismissedDoses.has(doseId));
|
||||
});
|
||||
|
||||
if (!hasMissedDose) continue;
|
||||
|
||||
medicationIds.add(med.id);
|
||||
const dayDate = day.date.toISOString().slice(0, 10);
|
||||
if (!latestMissedDate || dayDate > latestMissedDate) {
|
||||
latestMissedDate = dayDate;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
medicationIds: [...medicationIds],
|
||||
until: latestMissedDate,
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user