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. */