favicon and some TODOs
This commit is contained in:
@@ -4,6 +4,9 @@
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>CMU Meal Estimator</title>
|
||||
<link rel="icon" type="image/svg+xml" href="./favicon.svg" />
|
||||
<link rel="icon" type="image/x-icon" href="./favicon.ico" sizes="any" />
|
||||
<link rel="apple-touch-icon" href="./apple-touch-icon.png" />
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||
<link
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 8.2 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 15 KiB |
@@ -0,0 +1,38 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" viewBox="0 0 32 32">
|
||||
<!-- Rounded dark tile matching the app surface -->
|
||||
<rect width="32" height="32" rx="7" fill="#16181d"/>
|
||||
|
||||
<!-- Progress arc: faint full track + Tartan-red filled portion (~72%) -->
|
||||
<g fill="none" stroke-linecap="round">
|
||||
<circle cx="16" cy="16" r="10.5" stroke="#2f3440" stroke-width="3"/>
|
||||
<circle cx="16" cy="16" r="10.5" stroke="#c8102e" stroke-width="3"
|
||||
stroke-dasharray="47.5 65.97"
|
||||
transform="rotate(120 16 16)"/>
|
||||
</g>
|
||||
|
||||
<!-- Fork: single continuous silhouette. Three tapered tines meet a rounded
|
||||
neck that narrows into a slim handle. Shifted +1.59 to center its
|
||||
visual bounding box on the tile. -->
|
||||
<path transform="translate(1.59 0)" fill="#e8eaed" d="
|
||||
M11.35 9.4
|
||||
a0.62 0.62 0 0 1 1.24 0
|
||||
l0.16 3.55
|
||||
l0.86 0
|
||||
l0.12 -3.6
|
||||
a0.62 0.62 0 0 1 1.24 0
|
||||
l0.12 3.6
|
||||
l0.86 0
|
||||
l0.12 -3.6
|
||||
a0.62 0.62 0 0 1 1.24 0
|
||||
l0.16 3.55
|
||||
a2.35 2.35 0 0 1 -1.7 2.42
|
||||
l-0.32 0.1
|
||||
a0.9 0.9 0 0 0 -0.64 0.9
|
||||
l0.42 5.9
|
||||
a0.86 0.86 0 0 1 -1.72 0
|
||||
l0.42 -5.9
|
||||
a0.9 0.9 0 0 0 -0.64 -0.9
|
||||
l-0.32 -0.1
|
||||
a2.35 2.35 0 0 1 -1.7 -2.42
|
||||
z"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.2 KiB |
@@ -27,6 +27,8 @@ function sel(overrides: Partial<Selections> = {}): Selections {
|
||||
targetDate: d("2026-04-10"),
|
||||
activeMissingPeriodIds: new Set(),
|
||||
extraExcludedDays: 0,
|
||||
actualMealBlocks: null,
|
||||
actualFlexDollars: null,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
@@ -95,4 +97,51 @@ describe("estimate", () => {
|
||||
expect(r.consumedMealBlocks).toBe(0);
|
||||
expect(Number.isFinite(r.remainingMealBlocks)).toBe(true);
|
||||
});
|
||||
|
||||
it("reports activeDaysRemaining as total minus consumed", () => {
|
||||
// Halfway: Jan 1 .. Feb 19 is 50 inclusive days of a 100-day window.
|
||||
const r = estimate(config, sel({ targetDate: d("2026-02-19") }));
|
||||
expect(r.activeDaysConsumed).toBe(50);
|
||||
expect(r.activeDaysRemaining).toBe(50);
|
||||
});
|
||||
|
||||
it("returns null comparisons when no actual balance is entered", () => {
|
||||
const r = estimate(config, sel());
|
||||
expect(r.blockComparison).toBeNull();
|
||||
expect(r.flexComparison).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("balance comparison", () => {
|
||||
// Halfway through the 100-day window: expected remaining = 100 blocks / 250 flex.
|
||||
const halfway = () => sel({ targetDate: d("2026-02-19") });
|
||||
|
||||
it("flags a higher-than-expected balance as ahead", () => {
|
||||
const r = estimate(config, { ...halfway(), actualMealBlocks: 130 });
|
||||
expect(r.blockComparison?.status).toBe("ahead");
|
||||
expect(r.blockComparison?.difference).toBeCloseTo(30);
|
||||
});
|
||||
|
||||
it("flags a lower-than-expected balance as behind", () => {
|
||||
const r = estimate(config, { ...halfway(), actualMealBlocks: 70 });
|
||||
expect(r.blockComparison?.status).toBe("behind");
|
||||
expect(r.blockComparison?.difference).toBeCloseTo(-30);
|
||||
});
|
||||
|
||||
it("flags a matching balance as on track", () => {
|
||||
const r = estimate(config, { ...halfway(), actualMealBlocks: 100 });
|
||||
expect(r.blockComparison?.status).toBe("onTrack");
|
||||
});
|
||||
|
||||
it("suggests a go-forward per-week rate from the actual balance", () => {
|
||||
// 130 blocks over 50 remaining days => 2.6/day => 18.2/week.
|
||||
const r = estimate(config, { ...halfway(), actualMealBlocks: 130 });
|
||||
expect(r.blockComparison?.suggestedPerWeek).toBeCloseTo(18.2, 1);
|
||||
});
|
||||
|
||||
it("suggests null per-week when no active days remain", () => {
|
||||
const r = estimate(config, { ...sel(), actualMealBlocks: 0 });
|
||||
expect(r.activeDaysRemaining).toBe(0);
|
||||
expect(r.blockComparison?.suggestedPerWeek).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
+59
-3
@@ -1,6 +1,16 @@
|
||||
import type { Config, EstimationResult, MissingPeriod, Plan, Selections } from "./types";
|
||||
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));
|
||||
@@ -76,18 +86,64 @@ export function estimate(config: Config, selections: Selections): EstimationResu
|
||||
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: plan.totalMealBlocks - consumedMealBlocks,
|
||||
remainingFlexDollars: plan.totalFlexDollars - 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));
|
||||
}
|
||||
|
||||
+253
-37
@@ -4,7 +4,12 @@ import { estimate } from "./engine";
|
||||
import { Store } from "./store";
|
||||
import { toISODate, parseISODate, atMidnight } from "./dates";
|
||||
import { loadPersisted, savePersisted } from "./persistence";
|
||||
import type { Config, EstimationResult, Selections } from "./types";
|
||||
import type {
|
||||
BalanceComparison,
|
||||
Config,
|
||||
EstimationResult,
|
||||
Selections,
|
||||
} from "./types";
|
||||
|
||||
const appEl = document.getElementById("app")!;
|
||||
|
||||
@@ -22,8 +27,9 @@ async function main(): Promise<void> {
|
||||
|
||||
/**
|
||||
* Build the initial selections: defaults, overlaid with any persisted values
|
||||
* that are still valid for this config. The target date is never persisted —
|
||||
* it always starts at today.
|
||||
* that are still valid for this config. The target date and actual balances are
|
||||
* never persisted — the date always starts at today, and balances are
|
||||
* session-only since they change constantly.
|
||||
*/
|
||||
function initialSelections(config: Config): Selections {
|
||||
const defaults: Selections = {
|
||||
@@ -31,6 +37,8 @@ function initialSelections(config: Config): Selections {
|
||||
targetDate: atMidnight(new Date()),
|
||||
activeMissingPeriodIds: new Set(),
|
||||
extraExcludedDays: 0,
|
||||
actualMealBlocks: null,
|
||||
actualFlexDollars: null,
|
||||
};
|
||||
|
||||
const persisted = loadPersisted();
|
||||
@@ -62,7 +70,7 @@ function bootstrap(config: Config): void {
|
||||
const rerender = () => {
|
||||
const sel = store.getSelections();
|
||||
|
||||
// Persist everything except the date.
|
||||
// Persist everything except the date and the actual balances.
|
||||
savePersisted({
|
||||
selectedPlanId: sel.selectedPlanId,
|
||||
activeMissingPeriodIds: [...sel.activeMissingPeriodIds],
|
||||
@@ -100,8 +108,15 @@ function buildLayout(store: Store, config: Config): HTMLElement {
|
||||
grid.append(buildControls(store, config), buildResultsPanel());
|
||||
|
||||
const footer = el("footer", "footer");
|
||||
footer.textContent =
|
||||
"Estimates spread each plan evenly across on-campus days. Informational only, not official balances.";
|
||||
footer.append(
|
||||
footerLine(
|
||||
"Estimates spread each plan evenly across on-campus days. Informational only, not official balances."
|
||||
),
|
||||
footerLine(
|
||||
"Not affiliated with, endorsed by, or officially connected to Carnegie Mellon University or CMU Dining. An independent student tool.",
|
||||
"footer-disclaimer"
|
||||
)
|
||||
);
|
||||
|
||||
wrap.append(header, grid, footer);
|
||||
return wrap;
|
||||
@@ -111,7 +126,9 @@ function buildControls(store: Store, config: Config): HTMLElement {
|
||||
const panel = el("section", "panel controls");
|
||||
panel.append(sectionTitle("Your plan"));
|
||||
|
||||
// Plan selector
|
||||
// Plan + date, side by side to shorten the column.
|
||||
const pair = el("div", "field-pair");
|
||||
|
||||
const planField = field("Meal plan");
|
||||
const select = document.createElement("select");
|
||||
select.className = "select";
|
||||
@@ -119,15 +136,14 @@ function buildControls(store: Store, config: Config): HTMLElement {
|
||||
for (const plan of config.plans) {
|
||||
const opt = document.createElement("option");
|
||||
opt.value = plan.id;
|
||||
opt.textContent = `${plan.displayName} — ${plan.totalMealBlocks} blocks / $${plan.totalFlexDollars} FLEX`;
|
||||
// Keep the label short so it fits in a half-width control.
|
||||
opt.textContent = plan.displayName;
|
||||
select.appendChild(opt);
|
||||
}
|
||||
select.value = store.getSelections().selectedPlanId;
|
||||
select.addEventListener("change", () => store.setSelectedPlan(select.value));
|
||||
planField.appendChild(select);
|
||||
panel.appendChild(planField);
|
||||
|
||||
// Date picker
|
||||
const dateField = field("As of date");
|
||||
const date = document.createElement("input");
|
||||
date.type = "date";
|
||||
@@ -141,15 +157,30 @@ function buildControls(store: Store, config: Config): HTMLElement {
|
||||
if (parsed) store.setTargetDate(parsed);
|
||||
});
|
||||
dateField.appendChild(date);
|
||||
const range = el("p", "hint");
|
||||
range.textContent = `Meal period: ${toISODate(config.mealStartDate)} → ${toISODate(
|
||||
config.mealEndDate
|
||||
)}`;
|
||||
dateField.appendChild(range);
|
||||
panel.appendChild(dateField);
|
||||
|
||||
// Missing periods
|
||||
pair.append(planField, dateField);
|
||||
panel.appendChild(pair);
|
||||
|
||||
// Selected plan totals + meal-period range, on one compact line.
|
||||
const planInfo = el("p", "hint");
|
||||
const sel0 = config.plans.find((p) => p.id === store.getSelections().selectedPlanId)!;
|
||||
const setPlanInfo = (planId: string) => {
|
||||
const p = config.plans.find((pl) => pl.id === planId) ?? sel0;
|
||||
planInfo.textContent = `${p.totalMealBlocks} blocks · $${p.totalFlexDollars} FLEX · meal period ${toISODate(
|
||||
config.mealStartDate
|
||||
)} → ${toISODate(config.mealEndDate)}`;
|
||||
};
|
||||
setPlanInfo(store.getSelections().selectedPlanId);
|
||||
select.addEventListener("change", () => setPlanInfo(select.value));
|
||||
panel.appendChild(planInfo);
|
||||
|
||||
// Time away (breaks + extra days)
|
||||
panel.append(sectionTitle("Time away"));
|
||||
const awayIntro = el("p", "hint");
|
||||
awayIntro.textContent =
|
||||
"Days you weren't (or won't be) on campus — past or planned — so they don't count toward your expected usage.";
|
||||
panel.appendChild(awayIntro);
|
||||
|
||||
if (config.missingPeriods.length === 0) {
|
||||
const none = el("p", "hint");
|
||||
none.textContent = "No breaks configured.";
|
||||
@@ -178,7 +209,7 @@ function buildControls(store: Store, config: Config): HTMLElement {
|
||||
}
|
||||
panel.appendChild(list);
|
||||
const hint = el("p", "hint");
|
||||
hint.textContent = "Check any breaks you spent off campus.";
|
||||
hint.textContent = "Check any breaks you spent (or plan to spend) off campus.";
|
||||
panel.appendChild(hint);
|
||||
}
|
||||
|
||||
@@ -196,13 +227,60 @@ function buildControls(store: Store, config: Config): HTMLElement {
|
||||
);
|
||||
extraField.appendChild(extra);
|
||||
const extraHint = el("p", "hint");
|
||||
extraHint.textContent = "Other days you weren't eating on campus.";
|
||||
extraHint.textContent =
|
||||
"Any other days, past or future, you weren't or won't be eating on campus.";
|
||||
extraField.appendChild(extraHint);
|
||||
panel.appendChild(extraField);
|
||||
|
||||
// Current balance (optional) — drives the catch-up comparison.
|
||||
panel.append(sectionTitle("Current balance (optional)"));
|
||||
const balanceIntro = el("p", "hint");
|
||||
balanceIntro.textContent =
|
||||
"Enter what GrubHub or SIO shows you have left to see whether you're ahead or behind pace.";
|
||||
panel.appendChild(balanceIntro);
|
||||
|
||||
const balanceRow = el("div", "balance-inputs");
|
||||
balanceRow.append(
|
||||
balanceInput(
|
||||
"Meal blocks left",
|
||||
store.getSelections().actualMealBlocks,
|
||||
"1",
|
||||
(v) => store.setActualMealBlocks(v)
|
||||
),
|
||||
balanceInput(
|
||||
"FLEX dollars left",
|
||||
store.getSelections().actualFlexDollars,
|
||||
"0.01",
|
||||
(v) => store.setActualFlexDollars(v)
|
||||
)
|
||||
);
|
||||
panel.appendChild(balanceRow);
|
||||
|
||||
return panel;
|
||||
}
|
||||
|
||||
function balanceInput(
|
||||
label: string,
|
||||
current: number | null,
|
||||
step: string,
|
||||
onChange: (value: number | null) => void
|
||||
): HTMLElement {
|
||||
const f = field(label);
|
||||
const input = document.createElement("input");
|
||||
input.type = "number";
|
||||
input.min = "0";
|
||||
input.step = step;
|
||||
input.className = "input";
|
||||
input.placeholder = "—";
|
||||
input.value = current === null ? "" : String(current);
|
||||
input.addEventListener("input", () => {
|
||||
const trimmed = input.value.trim();
|
||||
onChange(trimmed === "" ? null : Number(trimmed));
|
||||
});
|
||||
f.appendChild(input);
|
||||
return f;
|
||||
}
|
||||
|
||||
function buildResultsPanel(): HTMLElement {
|
||||
const panel = el("section", "panel results-panel");
|
||||
panel.append(sectionTitle("Estimate"));
|
||||
@@ -219,39 +297,165 @@ function buildResultsPanel(): HTMLElement {
|
||||
|
||||
function renderResults(r: EstimationResult): HTMLElement {
|
||||
const container = el("div", "result-groups");
|
||||
container.append(
|
||||
resultGroup("Per week", r.perWeekMealBlocks, r.perWeekFlexDollars),
|
||||
resultGroup("Consumed so far", r.consumedMealBlocks, r.consumedFlexDollars),
|
||||
resultGroup("Remaining", r.remainingMealBlocks, r.remainingFlexDollars)
|
||||
|
||||
// Primary: Remaining — the number that matters most.
|
||||
container.appendChild(renderRemaining(r));
|
||||
|
||||
// Secondary: per-week pace and consumed-so-far, visually de-emphasized.
|
||||
const secondary = el("div", "result-secondary");
|
||||
secondary.append(
|
||||
// Per-week is a pace, so fractional blocks are meaningful here.
|
||||
miniStat("Suggested pace / week", r.perWeekMealBlocks, r.perWeekFlexDollars, fmtBlocks),
|
||||
// Consumed is a count, so show whole blocks.
|
||||
miniStat("Used so far", r.consumedMealBlocks, r.consumedFlexDollars, fmtBlocksWhole)
|
||||
);
|
||||
container.appendChild(secondary);
|
||||
|
||||
return container;
|
||||
}
|
||||
|
||||
function resultGroup(label: string, blocks: number, flex: number): HTMLElement {
|
||||
const group = el("div", "result-group");
|
||||
const h = el("h3", "result-label");
|
||||
h.textContent = label;
|
||||
group.appendChild(h);
|
||||
function renderRemaining(r: EstimationResult): HTMLElement {
|
||||
const card = el("div", "remaining-card");
|
||||
|
||||
const row = el("div", "result-row");
|
||||
const label = el("h3", "remaining-label");
|
||||
label.textContent = "Should have remaining";
|
||||
card.appendChild(label);
|
||||
|
||||
const row = el("div", "remaining-row");
|
||||
row.append(
|
||||
metric(fmtBlocks(blocks), "meal blocks"),
|
||||
metric(fmtMoney(flex), "FLEX dollars")
|
||||
bigMetric(fmtBlocksWhole(r.remainingMealBlocks), "meal blocks", r.blockComparison),
|
||||
bigMetric(fmtMoney(r.remainingFlexDollars), "FLEX dollars", r.flexComparison)
|
||||
);
|
||||
group.appendChild(row);
|
||||
return group;
|
||||
card.appendChild(row);
|
||||
|
||||
// Clarify how to read this against GrubHub / SIO.
|
||||
const note = el("p", "remaining-note");
|
||||
note.textContent =
|
||||
"If GrubHub or SIO shows more than this, you're doing well — you're not running low. Less than this means you're spending faster than the even pace.";
|
||||
card.appendChild(note);
|
||||
|
||||
// Per-resource ahead/behind guidance, only when a balance was entered.
|
||||
// `fmt` formats balance amounts (whole blocks); `fmtPace` formats the
|
||||
// suggested per-week pace (fractional blocks are useful there).
|
||||
const comparisons = [
|
||||
{
|
||||
label: "Meal blocks",
|
||||
cmp: r.blockComparison,
|
||||
fmt: fmtBlocksWhole,
|
||||
fmtPace: fmtBlocks,
|
||||
unit: "blocks/week",
|
||||
},
|
||||
{
|
||||
label: "FLEX",
|
||||
cmp: r.flexComparison,
|
||||
fmt: fmtMoney,
|
||||
fmtPace: fmtMoney,
|
||||
unit: "/week",
|
||||
},
|
||||
] as const;
|
||||
|
||||
const active = comparisons.filter((c) => c.cmp !== null);
|
||||
if (active.length > 0) {
|
||||
const guide = el("div", "catchup");
|
||||
for (const c of active) {
|
||||
guide.appendChild(renderCatchUp(c.label, c.cmp!, c.fmt, c.fmtPace, c.unit));
|
||||
}
|
||||
card.appendChild(guide);
|
||||
}
|
||||
|
||||
function metric(value: string, unit: string): HTMLElement {
|
||||
const m = el("div", "metric");
|
||||
const v = el("span", "metric-value");
|
||||
return card;
|
||||
}
|
||||
|
||||
function renderCatchUp(
|
||||
label: string,
|
||||
cmp: BalanceComparison,
|
||||
fmt: (n: number) => string,
|
||||
fmtPace: (n: number) => string,
|
||||
unit: string
|
||||
): HTMLElement {
|
||||
const row = el("div", `catchup-row catchup-${cmp.status}`);
|
||||
|
||||
const head = el("div", "catchup-head");
|
||||
const tag = el("span", "catchup-tag");
|
||||
tag.textContent =
|
||||
cmp.status === "ahead"
|
||||
? "Ahead of pace"
|
||||
: cmp.status === "behind"
|
||||
? "Behind pace"
|
||||
: "On track";
|
||||
const name = el("span", "catchup-name");
|
||||
name.textContent = label;
|
||||
head.append(name, tag);
|
||||
row.appendChild(head);
|
||||
|
||||
const detail = el("p", "catchup-detail");
|
||||
const diff = Math.abs(cmp.difference);
|
||||
if (cmp.status === "onTrack") {
|
||||
detail.textContent = `You're right about where you should be (${fmt(
|
||||
cmp.actual
|
||||
)} vs. an expected ${fmt(cmp.expected)}).`;
|
||||
} else if (cmp.status === "ahead") {
|
||||
detail.textContent = `You have ${fmt(diff)} more than the ${fmt(
|
||||
cmp.expected
|
||||
)} expected — you can afford to spend a bit more freely.`;
|
||||
} else {
|
||||
detail.textContent = `You have ${fmt(diff)} less than the ${fmt(
|
||||
cmp.expected
|
||||
)} expected — ease off to make it last.`;
|
||||
}
|
||||
row.appendChild(detail);
|
||||
|
||||
if (cmp.suggestedPerWeek !== null) {
|
||||
const pace = el("p", "catchup-pace");
|
||||
pace.textContent = `To use your balance evenly from here: about ${fmtPace(
|
||||
cmp.suggestedPerWeek
|
||||
)} ${unit}.`;
|
||||
row.appendChild(pace);
|
||||
}
|
||||
|
||||
return row;
|
||||
}
|
||||
|
||||
function bigMetric(
|
||||
value: string,
|
||||
unit: string,
|
||||
cmp: BalanceComparison | null
|
||||
): HTMLElement {
|
||||
const m = el("div", "big-metric");
|
||||
const v = el("span", "big-metric-value");
|
||||
v.textContent = value;
|
||||
const u = el("span", "metric-unit");
|
||||
const u = el("span", "big-metric-unit");
|
||||
u.textContent = unit;
|
||||
m.append(v, u);
|
||||
if (cmp) {
|
||||
const badge = el("span", `metric-badge badge-${cmp.status}`);
|
||||
badge.textContent =
|
||||
cmp.status === "ahead" ? "▲ ahead" : cmp.status === "behind" ? "▼ behind" : "● on track";
|
||||
m.appendChild(badge);
|
||||
}
|
||||
return m;
|
||||
}
|
||||
|
||||
function miniStat(
|
||||
label: string,
|
||||
blocks: number,
|
||||
flex: number,
|
||||
fmtBlk: (n: number) => string
|
||||
): HTMLElement {
|
||||
const box = el("div", "mini-stat");
|
||||
const h = el("span", "mini-label");
|
||||
h.textContent = label;
|
||||
box.appendChild(h);
|
||||
const vals = el("div", "mini-values");
|
||||
const b = el("span", "mini-value");
|
||||
b.textContent = `${fmtBlk(blocks)} blocks`;
|
||||
const f = el("span", "mini-value");
|
||||
f.textContent = `${fmtMoney(flex)} FLEX`;
|
||||
vals.append(b, f);
|
||||
box.appendChild(vals);
|
||||
return box;
|
||||
}
|
||||
|
||||
/* ---------- loading + config error ---------- */
|
||||
|
||||
function renderLoading(): void {
|
||||
@@ -280,6 +484,12 @@ function renderConfigError(errors: string[]): void {
|
||||
|
||||
/* ---------- formatting + DOM helpers ---------- */
|
||||
|
||||
/** Whole meal blocks — used everywhere except the suggested go-forward pace. */
|
||||
function fmtBlocksWhole(n: number): string {
|
||||
return Math.round(n).toLocaleString();
|
||||
}
|
||||
|
||||
/** Fractional meal blocks — used only for the suggested pace, where precision helps. */
|
||||
function fmtBlocks(n: number): string {
|
||||
return (Math.round(n * 10) / 10).toLocaleString(undefined, {
|
||||
minimumFractionDigits: 0,
|
||||
@@ -318,6 +528,12 @@ function field(label: string): HTMLElement {
|
||||
return f;
|
||||
}
|
||||
|
||||
function footerLine(text: string, extraClass = ""): HTMLElement {
|
||||
const p = el("p", `footer-line${extraClass ? ` ${extraClass}` : ""}`);
|
||||
p.textContent = text;
|
||||
return p;
|
||||
}
|
||||
|
||||
function spanTartan(text: string): HTMLElement {
|
||||
const s = el("span", "tartan");
|
||||
s.textContent = text;
|
||||
|
||||
+39
-19
@@ -3,7 +3,6 @@ import { readFileSync } from "node:fs";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { loadConfig } from "./configLoader";
|
||||
import { estimate } from "./engine";
|
||||
import { parseISODate } from "./dates";
|
||||
|
||||
// Read the actual shipped config that gets served at runtime.
|
||||
const configPath = fileURLToPath(new URL("../public/config.json", import.meta.url));
|
||||
@@ -16,45 +15,66 @@ describe("real config sanity", () => {
|
||||
expect(result.ok).toBe(true);
|
||||
});
|
||||
|
||||
it("produces sensible Green-plan numbers at the halfway point", () => {
|
||||
it("produces sensible first-plan numbers partway through the term", () => {
|
||||
if (!result.ok) throw new Error("config failed to load");
|
||||
const config = result.config;
|
||||
const plan = config.plans[0];
|
||||
|
||||
// Target a date roughly in the middle of the meal window.
|
||||
const midMs =
|
||||
(config.mealStartDate.getTime() + config.mealEndDate.getTime()) / 2;
|
||||
const midDate = new Date(midMs);
|
||||
|
||||
// Roughly mid-semester target.
|
||||
const r = estimate(config, {
|
||||
selectedPlanId: "green",
|
||||
targetDate: parseISODate("2026-10-18")!,
|
||||
selectedPlanId: plan.id,
|
||||
targetDate: midDate,
|
||||
activeMissingPeriodIds: new Set(),
|
||||
extraExcludedDays: 0,
|
||||
actualMealBlocks: null,
|
||||
actualFlexDollars: null,
|
||||
});
|
||||
|
||||
// Green = 210 blocks / 500 flex over ~112 days (Aug 24 -> Dec 13).
|
||||
expect(r.totalActiveDays).toBe(112);
|
||||
// per-week block rate ~ 210/112*7 = 13.125
|
||||
expect(r.perWeekMealBlocks).toBeCloseTo(13.125, 2);
|
||||
// consumed + remaining conserves the total
|
||||
expect(r.consumedMealBlocks + r.remainingMealBlocks).toBeCloseTo(210);
|
||||
expect(r.consumedFlexDollars + r.remainingFlexDollars).toBeCloseTo(500);
|
||||
// partway through, consumption is strictly between 0 and total
|
||||
// Per-week rate matches the plan total spread over the active days.
|
||||
expect(r.perWeekMealBlocks).toBeCloseTo(
|
||||
(plan.totalMealBlocks / r.totalActiveDays) * 7,
|
||||
2
|
||||
);
|
||||
// Consumed + remaining conserves the plan totals.
|
||||
expect(r.consumedMealBlocks + r.remainingMealBlocks).toBeCloseTo(
|
||||
plan.totalMealBlocks
|
||||
);
|
||||
expect(r.consumedFlexDollars + r.remainingFlexDollars).toBeCloseTo(
|
||||
plan.totalFlexDollars
|
||||
);
|
||||
// Partway through, consumption is strictly between 0 and the total.
|
||||
expect(r.consumedMealBlocks).toBeGreaterThan(0);
|
||||
expect(r.consumedMealBlocks).toBeLessThan(210);
|
||||
expect(r.consumedMealBlocks).toBeLessThan(plan.totalMealBlocks);
|
||||
});
|
||||
|
||||
it("checking a break reduces total active days", () => {
|
||||
if (!result.ok) throw new Error("config failed to load");
|
||||
const config = result.config;
|
||||
if (config.missingPeriods.length === 0) return; // nothing to check
|
||||
|
||||
const plan = config.plans[0];
|
||||
const period = config.missingPeriods[0];
|
||||
const endDate = config.mealEndDate;
|
||||
|
||||
const base = estimate(config, {
|
||||
selectedPlanId: "green",
|
||||
targetDate: parseISODate("2026-12-13")!,
|
||||
selectedPlanId: plan.id,
|
||||
targetDate: endDate,
|
||||
activeMissingPeriodIds: new Set(),
|
||||
extraExcludedDays: 0,
|
||||
actualMealBlocks: null,
|
||||
actualFlexDollars: null,
|
||||
});
|
||||
const withBreak = estimate(config, {
|
||||
selectedPlanId: "green",
|
||||
targetDate: parseISODate("2026-12-13")!,
|
||||
activeMissingPeriodIds: new Set(["thanksgiving"]),
|
||||
selectedPlanId: plan.id,
|
||||
targetDate: endDate,
|
||||
activeMissingPeriodIds: new Set([period.id]),
|
||||
extraExcludedDays: 0,
|
||||
actualMealBlocks: null,
|
||||
actualFlexDollars: null,
|
||||
});
|
||||
|
||||
expect(withBreak.totalActiveDays).toBeLessThan(base.totalActiveDays);
|
||||
|
||||
@@ -46,7 +46,21 @@ export class Store {
|
||||
this.update({ extraExcludedDays: Math.max(0, Math.floor(count) || 0) });
|
||||
}
|
||||
|
||||
setActualMealBlocks(value: number | null): void {
|
||||
this.update({ actualMealBlocks: normalizeBalance(value) });
|
||||
}
|
||||
|
||||
setActualFlexDollars(value: number | null): void {
|
||||
this.update({ actualFlexDollars: normalizeBalance(value) });
|
||||
}
|
||||
|
||||
private emit(): void {
|
||||
for (const l of this.listeners) l();
|
||||
}
|
||||
}
|
||||
|
||||
/** Clamp an entered balance to a non-negative number, or null if blank/invalid. */
|
||||
function normalizeBalance(value: number | null): number | null {
|
||||
if (value === null || !Number.isFinite(value)) return null;
|
||||
return Math.max(0, value);
|
||||
}
|
||||
|
||||
+223
-41
@@ -15,6 +15,12 @@
|
||||
--tartan: #c8102e;
|
||||
--tartan-soft: #e0384f;
|
||||
|
||||
/* Status */
|
||||
--ahead: #3fb27f;
|
||||
--ahead-dim: #2b8560;
|
||||
--behind: #e0a83a;
|
||||
--behind-dim: #a87d28;
|
||||
|
||||
--radius: 10px;
|
||||
--radius-sm: 7px;
|
||||
--gap: 20px;
|
||||
@@ -39,22 +45,22 @@ body {
|
||||
}
|
||||
|
||||
.wrap {
|
||||
max-width: 940px;
|
||||
max-width: 960px;
|
||||
margin: 0 auto;
|
||||
padding: 48px 24px 40px;
|
||||
padding: 30px 24px 28px;
|
||||
}
|
||||
|
||||
/* Header */
|
||||
.header {
|
||||
margin-bottom: 32px;
|
||||
margin-bottom: 22px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
padding-bottom: 24px;
|
||||
padding-bottom: 16px;
|
||||
}
|
||||
|
||||
.title {
|
||||
font-size: 30px;
|
||||
font-size: 26px;
|
||||
font-weight: 700;
|
||||
margin: 0 0 6px;
|
||||
margin: 0 0 4px;
|
||||
letter-spacing: -0.02em;
|
||||
}
|
||||
|
||||
@@ -65,7 +71,7 @@ body {
|
||||
.subtitle {
|
||||
margin: 0;
|
||||
color: var(--text-dim);
|
||||
font-size: 15px;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
/* Grid */
|
||||
@@ -87,27 +93,38 @@ body {
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
padding: 22px;
|
||||
padding: 18px;
|
||||
}
|
||||
|
||||
.section-title {
|
||||
font-size: 12px;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.08em;
|
||||
color: var(--text-faint);
|
||||
margin: 0 0 14px;
|
||||
margin: 0 0 11px;
|
||||
}
|
||||
|
||||
.controls .section-title:not(:first-child) {
|
||||
margin-top: 26px;
|
||||
padding-top: 22px;
|
||||
margin-top: 18px;
|
||||
padding-top: 16px;
|
||||
border-top: 1px solid var(--border);
|
||||
}
|
||||
|
||||
/* Fields */
|
||||
.field {
|
||||
margin-bottom: 16px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
/* Put plan + date side by side to shorten the controls column. */
|
||||
.field-pair {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
.field-pair .field {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.field:last-child {
|
||||
@@ -129,7 +146,7 @@ body {
|
||||
color: var(--text);
|
||||
border: 1px solid var(--border-strong);
|
||||
border-radius: var(--radius-sm);
|
||||
padding: 10px 12px;
|
||||
padding: 8px 11px;
|
||||
font-size: 14px;
|
||||
font-family: inherit;
|
||||
transition: border-color 0.12s ease;
|
||||
@@ -163,11 +180,11 @@ body {
|
||||
.checkbox-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
gap: 11px;
|
||||
background: var(--surface-2);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-sm);
|
||||
padding: 10px 12px;
|
||||
padding: 7px 11px;
|
||||
cursor: pointer;
|
||||
transition: border-color 0.12s ease;
|
||||
}
|
||||
@@ -201,6 +218,17 @@ body {
|
||||
font-family: "IBM Plex Mono", ui-monospace, monospace;
|
||||
}
|
||||
|
||||
/* Balance inputs */
|
||||
.balance-inputs {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
.balance-inputs .field {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
/* Results */
|
||||
.results-panel {
|
||||
position: sticky;
|
||||
@@ -208,7 +236,7 @@ body {
|
||||
}
|
||||
|
||||
.days-note {
|
||||
margin: -4px 0 18px;
|
||||
margin: -2px 0 14px;
|
||||
font-size: 13px;
|
||||
color: var(--text-dim);
|
||||
font-family: "IBM Plex Mono", ui-monospace, monospace;
|
||||
@@ -220,49 +248,188 @@ body {
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.result-group {
|
||||
/* Primary: Remaining */
|
||||
.remaining-card {
|
||||
background: var(--surface-2);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-sm);
|
||||
padding: 16px 18px;
|
||||
}
|
||||
|
||||
.result-group:first-child {
|
||||
border: 1px solid var(--border-strong);
|
||||
border-left: 3px solid var(--tartan);
|
||||
border-radius: var(--radius);
|
||||
padding: 16px 18px 15px;
|
||||
}
|
||||
|
||||
.result-label {
|
||||
margin: 0 0 12px;
|
||||
.remaining-label {
|
||||
margin: 0 0 10px;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.06em;
|
||||
color: var(--text-dim);
|
||||
}
|
||||
|
||||
.result-row {
|
||||
.remaining-row {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 16px;
|
||||
gap: 18px;
|
||||
}
|
||||
|
||||
.metric {
|
||||
.big-metric {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 3px;
|
||||
}
|
||||
|
||||
.big-metric-value {
|
||||
font-family: "IBM Plex Mono", ui-monospace, monospace;
|
||||
font-size: 36px;
|
||||
font-weight: 600;
|
||||
line-height: 1.05;
|
||||
letter-spacing: -0.02em;
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.big-metric-unit {
|
||||
font-size: 12px;
|
||||
color: var(--text-faint);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
}
|
||||
|
||||
.metric-badge {
|
||||
margin-top: 6px;
|
||||
align-self: flex-start;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
padding: 2px 8px;
|
||||
border-radius: 999px;
|
||||
border: 1px solid var(--border-strong);
|
||||
color: var(--text-dim);
|
||||
}
|
||||
|
||||
.badge-ahead {
|
||||
color: var(--ahead);
|
||||
border-color: var(--ahead-dim);
|
||||
}
|
||||
|
||||
.badge-behind {
|
||||
color: var(--behind);
|
||||
border-color: var(--behind-dim);
|
||||
}
|
||||
|
||||
.remaining-note {
|
||||
margin: 12px 0 0;
|
||||
font-size: 12px;
|
||||
line-height: 1.45;
|
||||
color: var(--text-faint);
|
||||
padding-top: 11px;
|
||||
border-top: 1px solid var(--border);
|
||||
}
|
||||
|
||||
/* Catch-up guidance */
|
||||
.catchup {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
margin-top: 14px;
|
||||
}
|
||||
|
||||
.catchup-row {
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border);
|
||||
border-left: 3px solid var(--border-strong);
|
||||
border-radius: var(--radius-sm);
|
||||
padding: 11px 13px;
|
||||
}
|
||||
|
||||
.catchup-ahead {
|
||||
border-left-color: var(--ahead);
|
||||
}
|
||||
|
||||
.catchup-behind {
|
||||
border-left-color: var(--behind);
|
||||
}
|
||||
|
||||
.catchup-onTrack {
|
||||
border-left-color: var(--text-faint);
|
||||
}
|
||||
|
||||
.catchup-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 10px;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.catchup-name {
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.catchup-tag {
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
color: var(--text-dim);
|
||||
}
|
||||
|
||||
.catchup-ahead .catchup-tag {
|
||||
color: var(--ahead);
|
||||
}
|
||||
|
||||
.catchup-behind .catchup-tag {
|
||||
color: var(--behind);
|
||||
}
|
||||
|
||||
.catchup-detail {
|
||||
margin: 0;
|
||||
font-size: 13px;
|
||||
color: var(--text-dim);
|
||||
}
|
||||
|
||||
.catchup-pace {
|
||||
margin: 6px 0 0;
|
||||
font-size: 12px;
|
||||
color: var(--text-faint);
|
||||
font-family: "IBM Plex Mono", ui-monospace, monospace;
|
||||
}
|
||||
|
||||
/* Secondary stats: per-week + consumed */
|
||||
.result-secondary {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.mini-stat {
|
||||
background: var(--surface-2);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-sm);
|
||||
padding: 12px 14px;
|
||||
}
|
||||
|
||||
.mini-label {
|
||||
display: block;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
color: var(--text-faint);
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
.mini-values {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.metric-value {
|
||||
.mini-value {
|
||||
font-family: "IBM Plex Mono", ui-monospace, monospace;
|
||||
font-size: 26px;
|
||||
font-weight: 600;
|
||||
letter-spacing: -0.01em;
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.metric-unit {
|
||||
font-size: 12px;
|
||||
color: var(--text-faint);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
font-size: 15px;
|
||||
font-weight: 500;
|
||||
color: var(--text-dim);
|
||||
}
|
||||
|
||||
/* Footer */
|
||||
@@ -270,8 +437,23 @@ body {
|
||||
margin-top: 28px;
|
||||
padding-top: 20px;
|
||||
border-top: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.footer-line {
|
||||
margin: 0 0 8px;
|
||||
font-size: 12px;
|
||||
color: var(--text-faint);
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.footer-line:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.footer-disclaimer {
|
||||
font-style: italic;
|
||||
color: var(--text-faint);
|
||||
opacity: 0.85;
|
||||
}
|
||||
|
||||
/* Config error */
|
||||
|
||||
@@ -31,6 +31,30 @@ export interface Selections {
|
||||
targetDate: Date;
|
||||
activeMissingPeriodIds: Set<string>;
|
||||
extraExcludedDays: number;
|
||||
/** The user's actual current meal-block balance, or null if not entered. */
|
||||
actualMealBlocks: number | null;
|
||||
/** The user's actual current FLEX-dollar balance, or null if not entered. */
|
||||
actualFlexDollars: number | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Compares an actual balance the user entered against the on-pace estimate for
|
||||
* a single resource (blocks or FLEX).
|
||||
*/
|
||||
export interface BalanceComparison {
|
||||
/** What the user actually has left (as entered). */
|
||||
actual: number;
|
||||
/** What an on-pace student would have left right now. */
|
||||
expected: number;
|
||||
/** actual - expected. Positive = ahead (has more than expected). */
|
||||
difference: number;
|
||||
/** "ahead" | "behind" | "onTrack" (within a small tolerance). */
|
||||
status: "ahead" | "behind" | "onTrack";
|
||||
/**
|
||||
* Go-forward rate that would exactly use up the actual balance over the
|
||||
* remaining active days, expressed per week. Null if no active days remain.
|
||||
*/
|
||||
suggestedPerWeek: number | null;
|
||||
}
|
||||
|
||||
/** Output of the estimation engine. */
|
||||
@@ -43,6 +67,12 @@ export interface EstimationResult {
|
||||
remainingFlexDollars: number;
|
||||
totalActiveDays: number;
|
||||
activeDaysConsumed: number;
|
||||
/** Active days remaining after the target date (used for catch-up math). */
|
||||
activeDaysRemaining: number;
|
||||
/** Present only when the user entered an actual meal-block balance. */
|
||||
blockComparison: BalanceComparison | null;
|
||||
/** Present only when the user entered an actual FLEX-dollar balance. */
|
||||
flexComparison: BalanceComparison | null;
|
||||
}
|
||||
|
||||
/** Result of loading + validating the raw config. */
|
||||
|
||||
Reference in New Issue
Block a user