// ============================================================================= // Schedule Building and Coverage Calculations // ============================================================================= import type { Blister, Coverage, Intake, Medication, PackageType, ScheduleEvent, StockStatus, StockThresholds, } from "../types"; import { getMedDisplayName, getMedTotal, isLiquidContainerPackageType, isTubePackageType } from "../types"; function normalizeIntakeUsageForStock(intake: Intake, med: Medication): number { const usage = Number(intake.usage); if (!Number.isFinite(usage) || usage <= 0) return 0; if (isTubePackageType(med.packageType)) return 0; 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; } /** * Build schedule preview events for medications */ export function buildSchedulePreview( meds: Medication[], locale: string, includePast: boolean = false ): { events: ScheduleEvent[]; today: number; nextThree: number; totalBlisters: number } { const events: ScheduleEvent[] = []; if (!Array.isArray(meds)) return { events, today: 0, nextThree: 0, totalBlisters: 0 }; const now = new Date(); const todayStart = new Date(now.getFullYear(), now.getMonth(), now.getDate()); const end = new Date(); end.setDate(end.getDate() + 180); // 6 months horizon meds.forEach((med) => { const intakes = getIntakesForMed(med); intakes.forEach((intake, idx) => { const start = new Date(intake.start); if (Number.isNaN(start.getTime())) return; for (let d = new Date(start); d <= end; d.setDate(d.getDate() + intake.every)) { const isPast = d < todayStart; if (isPast && !includePast) continue; 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 const dateOnlyMs = new Date(d.getFullYear(), d.getMonth(), d.getDate()).getTime(); events.push({ id: `${med.id}-${idx}-${dateOnlyMs}`, medName: getMedDisplayName(med), takenBy: intake.takenBy, // Per-intake takenBy (string | null) usage: intake.usage, intakeUnit: intake.intakeUnit ?? null, when: whenMs, isPast, timeStr: d.toLocaleTimeString(locale, { hour: "2-digit", minute: "2-digit" }), dateStr: d.toLocaleDateString(locale, { weekday: "short", day: "2-digit", month: "short" }), intakeRemindersEnabled: intake.intakeRemindersEnabled, }); } }); }); events.sort((a, b) => a.when - b.when); const todayCount = events.filter((e) => { const t = new Date(e.when); const n = new Date(); return t.getFullYear() === n.getFullYear() && t.getMonth() === n.getMonth() && t.getDate() === n.getDate(); }).length; return { events, today: todayCount, nextThree: events.length, totalBlisters: meds.reduce((acc, m) => acc + getIntakesForMed(m).length, 0), }; } /** * Calculate coverage information for medications */ export function calculateCoverage( meds: Medication[], events: Array<{ medName: string; when: number; id: string }>, locale: string, reminderDaysBefore: number, stockCalculationMode: "automatic" | "manual", takenDoses: Set, takenDoseTimestamps?: Map ): { low: Coverage[]; all: Coverage[] } { const MS_PER_DAY = 86_400_000; const now = Date.now(); const coverage: Coverage[] = meds.map((m) => { const intakes = getIntakesForMed(m); const blisters = getBlistersForMed(m); // Count unique people from all intakes (for per-intake takenBy) const uniquePeople = new Set(); intakes.forEach((intake) => { if (intake.takenBy) uniquePeople.add(intake.takenBy); }); // Also add medication-level takenBy for backward compatibility m.takenBy?.forEach((person) => uniquePeople.add(person)); const personCount = Math.max(1, uniquePeople.size || m.takenBy?.length || 1); // Calculate daily consumption rate per intake, accounting for per-intake takenBy. // When an intake has a per-intake takenBy (new format), it represents exactly // 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; const usageForStock = normalizeIntakeUsageForStock(intake, m); const baseRate = intake.every > 0 ? usageForStock / intake.every : 0; if (intake?.takenBy) { // Per-intake takenBy: this intake is for exactly 1 person dailyRate += baseRate; } else { // Legacy: this intake applies to all people dailyRate += baseRate * personCount; } }); let consumed = 0; const stockCorrectionCutoff = m.lastStockCorrectionAt ? new Date(m.lastStockCorrectionAt).getTime() : 0; if (stockCalculationMode === "automatic") { // In automatic mode, stock is reduced automatically based on the schedule. // Every scheduled dose counts as consumed once its time has passed. // Additionally, if a user marks a future dose as taken BEFORE the scheduled // 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 = new Date(s.start).getTime(); const period = Math.max(1, s.every) * MS_PER_DAY; const intake = intakes[blisterIdx]; if (!intake) 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 // For legacy (no takenBy), count for all people in medication takenBy const fallbackPeople = m.takenBy?.length > 0 ? m.takenBy : [null]; const peopleForThisIntake = intakePerson ? [intakePerson] : fallbackPeople; // Time-based: count doses where the scheduled time has already passed 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); lastAutoConsumedDateMs = new Date( lastDoseTime.getFullYear(), lastDoseTime.getMonth(), lastDoseTime.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. // This prevents double-counting (time-based + early-taken) and respects corrections. const stockCorrectionDateOnly = stockCorrectionCutoff > 0 ? new Date( new Date(stockCorrectionCutoff).getFullYear(), new Date(stockCorrectionCutoff).getMonth(), new Date(stockCorrectionCutoff).getDate() ).getTime() : 0; const earlyCutoff = Math.max(lastAutoConsumedDateMs, stockCorrectionDateOnly); let earlyTakenConsumed = 0; for (const doseId of takenDoses) { const parts = doseId.split("-"); if (parts.length >= 3) { const medId = parseInt(parts[0], 10); const bIdx = parseInt(parts[1], 10); const timestamp = parseInt(parts[2], 10); if (medId === m.id && bIdx === blisterIdx && timestamp > earlyCutoff) { earlyTakenConsumed += usageForStock; } } } consumed += timeBasedConsumed + earlyTakenConsumed; }); } else { // In manual mode, only count doses that are explicitly marked as taken. // For stock correction filtering, we use the actual time the dose was marked // as taken (takenAt), not the scheduled date. This correctly handles same-day // scenarios: if a user corrects stock at 3pm, then takes a dose at 4pm, // the dose counts because takenAt (4pm) > correctionTime (3pm). takenDoses.forEach((doseId) => { const parts = doseId.split("-"); if (parts.length >= 3) { const medId = parseInt(parts[0], 10); const blisterIdx = parseInt(parts[1], 10); const doseTimestamp = parseInt(parts[2], 10); if (medId === m.id && blisters[blisterIdx]) { const intake = intakes[blisterIdx]; if (!intake) return; 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() ).getTime(); // Use actual takenAt timestamp for stock correction comparison. // A dose counts only if it was MARKED after the stock correction, // regardless of what day it was scheduled for. const takenAt = takenDoseTimestamps?.get(doseId) ?? 0; const afterCorrectionOrNoCorrectionMs = stockCorrectionCutoff === 0 || takenAt > stockCorrectionCutoff; if ( !Number.isNaN(blisterStartDateOnly) && doseTimestamp >= blisterStartDateOnly && afterCorrectionOrNoCorrectionMs ) { consumed += usageForStock; } } } }); } const totalPills = getMedTotal(m); const medsLeft = Math.max(0, totalPills - consumed); const rawDaysLeft = dailyRate > 0 ? medsLeft / dailyRate : null; const daysLeft = rawDaysLeft !== null ? Math.max(0, Math.floor(rawDaysLeft)) : null; const depletionMs = daysLeft !== null ? now + daysLeft * MS_PER_DAY : null; const depletionDate = depletionMs !== null ? new Date(depletionMs).toLocaleDateString(locale, { weekday: "short", day: "2-digit", month: "short" }) : null; const displayName = getMedDisplayName(m); const nextEvent = events.find((e) => e.medName === displayName); return { name: displayName, medsLeft: Number(medsLeft.toFixed(1)), daysLeft, depletionDate, depletionTime: depletionMs, nextDose: nextEvent ? new Date(nextEvent.when).toLocaleString(locale, { weekday: "short", day: "2-digit", month: "short", hour: "2-digit", minute: "2-digit", }) : null, }; }); const low = coverage.filter((c) => c.medsLeft <= 0 || (c.daysLeft !== null && c.daysLeft <= reminderDaysBefore)); return { low, all: coverage }; } function getLiquidDerivedThresholds(baselineDays: number): { lowDays: number; criticalDays: number } { const lowDays = Math.max(1, Math.floor(baselineDays)); const criticalDays = Math.max(1, Math.ceil(lowDays / 2)); return { lowDays, criticalDays }; } /** * Get stock status based on days left and thresholds */ export function getStockStatus( daysLeft: number | null, medsLeft: number, thresholds: StockThresholds, packageType?: PackageType ): StockStatus { // Out of stock or completely depleted = danger (red) if (medsLeft <= 0 || daysLeft === 0) { return { level: "out-of-stock", className: "danger", label: "status.outOfStock" }; } // Tube has no stock reminder semantics. if (isTubePackageType(packageType)) { return { level: "normal", className: "success", label: "status.noSchedule" }; } // No schedule, but has stock = normal if (daysLeft === null) { return { level: "normal", className: "success", label: "status.noSchedule" }; } if (isLiquidContainerPackageType(packageType)) { const liquidThresholds = getLiquidDerivedThresholds(thresholds.criticalStockDays); if (daysLeft <= liquidThresholds.criticalDays) { return { level: "critical", className: "danger", label: "status.criticalStock" }; } if (daysLeft <= liquidThresholds.lowDays) { return { level: "low", className: "warning", label: "status.lowStock" }; } return { level: "normal", className: "success", label: "status.normal" }; } // High stock if (daysLeft > thresholds.highStockDays) { return { level: "high", className: "high", label: "status.highStock" }; } // Normal stock if (daysLeft >= thresholds.lowStockDays) { return { level: "normal", className: "success", label: "status.normal" }; } // Critical: at or below critical threshold = danger (red) if (daysLeft <= thresholds.criticalStockDays) { return { level: "critical", className: "danger", label: "status.criticalStock" }; } // Low stock: below lowStockDays but above critical = warning (yellow) return { level: "low", className: "warning", label: "status.lowStock" }; } /** * Get next reminder date for a medication */ export function getNextReminderForMed(med: Coverage, reminderDaysBefore: number, locale: string): string { if (!med.depletionTime) return "—"; const reminderTime = med.depletionTime - reminderDaysBefore * 86_400_000; const now = Date.now(); if (reminderTime <= now) { return "Due now"; } return new Date(reminderTime).toLocaleDateString(locale, { day: "2-digit", month: "short", }); } /** * Get reminder status text for dashboard display */ export function getReminderStatusText( reminderDaysBefore: number, lowStockDays: number, _lowStock: Coverage[], allCoverage: Coverage[], lastSent: string | null, lastType: "stock" | "intake" | null, lastChannel: "email" | "push" | "both" | null, t: (key: string, options?: Record) => string, locale: string ): { lines: Array<{ text: string; className?: string; strong?: boolean }> } { const emptyMeds = allCoverage.filter((c) => c.medsLeft <= 0); const medsNeedingReminder = allCoverage .filter((c) => c.medsLeft > 0 && c.daysLeft !== null && c.daysLeft <= reminderDaysBefore) .sort((a, b) => (a.daysLeft ?? 0) - (b.daysLeft ?? 0)); const lowStockNotYetCritical = allCoverage.filter( (c) => c.medsLeft > 0 && c.daysLeft !== null && c.daysLeft > reminderDaysBefore && c.daysLeft < lowStockDays ); const formatLastSent = (iso: string) => { const date = new Date(iso); return date.toLocaleDateString(locale, { day: "2-digit", month: "short", hour: "2-digit", minute: "2-digit" }); }; const getTypeLabel = () => lastType === "intake" ? t("dashboard.reminders.typeIntake") : t("dashboard.reminders.typeStock"); const getChannelLabel = () => { if (lastChannel === "both") return t("dashboard.reminders.channelBoth"); if (lastChannel === "push") return t("dashboard.reminders.channelPush"); return t("dashboard.reminders.channelEmail"); }; const formatLastInfo = (iso: string) => { const dateStr = formatLastSent(iso); if (lastType && lastChannel) { return `${dateStr} (${getTypeLabel()}, ${getChannelLabel()})`; } return dateStr; }; const lines: Array<{ text: string; className?: string; strong?: boolean }> = []; if (emptyMeds.length > 0) { lines.push({ text: `🚨 ${t("dashboard.reminders.emptyStock", { count: emptyMeds.length })}`, className: "danger-text", strong: true, }); if (medsNeedingReminder.length > 0) { lines.push({ text: `⚠ ${t("dashboard.reminders.needRefill", { count: medsNeedingReminder.length })}`, className: "danger-text", }); } if (lowStockNotYetCritical.length > 0) { lines.push({ text: t("dashboard.reminders.lowWarning", { count: lowStockNotYetCritical.length }), className: "warning-text", }); } if (lastSent) { lines.push({ text: `${t("dashboard.reminders.lastReminder")}: ${formatLastInfo(lastSent)}` }); } return { lines }; } if (medsNeedingReminder.length > 0) { lines.push({ text: `⚠ ${t("dashboard.reminders.needRefill", { count: medsNeedingReminder.length })}`, className: "danger-text", strong: true, }); if (lowStockNotYetCritical.length > 0) { lines.push({ text: t("dashboard.reminders.lowWarning", { count: lowStockNotYetCritical.length }), className: "warning-text", }); } if (lastSent) { lines.push({ text: `${t("dashboard.reminders.lastReminder")}: ${formatLastInfo(lastSent)}` }); } return { lines }; } if (lowStockNotYetCritical.length > 0) { const nextMed = lowStockNotYetCritical.sort((a, b) => (a.daysLeft ?? 0) - (b.daysLeft ?? 0))[0]; const daysUntilReminder = Math.max(0, (nextMed.daysLeft ?? 0) - reminderDaysBefore); lines.push({ text: t("dashboard.reminders.lowWarning", { count: lowStockNotYetCritical.length }), className: "warning-text", }); lines.push({ text: `${t("dashboard.reminders.nextIn")}: ${nextMed.name} ${t("dashboard.reminders.inDays", { days: daysUntilReminder })}`, }); return { lines }; } const allWithDepletion = allCoverage .filter((c) => c.depletionTime !== null && c.daysLeft !== null && c.medsLeft > 0) .sort((a, b) => (a.daysLeft ?? Infinity) - (b.daysLeft ?? Infinity)); if (allWithDepletion.length > 0) { const nextMed = allWithDepletion[0]; const daysUntilReminder = (nextMed.daysLeft ?? 0) - reminderDaysBefore; if (daysUntilReminder > 0) { lines.push({ text: `✓ ${t("dashboard.reminders.allOk")}`, className: "success-text" }); lines.push({ text: `${t("dashboard.reminders.nextIn")}: ${nextMed.name} ${t("dashboard.reminders.inDays", { days: daysUntilReminder })}`, }); return { lines }; } } lines.push({ text: `✓ ${t("dashboard.reminders.allStockOk")}`, className: "success-text" }); if (lastSent) { lines.push({ text: `${t("dashboard.reminders.lastReminder")}: ${formatLastInfo(lastSent)}` }); } else { lines.push({ text: t("dashboard.reminders.noRemindersNeeded") }); } return { lines }; } // ============================================================================= // Dose Dismissal & Missed Dose Computation // ============================================================================= /** * Check if a dose is dismissed based on its ID and a dismissedUntil date string. * Extracts the date-only timestamp from the dose ID and compares it with the dismissedUntil date. * * @param doseId - Dose ID in format "medId-intakeIdx-dateOnlyMs" or "medId-intakeIdx-dateOnlyMs-person" * @param dismissedUntilDate - YYYY-MM-DD formatted date string, or undefined if not dismissed * @returns true if the dose date is on or before the dismissedUntil date */ export function isDoseDismissed(doseId: string, dismissedUntilDate: string | undefined): boolean { if (!dismissedUntilDate) return false; const parts = doseId.split("-"); if (parts.length < 3) return false; const timestamp = parseInt(parts[2], 10); if (Number.isNaN(timestamp)) return false; const doseDate = new Date(timestamp); const doseDateStr = `${doseDate.getFullYear()}-${String(doseDate.getMonth() + 1).padStart(2, "0")}-${String(doseDate.getDate()).padStart(2, "0")}`; return doseDateStr <= dismissedUntilDate; } /** * Compute the list of missed past dose IDs. * A dose is "missed" if it is in the past, not taken, not individually dismissed, * and not covered by the medication's dismissedUntil date. * * @param pastDays - Grouped schedule days that are in the past * @param medications - Full medication list (used to look up dismissedUntil) * @param takenDoses - Set of dose IDs marked as taken * @param dismissedDoses - Set of dose IDs individually dismissed * @returns Array of dose IDs that are missed */ /** * Compute the full set of dose IDs for a list of doses, correctly handling * per-intake takenBy arrays. Empty arrays produce base IDs (no suffix), * non-empty arrays produce one ID per person with a `-person` suffix. */ export function expandDoseIds(doses: ReadonlyArray<{ id: string; takenBy: string[] }>): string[] { return doses.flatMap((d) => { const takenByArray = Array.isArray(d.takenBy) ? d.takenBy : []; return takenByArray.length > 0 ? takenByArray.map((p) => `${d.id}-${p}`) : [d.id]; }); } export function computeMissedPastDoseIds( pastDays: ReadonlyArray<{ meds: ReadonlyArray<{ medName: string; doses: ReadonlyArray<{ id: string; takenBy: string[] }>; }>; }>, medications: ReadonlyArray<{ name: string; dismissedUntil?: string | null }>, takenDoses: Set, dismissedDoses: Set ): string[] { const totalPastDoses = pastDays.flatMap((d) => d.meds.flatMap((m) => { const med = medications.find((med) => med.name === m.medName); const dismissedUntilDate = med?.dismissedUntil ?? undefined; return m.doses.flatMap((dose) => { if (isDoseDismissed(dose.id, dismissedUntilDate)) { return []; } const takenByArray = Array.isArray(dose.takenBy) ? dose.takenBy : []; return takenByArray.length > 0 ? takenByArray.map((p: string) => `${dose.id}-${p}`) : [dose.id]; }); }) ); return totalPastDoses.filter((id) => !takenDoses.has(id) && !dismissedDoses.has(id)); }