Files
cmu-meal-estimator/src/engine.ts
T
2026-09-18 16:47:15 -04:00

150 lines
5.2 KiB
TypeScript

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<string>): 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<string>): 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));
}