import type { BalanceComparison, Config, EstimationResult, MissingPeriod, Plan, Selections, } from "./types"; import { clampDate, daysBetween, inclusiveDayCount, overlapDays } from "./dates"; /** A balance is treated as "on track" when within this fraction of expected. */ const ON_TRACK_TOLERANCE = 0.02; /** The active missing periods for a set of ids, in config order. */ function activePeriods(config: Config, ids: Set): MissingPeriod[] { return config.missingPeriods.filter((p) => ids.has(p.id)); } /** * Days a student is expected to be on campus across the full meal window, * after removing days covered by the active missing periods. * Overlapping/adjacent periods are handled by summing per-period overlap with * the window; periods in the config are assumed non-overlapping (typical for * distinct breaks). Result is clamped to >= 0. */ export function totalActiveDays(config: Config, activeIds: Set): number { const { mealStartDate, mealEndDate } = config; const windowDays = inclusiveDayCount(mealStartDate, mealEndDate); let excluded = 0; for (const p of activePeriods(config, activeIds)) { excluded += overlapDays(mealStartDate, mealEndDate, p.startDate, p.endDate); } return Math.max(0, windowDays - excluded); } /** * Active days from meal start up to (and including) the target date, minus * active missing periods within that window, minus extra excluded days. * The target date is clamped into the meal window first. Result clamped >= 0. */ export function activeDaysConsumed(config: Config, selections: Selections): number { const { mealStartDate, mealEndDate } = config; // Before the meal period even begins, nothing is consumed yet. if (daysBetween(mealStartDate, selections.targetDate) < 0) { return 0; } const target = clampDate(selections.targetDate, mealStartDate, mealEndDate); const windowDays = inclusiveDayCount(mealStartDate, target); let excluded = 0; for (const p of activePeriods(config, selections.activeMissingPeriodIds)) { excluded += overlapDays(mealStartDate, target, p.startDate, p.endDate); } const consumed = windowDays - excluded - Math.max(0, selections.extraExcludedDays); return Math.max(0, consumed); } function findPlan(config: Config, planId: string): Plan | undefined { return config.plans.find((p) => p.id === planId); } /** * Master estimate. Distributes a plan's totals evenly across total active days, * then reports per-week rate, consumed-by-target, and remaining for both meal * blocks and FLEX dollars. All output values are clamped to sane bounds. */ export function estimate(config: Config, selections: Selections): EstimationResult { const plan = findPlan(config, selections.selectedPlanId) ?? config.plans[0]; const total = totalActiveDays(config, selections.activeMissingPeriodIds); const consumedDays = Math.min( activeDaysConsumed(config, selections), total ); // Guard against division by zero when every day is excluded. const blockRate = total > 0 ? plan.totalMealBlocks / total : 0; const flexRate = total > 0 ? plan.totalFlexDollars / total : 0; const consumedMealBlocks = clamp(blockRate * consumedDays, 0, plan.totalMealBlocks); const consumedFlexDollars = clamp(flexRate * consumedDays, 0, plan.totalFlexDollars); const remainingMealBlocks = plan.totalMealBlocks - consumedMealBlocks; const remainingFlexDollars = plan.totalFlexDollars - consumedFlexDollars; // Active days still ahead of the target date (for catch-up guidance). const activeDaysRemaining = Math.max(0, total - consumedDays); return { perWeekMealBlocks: blockRate * 7, perWeekFlexDollars: flexRate * 7, consumedMealBlocks, consumedFlexDollars, remainingMealBlocks, remainingFlexDollars, totalActiveDays: total, activeDaysConsumed: consumedDays, activeDaysRemaining, blockComparison: compareBalance( selections.actualMealBlocks, remainingMealBlocks, activeDaysRemaining ), flexComparison: compareBalance( selections.actualFlexDollars, remainingFlexDollars, activeDaysRemaining ), }; } /** * Compare an actual balance the user entered against the on-pace expected * remaining amount. Returns null when no actual balance was entered. * * A higher actual balance than expected means the student is spending slower * than the even pace (ahead / not running low). The suggested per-week rate is * the pace that would use the actual balance up exactly over the days left. */ export function compareBalance( actual: number | null, expectedRemaining: number, activeDaysRemaining: number ): BalanceComparison | null { if (actual === null) return null; const difference = actual - expectedRemaining; let status: BalanceComparison["status"]; const tolerance = Math.max(ON_TRACK_TOLERANCE * expectedRemaining, 0.5); if (Math.abs(difference) <= tolerance) status = "onTrack"; else if (difference > 0) status = "ahead"; else status = "behind"; const suggestedPerWeek = activeDaysRemaining > 0 ? (actual / activeDaysRemaining) * 7 : null; return { actual, expected: expectedRemaining, difference, status, suggestedPerWeek }; } function clamp(v: number, lo: number, hi: number): number { return Math.min(hi, Math.max(lo, v)); }