150 lines
5.2 KiB
TypeScript
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));
|
|
}
|