functional
This commit is contained in:
@@ -0,0 +1,93 @@
|
||||
import type { Config, EstimationResult, MissingPeriod, Plan, Selections } from "./types";
|
||||
import { clampDate, daysBetween, inclusiveDayCount, overlapDays } from "./dates";
|
||||
|
||||
/** 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);
|
||||
|
||||
return {
|
||||
perWeekMealBlocks: blockRate * 7,
|
||||
perWeekFlexDollars: flexRate * 7,
|
||||
consumedMealBlocks,
|
||||
consumedFlexDollars,
|
||||
remainingMealBlocks: plan.totalMealBlocks - consumedMealBlocks,
|
||||
remainingFlexDollars: plan.totalFlexDollars - consumedFlexDollars,
|
||||
totalActiveDays: total,
|
||||
activeDaysConsumed: consumedDays,
|
||||
};
|
||||
}
|
||||
|
||||
function clamp(v: number, lo: number, hi: number): number {
|
||||
return Math.min(hi, Math.max(lo, v));
|
||||
}
|
||||
Reference in New Issue
Block a user