diff --git a/index.html b/index.html
index 22437e1..8b312d9 100644
--- a/index.html
+++ b/index.html
@@ -4,6 +4,9 @@
CMU Meal Estimator
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/engine.test.ts b/src/engine.test.ts
index 9a1c71c..38ed87a 100644
--- a/src/engine.test.ts
+++ b/src/engine.test.ts
@@ -27,6 +27,8 @@ function sel(overrides: Partial = {}): 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();
+ });
});
diff --git a/src/engine.ts b/src/engine.ts
index 665a04e..4b8f284 100644
--- a/src/engine.ts
+++ b/src/engine.ts
@@ -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): 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));
}
diff --git a/src/main.ts b/src/main.ts
index 2b03c24..b6adf35 100644
--- a/src/main.ts
+++ b/src/main.ts
@@ -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 {
/**
* 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);
+ }
+
+ return card;
}
-function metric(value: string, unit: string): HTMLElement {
- const m = el("div", "metric");
- const v = el("span", "metric-value");
+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;
diff --git a/src/sanity.test.ts b/src/sanity.test.ts
index 65d1860..8cb44d3 100644
--- a/src/sanity.test.ts
+++ b/src/sanity.test.ts
@@ -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);
diff --git a/src/store.ts b/src/store.ts
index 8148ac0..9b6fee8 100644
--- a/src/store.ts
+++ b/src/store.ts
@@ -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);
+}
diff --git a/src/style.css b/src/style.css
index 9bab462..3120dd0 100644
--- a/src/style.css
+++ b/src/style.css
@@ -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 */
diff --git a/src/types.ts b/src/types.ts
index 6bf9f76..b66ab8b 100644
--- a/src/types.ts
+++ b/src/types.ts
@@ -31,6 +31,30 @@ export interface Selections {
targetDate: Date;
activeMissingPeriodIds: Set;
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. */