functional
This commit is contained in:
@@ -0,0 +1,23 @@
|
||||
{
|
||||
"$comment": "Edit this file to configure the estimator. Dates are ISO YYYY-MM-DD. totalMealBlocks and totalFlexDollars are the amounts for the whole meal period. Add/remove plans and missingPeriods freely; the UI updates reactively. Set exact figures from your CMU dining agreement.",
|
||||
|
||||
"mealStartDate": "2026-08-24",
|
||||
"mealEndDate": "2026-12-13",
|
||||
|
||||
"plans": [
|
||||
{ "id": "green", "displayName": "Green", "totalMealBlocks": 210, "totalFlexDollars": 500 },
|
||||
{ "id": "blue", "displayName": "Blue", "totalMealBlocks": 285, "totalFlexDollars": 450 },
|
||||
{ "id": "red", "displayName": "Red", "totalMealBlocks": 175, "totalFlexDollars": 625 },
|
||||
{ "id": "yellow", "displayName": "Yellow", "totalMealBlocks": 140, "totalFlexDollars": 725 },
|
||||
{ "id": "tartan-flex", "displayName": "Tartan Flex", "totalMealBlocks": 105, "totalFlexDollars": 900 },
|
||||
{ "id": "scottys-choice", "displayName": "Scotty's Choice", "totalMealBlocks": 245, "totalFlexDollars": 400 },
|
||||
{ "id": "whitfields-favor", "displayName": "Whitfield's Favor", "totalMealBlocks": 300, "totalFlexDollars": 350 },
|
||||
{ "id": "piper-select", "displayName": "Piper Select", "totalMealBlocks": 70, "totalFlexDollars": 1100 }
|
||||
],
|
||||
|
||||
"missingPeriods": [
|
||||
{ "id": "labor-day", "displayName": "Labor Day", "startDate": "2026-09-07", "endDate": "2026-09-07" },
|
||||
{ "id": "fall-break", "displayName": "Fall Break", "startDate": "2026-10-19", "endDate": "2026-10-20" },
|
||||
{ "id": "thanksgiving", "displayName": "Thanksgiving Break", "startDate": "2026-11-25", "endDate": "2026-11-29" }
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,203 @@
|
||||
import type { ConfigResult, MissingPeriod, Plan } from "./types";
|
||||
import { daysBetween, parseISODate } from "./dates";
|
||||
|
||||
/**
|
||||
* Validate and normalize a raw (parsed JSON) config object into a typed Config,
|
||||
* or return a list of human-readable errors. Pure: no I/O.
|
||||
*/
|
||||
export function loadConfig(raw: unknown): ConfigResult {
|
||||
const errors: string[] = [];
|
||||
|
||||
if (typeof raw !== "object" || raw === null) {
|
||||
return { ok: false, errors: ["Config must be a JSON object."] };
|
||||
}
|
||||
const obj = raw as Record<string, unknown>;
|
||||
|
||||
const mealStartDate = requireDate(obj.mealStartDate, "mealStartDate", errors);
|
||||
const mealEndDate = requireDate(obj.mealEndDate, "mealEndDate", errors);
|
||||
|
||||
if (mealStartDate && mealEndDate && daysBetween(mealStartDate, mealEndDate) <= 0) {
|
||||
errors.push("mealEndDate must be strictly after mealStartDate.");
|
||||
}
|
||||
|
||||
const plans = parsePlans(obj.plans, errors);
|
||||
const missingPeriods = parseMissingPeriods(
|
||||
obj.missingPeriods,
|
||||
mealStartDate,
|
||||
mealEndDate,
|
||||
errors
|
||||
);
|
||||
|
||||
if (errors.length > 0 || !mealStartDate || !mealEndDate) {
|
||||
return { ok: false, errors };
|
||||
}
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
config: { mealStartDate, mealEndDate, plans, missingPeriods },
|
||||
};
|
||||
}
|
||||
|
||||
function requireDate(
|
||||
value: unknown,
|
||||
field: string,
|
||||
errors: string[]
|
||||
): Date | null {
|
||||
if (typeof value !== "string") {
|
||||
errors.push(`${field} is required and must be a "YYYY-MM-DD" string.`);
|
||||
return null;
|
||||
}
|
||||
const parsed = parseISODate(value);
|
||||
if (!parsed) {
|
||||
errors.push(`${field} "${value}" is not a valid YYYY-MM-DD date.`);
|
||||
return null;
|
||||
}
|
||||
return parsed;
|
||||
}
|
||||
|
||||
function parsePlans(value: unknown, errors: string[]): Plan[] {
|
||||
if (!Array.isArray(value) || value.length === 0) {
|
||||
errors.push("plans must be a non-empty array.");
|
||||
return [];
|
||||
}
|
||||
|
||||
const plans: Plan[] = [];
|
||||
const seenIds = new Set<string>();
|
||||
|
||||
value.forEach((entry, i) => {
|
||||
const label = `plans[${i}]`;
|
||||
if (typeof entry !== "object" || entry === null) {
|
||||
errors.push(`${label} must be an object.`);
|
||||
return;
|
||||
}
|
||||
const p = entry as Record<string, unknown>;
|
||||
|
||||
const id = requireNonEmptyString(p.id, `${label}.id`, errors);
|
||||
const displayName = requireNonEmptyString(
|
||||
p.displayName,
|
||||
`${label}.displayName`,
|
||||
errors
|
||||
);
|
||||
const totalMealBlocks = requireNonNegativeNumber(
|
||||
p.totalMealBlocks,
|
||||
`${label}.totalMealBlocks`,
|
||||
errors
|
||||
);
|
||||
const totalFlexDollars = requireNonNegativeNumber(
|
||||
p.totalFlexDollars,
|
||||
`${label}.totalFlexDollars`,
|
||||
errors
|
||||
);
|
||||
|
||||
if (id !== null) {
|
||||
if (seenIds.has(id)) {
|
||||
errors.push(`Duplicate plan id "${id}".`);
|
||||
}
|
||||
seenIds.add(id);
|
||||
}
|
||||
|
||||
if (
|
||||
id !== null &&
|
||||
displayName !== null &&
|
||||
totalMealBlocks !== null &&
|
||||
totalFlexDollars !== null
|
||||
) {
|
||||
plans.push({ id, displayName, totalMealBlocks, totalFlexDollars });
|
||||
}
|
||||
});
|
||||
|
||||
return plans;
|
||||
}
|
||||
|
||||
function parseMissingPeriods(
|
||||
value: unknown,
|
||||
mealStartDate: Date | null,
|
||||
mealEndDate: Date | null,
|
||||
errors: string[]
|
||||
): MissingPeriod[] {
|
||||
if (value === undefined || value === null) return [];
|
||||
if (!Array.isArray(value)) {
|
||||
errors.push("missingPeriods must be an array (or omitted).");
|
||||
return [];
|
||||
}
|
||||
|
||||
const periods: MissingPeriod[] = [];
|
||||
const seenIds = new Set<string>();
|
||||
|
||||
value.forEach((entry, i) => {
|
||||
const label = `missingPeriods[${i}]`;
|
||||
if (typeof entry !== "object" || entry === null) {
|
||||
errors.push(`${label} must be an object.`);
|
||||
return;
|
||||
}
|
||||
const m = entry as Record<string, unknown>;
|
||||
|
||||
const id = requireNonEmptyString(m.id, `${label}.id`, errors);
|
||||
const displayName = requireNonEmptyString(
|
||||
m.displayName,
|
||||
`${label}.displayName`,
|
||||
errors
|
||||
);
|
||||
const startDate = requireDate(m.startDate, `${label}.startDate`, errors);
|
||||
const endDate = requireDate(m.endDate, `${label}.endDate`, errors);
|
||||
|
||||
if (startDate && endDate && daysBetween(startDate, endDate) < 0) {
|
||||
errors.push(`${label}: startDate must be on or before endDate.`);
|
||||
}
|
||||
|
||||
if (id !== null) {
|
||||
if (seenIds.has(id)) {
|
||||
errors.push(`Duplicate missing period id "${id}".`);
|
||||
}
|
||||
seenIds.add(id);
|
||||
}
|
||||
|
||||
// Warn (non-fatal) if the period falls outside the meal window.
|
||||
if (startDate && endDate && mealStartDate && mealEndDate) {
|
||||
const outside =
|
||||
daysBetween(mealStartDate, endDate) < 0 ||
|
||||
daysBetween(startDate, mealEndDate) < 0;
|
||||
if (outside) {
|
||||
console.warn(
|
||||
`${label} "${displayName ?? id}" falls outside the meal period; it will have no effect.`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (
|
||||
id !== null &&
|
||||
displayName !== null &&
|
||||
startDate !== null &&
|
||||
endDate !== null &&
|
||||
daysBetween(startDate, endDate) >= 0
|
||||
) {
|
||||
periods.push({ id, displayName, startDate, endDate });
|
||||
}
|
||||
});
|
||||
|
||||
return periods;
|
||||
}
|
||||
|
||||
function requireNonEmptyString(
|
||||
value: unknown,
|
||||
field: string,
|
||||
errors: string[]
|
||||
): string | null {
|
||||
if (typeof value !== "string" || value.trim() === "") {
|
||||
errors.push(`${field} is required and must be a non-empty string.`);
|
||||
return null;
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function requireNonNegativeNumber(
|
||||
value: unknown,
|
||||
field: string,
|
||||
errors: string[]
|
||||
): number | null {
|
||||
if (typeof value !== "number" || !Number.isFinite(value) || value < 0) {
|
||||
errors.push(`${field} is required and must be a number >= 0.`);
|
||||
return null;
|
||||
}
|
||||
return value;
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
/**
|
||||
* Date helpers that work in whole calendar days at local midnight, so day
|
||||
* counting is not affected by time-of-day or DST shifts.
|
||||
*/
|
||||
|
||||
const MS_PER_DAY = 24 * 60 * 60 * 1000;
|
||||
|
||||
/** Strip time; return a new Date at local midnight. */
|
||||
export function atMidnight(d: Date): Date {
|
||||
return new Date(d.getFullYear(), d.getMonth(), d.getDate());
|
||||
}
|
||||
|
||||
/** Parse an ISO YYYY-MM-DD string as a local-midnight Date. Returns null if invalid. */
|
||||
export function parseISODate(value: string): Date | null {
|
||||
const match = /^(\d{4})-(\d{2})-(\d{2})$/.exec(value.trim());
|
||||
if (!match) return null;
|
||||
const [, y, m, d] = match;
|
||||
const year = Number(y);
|
||||
const month = Number(m);
|
||||
const day = Number(d);
|
||||
const date = new Date(year, month - 1, day);
|
||||
// Reject overflow (e.g. 2026-02-30 rolling into March).
|
||||
if (
|
||||
date.getFullYear() !== year ||
|
||||
date.getMonth() !== month - 1 ||
|
||||
date.getDate() !== day
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
return date;
|
||||
}
|
||||
|
||||
/** Format a Date as ISO YYYY-MM-DD (local). */
|
||||
export function toISODate(d: Date): string {
|
||||
const y = d.getFullYear();
|
||||
const m = String(d.getMonth() + 1).padStart(2, "0");
|
||||
const day = String(d.getDate()).padStart(2, "0");
|
||||
return `${y}-${m}-${day}`;
|
||||
}
|
||||
|
||||
/** Whole days from a to b (b - a). Negative if b is before a. */
|
||||
export function daysBetween(a: Date, b: Date): number {
|
||||
const diff = atMidnight(b).getTime() - atMidnight(a).getTime();
|
||||
return Math.round(diff / MS_PER_DAY);
|
||||
}
|
||||
|
||||
/**
|
||||
* Inclusive day count of [start, end]. Zero if end < start.
|
||||
* e.g. same day => 1.
|
||||
*/
|
||||
export function inclusiveDayCount(start: Date, end: Date): number {
|
||||
const n = daysBetween(start, end) + 1;
|
||||
return n > 0 ? n : 0;
|
||||
}
|
||||
|
||||
/** Return the later of two dates. */
|
||||
export function maxDate(a: Date, b: Date): Date {
|
||||
return a.getTime() >= b.getTime() ? a : b;
|
||||
}
|
||||
|
||||
/** Return the earlier of two dates. */
|
||||
export function minDate(a: Date, b: Date): Date {
|
||||
return a.getTime() <= b.getTime() ? a : b;
|
||||
}
|
||||
|
||||
/** Clamp d into [lo, hi]. */
|
||||
export function clampDate(d: Date, lo: Date, hi: Date): Date {
|
||||
return maxDate(lo, minDate(d, hi));
|
||||
}
|
||||
|
||||
/**
|
||||
* Count days of the inclusive window [winStart, winEnd] that are covered by the
|
||||
* inclusive period [perStart, perEnd] — i.e. the size of the overlap.
|
||||
*/
|
||||
export function overlapDays(
|
||||
winStart: Date,
|
||||
winEnd: Date,
|
||||
perStart: Date,
|
||||
perEnd: Date
|
||||
): number {
|
||||
const start = maxDate(winStart, perStart);
|
||||
const end = minDate(winEnd, perEnd);
|
||||
return inclusiveDayCount(start, end);
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { estimate, totalActiveDays, activeDaysConsumed } from "./engine";
|
||||
import type { Config, Selections } from "./types";
|
||||
import { parseISODate } from "./dates";
|
||||
|
||||
function d(iso: string): Date {
|
||||
const parsed = parseISODate(iso);
|
||||
if (!parsed) throw new Error(`bad test date ${iso}`);
|
||||
return parsed;
|
||||
}
|
||||
|
||||
// A simple 100-day window (Jan 1 .. Apr 10, 2026 is 100 inclusive days).
|
||||
const config: Config = {
|
||||
mealStartDate: d("2026-01-01"),
|
||||
mealEndDate: d("2026-04-10"),
|
||||
plans: [
|
||||
{ id: "green", displayName: "Green", totalMealBlocks: 200, totalFlexDollars: 500 },
|
||||
],
|
||||
missingPeriods: [
|
||||
{ id: "break", displayName: "Break", startDate: d("2026-02-01"), endDate: d("2026-02-10") }, // 10 days
|
||||
],
|
||||
};
|
||||
|
||||
function sel(overrides: Partial<Selections> = {}): Selections {
|
||||
return {
|
||||
selectedPlanId: "green",
|
||||
targetDate: d("2026-04-10"),
|
||||
activeMissingPeriodIds: new Set(),
|
||||
extraExcludedDays: 0,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe("totalActiveDays", () => {
|
||||
it("counts the full inclusive window with no missing periods", () => {
|
||||
expect(totalActiveDays(config, new Set())).toBe(100);
|
||||
});
|
||||
|
||||
it("subtracts an active missing period", () => {
|
||||
expect(totalActiveDays(config, new Set(["break"]))).toBe(90);
|
||||
});
|
||||
});
|
||||
|
||||
describe("activeDaysConsumed", () => {
|
||||
it("is the full window at the end date", () => {
|
||||
expect(activeDaysConsumed(config, sel())).toBe(100);
|
||||
});
|
||||
|
||||
it("clamps a target date before the start to zero", () => {
|
||||
expect(activeDaysConsumed(config, sel({ targetDate: d("2025-12-01") }))).toBe(0);
|
||||
});
|
||||
|
||||
it("subtracts extra excluded days", () => {
|
||||
expect(activeDaysConsumed(config, sel({ extraExcludedDays: 5 }))).toBe(95);
|
||||
});
|
||||
});
|
||||
|
||||
describe("estimate", () => {
|
||||
it("conserves the budget at the end date with no exclusions", () => {
|
||||
const r = estimate(config, sel());
|
||||
expect(r.consumedMealBlocks).toBeCloseTo(200);
|
||||
expect(r.remainingMealBlocks).toBeCloseTo(0);
|
||||
expect(r.consumedFlexDollars).toBeCloseTo(500);
|
||||
expect(r.remainingFlexDollars).toBeCloseTo(0);
|
||||
});
|
||||
|
||||
it("consumed + remaining always equals the plan total", () => {
|
||||
const r = estimate(config, sel({ targetDate: d("2026-02-15"), activeMissingPeriodIds: new Set(["break"]) }));
|
||||
expect(r.consumedMealBlocks + r.remainingMealBlocks).toBeCloseTo(200);
|
||||
expect(r.consumedFlexDollars + r.remainingFlexDollars).toBeCloseTo(500);
|
||||
});
|
||||
|
||||
it("computes a per-week rate of dailyRate * 7", () => {
|
||||
const r = estimate(config, sel());
|
||||
// 200 blocks / 100 days = 2/day => 14/week
|
||||
expect(r.perWeekMealBlocks).toBeCloseTo(14);
|
||||
expect(r.perWeekFlexDollars).toBeCloseTo(35); // 500/100*7
|
||||
});
|
||||
|
||||
it("never lets excluding days increase consumption (monotonicity)", () => {
|
||||
const base = estimate(config, sel({ targetDate: d("2026-03-01") }));
|
||||
const more = estimate(config, sel({ targetDate: d("2026-03-01"), extraExcludedDays: 10 }));
|
||||
expect(more.consumedMealBlocks).toBeLessThanOrEqual(base.consumedMealBlocks);
|
||||
});
|
||||
|
||||
it("handles zero active days without dividing by zero", () => {
|
||||
const allExcluded: Config = {
|
||||
...config,
|
||||
missingPeriods: [
|
||||
{ id: "all", displayName: "All", startDate: d("2026-01-01"), endDate: d("2026-04-10") },
|
||||
],
|
||||
};
|
||||
const r = estimate(allExcluded, sel({ activeMissingPeriodIds: new Set(["all"]) }));
|
||||
expect(r.perWeekMealBlocks).toBe(0);
|
||||
expect(r.consumedMealBlocks).toBe(0);
|
||||
expect(Number.isFinite(r.remainingMealBlocks)).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,93 @@
|
||||
import type { Config, EstimationResult, MissingPeriod, Plan, Selections } from "./types";
|
||||
import { clampDate, daysBetween, inclusiveDayCount, overlapDays } from "./dates";
|
||||
|
||||
/** The active missing periods for a set of ids, in config order. */
|
||||
function activePeriods(config: Config, ids: Set<string>): MissingPeriod[] {
|
||||
return config.missingPeriods.filter((p) => ids.has(p.id));
|
||||
}
|
||||
|
||||
/**
|
||||
* Days a student is expected to be on campus across the full meal window,
|
||||
* after removing days covered by the active missing periods.
|
||||
* Overlapping/adjacent periods are handled by summing per-period overlap with
|
||||
* the window; periods in the config are assumed non-overlapping (typical for
|
||||
* distinct breaks). Result is clamped to >= 0.
|
||||
*/
|
||||
export function totalActiveDays(config: Config, activeIds: Set<string>): number {
|
||||
const { mealStartDate, mealEndDate } = config;
|
||||
const windowDays = inclusiveDayCount(mealStartDate, mealEndDate);
|
||||
|
||||
let excluded = 0;
|
||||
for (const p of activePeriods(config, activeIds)) {
|
||||
excluded += overlapDays(mealStartDate, mealEndDate, p.startDate, p.endDate);
|
||||
}
|
||||
|
||||
return Math.max(0, windowDays - excluded);
|
||||
}
|
||||
|
||||
/**
|
||||
* Active days from meal start up to (and including) the target date, minus
|
||||
* active missing periods within that window, minus extra excluded days.
|
||||
* The target date is clamped into the meal window first. Result clamped >= 0.
|
||||
*/
|
||||
export function activeDaysConsumed(config: Config, selections: Selections): number {
|
||||
const { mealStartDate, mealEndDate } = config;
|
||||
|
||||
// Before the meal period even begins, nothing is consumed yet.
|
||||
if (daysBetween(mealStartDate, selections.targetDate) < 0) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
const target = clampDate(selections.targetDate, mealStartDate, mealEndDate);
|
||||
|
||||
const windowDays = inclusiveDayCount(mealStartDate, target);
|
||||
|
||||
let excluded = 0;
|
||||
for (const p of activePeriods(config, selections.activeMissingPeriodIds)) {
|
||||
excluded += overlapDays(mealStartDate, target, p.startDate, p.endDate);
|
||||
}
|
||||
|
||||
const consumed = windowDays - excluded - Math.max(0, selections.extraExcludedDays);
|
||||
return Math.max(0, consumed);
|
||||
}
|
||||
|
||||
function findPlan(config: Config, planId: string): Plan | undefined {
|
||||
return config.plans.find((p) => p.id === planId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Master estimate. Distributes a plan's totals evenly across total active days,
|
||||
* then reports per-week rate, consumed-by-target, and remaining for both meal
|
||||
* blocks and FLEX dollars. All output values are clamped to sane bounds.
|
||||
*/
|
||||
export function estimate(config: Config, selections: Selections): EstimationResult {
|
||||
const plan = findPlan(config, selections.selectedPlanId) ?? config.plans[0];
|
||||
|
||||
const total = totalActiveDays(config, selections.activeMissingPeriodIds);
|
||||
const consumedDays = Math.min(
|
||||
activeDaysConsumed(config, selections),
|
||||
total
|
||||
);
|
||||
|
||||
// Guard against division by zero when every day is excluded.
|
||||
const blockRate = total > 0 ? plan.totalMealBlocks / total : 0;
|
||||
const flexRate = total > 0 ? plan.totalFlexDollars / total : 0;
|
||||
|
||||
const consumedMealBlocks = clamp(blockRate * consumedDays, 0, plan.totalMealBlocks);
|
||||
const consumedFlexDollars = clamp(flexRate * consumedDays, 0, plan.totalFlexDollars);
|
||||
|
||||
return {
|
||||
perWeekMealBlocks: blockRate * 7,
|
||||
perWeekFlexDollars: flexRate * 7,
|
||||
consumedMealBlocks,
|
||||
consumedFlexDollars,
|
||||
remainingMealBlocks: plan.totalMealBlocks - consumedMealBlocks,
|
||||
remainingFlexDollars: plan.totalFlexDollars - consumedFlexDollars,
|
||||
totalActiveDays: total,
|
||||
activeDaysConsumed: consumedDays,
|
||||
};
|
||||
}
|
||||
|
||||
function clamp(v: number, lo: number, hi: number): number {
|
||||
return Math.min(hi, Math.max(lo, v));
|
||||
}
|
||||
+280
@@ -0,0 +1,280 @@
|
||||
import "./style.css";
|
||||
import rawConfig from "./config.json";
|
||||
import { loadConfig } from "./configLoader";
|
||||
import { estimate } from "./engine";
|
||||
import { Store } from "./store";
|
||||
import { toISODate, parseISODate, atMidnight } from "./dates";
|
||||
import type { Config, EstimationResult } from "./types";
|
||||
|
||||
const appEl = document.getElementById("app")!;
|
||||
|
||||
const result = loadConfig(rawConfig);
|
||||
if (!result.ok) {
|
||||
renderConfigError(result.errors);
|
||||
} else {
|
||||
bootstrap(result.config);
|
||||
}
|
||||
|
||||
function bootstrap(config: Config): void {
|
||||
const today = atMidnight(new Date());
|
||||
|
||||
const store = new Store(config, {
|
||||
selectedPlanId: config.plans[0].id,
|
||||
targetDate: today,
|
||||
activeMissingPeriodIds: new Set(),
|
||||
extraExcludedDays: 0,
|
||||
});
|
||||
|
||||
appEl.innerHTML = "";
|
||||
appEl.appendChild(buildLayout(store, config));
|
||||
|
||||
const resultsMount = appEl.querySelector<HTMLElement>("#results")!;
|
||||
const daysNote = appEl.querySelector<HTMLElement>("#days-note")!;
|
||||
|
||||
const rerender = () => {
|
||||
const r = estimate(config, store.getSelections());
|
||||
resultsMount.innerHTML = "";
|
||||
resultsMount.appendChild(renderResults(r));
|
||||
daysNote.textContent = `${fmtDays(r.activeDaysConsumed)} of ${fmtDays(
|
||||
r.totalActiveDays
|
||||
)} on-campus days counted.`;
|
||||
};
|
||||
|
||||
store.onChange(rerender);
|
||||
rerender();
|
||||
}
|
||||
|
||||
/* ---------- layout ---------- */
|
||||
|
||||
function buildLayout(store: Store, config: Config): HTMLElement {
|
||||
const wrap = el("div", "wrap");
|
||||
|
||||
// Header
|
||||
const header = el("header", "header");
|
||||
const title = el("h1", "title");
|
||||
title.append(spanTartan("CMU"), document.createTextNode(" Meal Estimator"));
|
||||
const subtitle = el("p", "subtitle");
|
||||
subtitle.textContent =
|
||||
"Estimate the meal blocks and FLEX dollars you should have used by a given date.";
|
||||
header.append(title, subtitle);
|
||||
|
||||
// Two-column body
|
||||
const grid = el("div", "grid");
|
||||
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.";
|
||||
|
||||
wrap.append(header, grid, footer);
|
||||
return wrap;
|
||||
}
|
||||
|
||||
function buildControls(store: Store, config: Config): HTMLElement {
|
||||
const panel = el("section", "panel controls");
|
||||
panel.append(sectionTitle("Your plan"));
|
||||
|
||||
// Plan selector
|
||||
const planField = field("Meal plan");
|
||||
const select = document.createElement("select");
|
||||
select.className = "select";
|
||||
select.id = "plan-select";
|
||||
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`;
|
||||
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";
|
||||
date.className = "input";
|
||||
date.id = "target-date";
|
||||
date.min = toISODate(config.mealStartDate);
|
||||
date.max = toISODate(config.mealEndDate);
|
||||
date.value = toISODate(store.getSelections().targetDate);
|
||||
date.addEventListener("change", () => {
|
||||
const parsed = parseISODate(date.value);
|
||||
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
|
||||
panel.append(sectionTitle("Time away"));
|
||||
if (config.missingPeriods.length === 0) {
|
||||
const none = el("p", "hint");
|
||||
none.textContent = "No breaks configured.";
|
||||
panel.appendChild(none);
|
||||
} else {
|
||||
const list = el("div", "checkbox-list");
|
||||
for (const period of config.missingPeriods) {
|
||||
const row = el("label", "checkbox-row");
|
||||
const cb = document.createElement("input");
|
||||
cb.type = "checkbox";
|
||||
cb.value = period.id;
|
||||
cb.addEventListener("change", () =>
|
||||
store.setMissingPeriodActive(period.id, cb.checked)
|
||||
);
|
||||
const text = el("span", "checkbox-text");
|
||||
const name = el("span", "checkbox-name");
|
||||
name.textContent = period.displayName;
|
||||
const dates = el("span", "checkbox-dates");
|
||||
dates.textContent = `${toISODate(period.startDate)} → ${toISODate(
|
||||
period.endDate
|
||||
)}`;
|
||||
text.append(name, dates);
|
||||
row.append(cb, text);
|
||||
list.appendChild(row);
|
||||
}
|
||||
panel.appendChild(list);
|
||||
const hint = el("p", "hint");
|
||||
hint.textContent = "Check any breaks you spent off campus.";
|
||||
panel.appendChild(hint);
|
||||
}
|
||||
|
||||
// Extra excluded days
|
||||
const extraField = field("Additional days to exclude");
|
||||
const extra = document.createElement("input");
|
||||
extra.type = "number";
|
||||
extra.min = "0";
|
||||
extra.step = "1";
|
||||
extra.value = "0";
|
||||
extra.className = "input";
|
||||
extra.id = "extra-days";
|
||||
extra.addEventListener("input", () =>
|
||||
store.setExtraExcludedDays(Number(extra.value))
|
||||
);
|
||||
extraField.appendChild(extra);
|
||||
const extraHint = el("p", "hint");
|
||||
extraHint.textContent = "Other days you weren't eating on campus.";
|
||||
extraField.appendChild(extraHint);
|
||||
panel.appendChild(extraField);
|
||||
|
||||
return panel;
|
||||
}
|
||||
|
||||
function buildResultsPanel(): HTMLElement {
|
||||
const panel = el("section", "panel results-panel");
|
||||
panel.append(sectionTitle("Estimate"));
|
||||
const note = el("p", "days-note");
|
||||
note.id = "days-note";
|
||||
panel.appendChild(note);
|
||||
const mount = el("div", "results");
|
||||
mount.id = "results";
|
||||
panel.appendChild(mount);
|
||||
return panel;
|
||||
}
|
||||
|
||||
/* ---------- results rendering ---------- */
|
||||
|
||||
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)
|
||||
);
|
||||
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);
|
||||
|
||||
const row = el("div", "result-row");
|
||||
row.append(
|
||||
metric(fmtBlocks(blocks), "meal blocks"),
|
||||
metric(fmtMoney(flex), "FLEX dollars")
|
||||
);
|
||||
group.appendChild(row);
|
||||
return group;
|
||||
}
|
||||
|
||||
function metric(value: string, unit: string): HTMLElement {
|
||||
const m = el("div", "metric");
|
||||
const v = el("span", "metric-value");
|
||||
v.textContent = value;
|
||||
const u = el("span", "metric-unit");
|
||||
u.textContent = unit;
|
||||
m.append(v, u);
|
||||
return m;
|
||||
}
|
||||
|
||||
/* ---------- config error ---------- */
|
||||
|
||||
function renderConfigError(errors: string[]): void {
|
||||
const box = el("div", "config-error");
|
||||
const h = el("h2", "");
|
||||
h.textContent = "Configuration problem";
|
||||
const p = el("p", "");
|
||||
p.textContent = "The config file could not be loaded. Fix these and reload:";
|
||||
const ul = document.createElement("ul");
|
||||
for (const e of errors) {
|
||||
const li = document.createElement("li");
|
||||
li.textContent = e;
|
||||
ul.appendChild(li);
|
||||
}
|
||||
box.append(h, p, ul);
|
||||
appEl.innerHTML = "";
|
||||
appEl.appendChild(box);
|
||||
}
|
||||
|
||||
/* ---------- formatting + DOM helpers ---------- */
|
||||
|
||||
function fmtBlocks(n: number): string {
|
||||
return (Math.round(n * 10) / 10).toLocaleString(undefined, {
|
||||
minimumFractionDigits: 0,
|
||||
maximumFractionDigits: 1,
|
||||
});
|
||||
}
|
||||
|
||||
function fmtMoney(n: number): string {
|
||||
return `$${n.toLocaleString(undefined, {
|
||||
minimumFractionDigits: 2,
|
||||
maximumFractionDigits: 2,
|
||||
})}`;
|
||||
}
|
||||
|
||||
function fmtDays(n: number): string {
|
||||
return `${n} ${n === 1 ? "day" : "days"}`;
|
||||
}
|
||||
|
||||
function el(tag: string, className: string): HTMLElement {
|
||||
const e = document.createElement(tag);
|
||||
if (className) e.className = className;
|
||||
return e;
|
||||
}
|
||||
|
||||
function sectionTitle(text: string): HTMLElement {
|
||||
const h = el("h2", "section-title");
|
||||
h.textContent = text;
|
||||
return h;
|
||||
}
|
||||
|
||||
function field(label: string): HTMLElement {
|
||||
const f = el("div", "field");
|
||||
const l = el("label", "field-label");
|
||||
l.textContent = label;
|
||||
f.appendChild(l);
|
||||
return f;
|
||||
}
|
||||
|
||||
function spanTartan(text: string): HTMLElement {
|
||||
const s = el("span", "tartan");
|
||||
s.textContent = text;
|
||||
return s;
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import rawConfig from "./config.json";
|
||||
import { loadConfig } from "./configLoader";
|
||||
import { estimate } from "./engine";
|
||||
import { parseISODate } from "./dates";
|
||||
|
||||
describe("real config sanity", () => {
|
||||
const result = loadConfig(rawConfig);
|
||||
|
||||
it("loads the shipped config without errors", () => {
|
||||
expect(result.ok).toBe(true);
|
||||
});
|
||||
|
||||
it("produces sensible Green-plan numbers at the halfway point", () => {
|
||||
if (!result.ok) throw new Error("config failed to load");
|
||||
const config = result.config;
|
||||
|
||||
// Roughly mid-semester target.
|
||||
const r = estimate(config, {
|
||||
selectedPlanId: "green",
|
||||
targetDate: parseISODate("2026-10-18")!,
|
||||
activeMissingPeriodIds: new Set(),
|
||||
extraExcludedDays: 0,
|
||||
});
|
||||
|
||||
// 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
|
||||
expect(r.consumedMealBlocks).toBeGreaterThan(0);
|
||||
expect(r.consumedMealBlocks).toBeLessThan(210);
|
||||
});
|
||||
|
||||
it("checking a break reduces total active days", () => {
|
||||
if (!result.ok) throw new Error("config failed to load");
|
||||
const config = result.config;
|
||||
|
||||
const base = estimate(config, {
|
||||
selectedPlanId: "green",
|
||||
targetDate: parseISODate("2026-12-13")!,
|
||||
activeMissingPeriodIds: new Set(),
|
||||
extraExcludedDays: 0,
|
||||
});
|
||||
const withBreak = estimate(config, {
|
||||
selectedPlanId: "green",
|
||||
targetDate: parseISODate("2026-12-13")!,
|
||||
activeMissingPeriodIds: new Set(["thanksgiving"]),
|
||||
extraExcludedDays: 0,
|
||||
});
|
||||
|
||||
expect(withBreak.totalActiveDays).toBeLessThan(base.totalActiveDays);
|
||||
// Per-week rate rises because the same total spreads over fewer days.
|
||||
expect(withBreak.perWeekMealBlocks).toBeGreaterThan(base.perWeekMealBlocks);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,52 @@
|
||||
import type { Config, Selections } from "./types";
|
||||
|
||||
type Listener = () => void;
|
||||
|
||||
/**
|
||||
* Minimal reactive store: holds the immutable config plus mutable user
|
||||
* selections, and notifies subscribers on any change.
|
||||
*/
|
||||
export class Store {
|
||||
private listeners = new Set<Listener>();
|
||||
|
||||
constructor(
|
||||
public readonly config: Config,
|
||||
private selections: Selections
|
||||
) {}
|
||||
|
||||
getSelections(): Selections {
|
||||
return this.selections;
|
||||
}
|
||||
|
||||
onChange(listener: Listener): void {
|
||||
this.listeners.add(listener);
|
||||
}
|
||||
|
||||
private update(patch: Partial<Selections>): void {
|
||||
this.selections = { ...this.selections, ...patch };
|
||||
this.emit();
|
||||
}
|
||||
|
||||
setSelectedPlan(planId: string): void {
|
||||
this.update({ selectedPlanId: planId });
|
||||
}
|
||||
|
||||
setTargetDate(date: Date): void {
|
||||
this.update({ targetDate: date });
|
||||
}
|
||||
|
||||
setMissingPeriodActive(periodId: string, isActive: boolean): void {
|
||||
const next = new Set(this.selections.activeMissingPeriodIds);
|
||||
if (isActive) next.add(periodId);
|
||||
else next.delete(periodId);
|
||||
this.update({ activeMissingPeriodIds: next });
|
||||
}
|
||||
|
||||
setExtraExcludedDays(count: number): void {
|
||||
this.update({ extraExcludedDays: Math.max(0, Math.floor(count) || 0) });
|
||||
}
|
||||
|
||||
private emit(): void {
|
||||
for (const l of this.listeners) l();
|
||||
}
|
||||
}
|
||||
+301
@@ -0,0 +1,301 @@
|
||||
:root {
|
||||
/* Dark surfaces */
|
||||
--bg: #0e0f12;
|
||||
--surface: #16181d;
|
||||
--surface-2: #1d2027;
|
||||
--border: #2a2e37;
|
||||
--border-strong: #3a3f4b;
|
||||
|
||||
/* Text */
|
||||
--text: #e8eaed;
|
||||
--text-dim: #9aa0aa;
|
||||
--text-faint: #6b7280;
|
||||
|
||||
/* CMU Tartan accent */
|
||||
--tartan: #c8102e;
|
||||
--tartan-soft: #e0384f;
|
||||
|
||||
--radius: 10px;
|
||||
--radius-sm: 7px;
|
||||
--gap: 20px;
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
html,
|
||||
body {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
body {
|
||||
background: var(--bg);
|
||||
color: var(--text);
|
||||
font-family: "Inter", system-ui, -apple-system, sans-serif;
|
||||
line-height: 1.5;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
}
|
||||
|
||||
.wrap {
|
||||
max-width: 940px;
|
||||
margin: 0 auto;
|
||||
padding: 48px 24px 40px;
|
||||
}
|
||||
|
||||
/* Header */
|
||||
.header {
|
||||
margin-bottom: 32px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
padding-bottom: 24px;
|
||||
}
|
||||
|
||||
.title {
|
||||
font-size: 30px;
|
||||
font-weight: 700;
|
||||
margin: 0 0 6px;
|
||||
letter-spacing: -0.02em;
|
||||
}
|
||||
|
||||
.title .tartan {
|
||||
color: var(--tartan);
|
||||
}
|
||||
|
||||
.subtitle {
|
||||
margin: 0;
|
||||
color: var(--text-dim);
|
||||
font-size: 15px;
|
||||
}
|
||||
|
||||
/* Grid */
|
||||
.grid {
|
||||
display: grid;
|
||||
grid-template-columns: 5fr 6fr;
|
||||
gap: var(--gap);
|
||||
align-items: start;
|
||||
}
|
||||
|
||||
@media (max-width: 760px) {
|
||||
.grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
/* Panels */
|
||||
.panel {
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
padding: 22px;
|
||||
}
|
||||
|
||||
.section-title {
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.08em;
|
||||
color: var(--text-faint);
|
||||
margin: 0 0 14px;
|
||||
}
|
||||
|
||||
.controls .section-title:not(:first-child) {
|
||||
margin-top: 26px;
|
||||
padding-top: 22px;
|
||||
border-top: 1px solid var(--border);
|
||||
}
|
||||
|
||||
/* Fields */
|
||||
.field {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.field:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.field-label {
|
||||
display: block;
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
color: var(--text-dim);
|
||||
margin-bottom: 7px;
|
||||
}
|
||||
|
||||
.select,
|
||||
.input {
|
||||
width: 100%;
|
||||
background: var(--surface-2);
|
||||
color: var(--text);
|
||||
border: 1px solid var(--border-strong);
|
||||
border-radius: var(--radius-sm);
|
||||
padding: 10px 12px;
|
||||
font-size: 14px;
|
||||
font-family: inherit;
|
||||
transition: border-color 0.12s ease;
|
||||
}
|
||||
|
||||
.select:focus,
|
||||
.input:focus {
|
||||
outline: none;
|
||||
border-color: var(--tartan);
|
||||
}
|
||||
|
||||
/* Native date/number controls readable on dark */
|
||||
.input[type="date"],
|
||||
.input[type="number"] {
|
||||
color-scheme: dark;
|
||||
}
|
||||
|
||||
.hint {
|
||||
margin: 7px 0 0;
|
||||
font-size: 12px;
|
||||
color: var(--text-faint);
|
||||
}
|
||||
|
||||
/* Checkboxes */
|
||||
.checkbox-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.checkbox-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
background: var(--surface-2);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-sm);
|
||||
padding: 10px 12px;
|
||||
cursor: pointer;
|
||||
transition: border-color 0.12s ease;
|
||||
}
|
||||
|
||||
.checkbox-row:hover {
|
||||
border-color: var(--border-strong);
|
||||
}
|
||||
|
||||
.checkbox-row input[type="checkbox"] {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
accent-color: var(--tartan);
|
||||
cursor: pointer;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.checkbox-text {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1px;
|
||||
}
|
||||
|
||||
.checkbox-name {
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.checkbox-dates {
|
||||
font-size: 12px;
|
||||
color: var(--text-faint);
|
||||
font-family: "IBM Plex Mono", ui-monospace, monospace;
|
||||
}
|
||||
|
||||
/* Results */
|
||||
.results-panel {
|
||||
position: sticky;
|
||||
top: 24px;
|
||||
}
|
||||
|
||||
.days-note {
|
||||
margin: -4px 0 18px;
|
||||
font-size: 13px;
|
||||
color: var(--text-dim);
|
||||
font-family: "IBM Plex Mono", ui-monospace, monospace;
|
||||
}
|
||||
|
||||
.result-groups {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.result-group {
|
||||
background: var(--surface-2);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-sm);
|
||||
padding: 16px 18px;
|
||||
}
|
||||
|
||||
.result-group:first-child {
|
||||
border-left: 3px solid var(--tartan);
|
||||
}
|
||||
|
||||
.result-label {
|
||||
margin: 0 0 12px;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: var(--text-dim);
|
||||
}
|
||||
|
||||
.result-row {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.metric {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.metric-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;
|
||||
}
|
||||
|
||||
/* Footer */
|
||||
.footer {
|
||||
margin-top: 28px;
|
||||
padding-top: 20px;
|
||||
border-top: 1px solid var(--border);
|
||||
font-size: 12px;
|
||||
color: var(--text-faint);
|
||||
}
|
||||
|
||||
/* Config error */
|
||||
.config-error {
|
||||
max-width: 640px;
|
||||
margin: 64px auto;
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--tartan);
|
||||
border-radius: var(--radius);
|
||||
padding: 24px 28px;
|
||||
}
|
||||
|
||||
.config-error h2 {
|
||||
margin: 0 0 8px;
|
||||
color: var(--tartan-soft);
|
||||
}
|
||||
|
||||
.config-error ul {
|
||||
margin: 12px 0 0;
|
||||
padding-left: 20px;
|
||||
color: var(--text-dim);
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.config-error li {
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
/** A single dining plan and its full-period totals. */
|
||||
export interface Plan {
|
||||
id: string;
|
||||
displayName: string;
|
||||
totalMealBlocks: number;
|
||||
totalFlexDollars: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* A named span the student was not on campus (e.g. Fall Break).
|
||||
* Both dates are inclusive.
|
||||
*/
|
||||
export interface MissingPeriod {
|
||||
id: string;
|
||||
displayName: string;
|
||||
startDate: Date;
|
||||
endDate: Date;
|
||||
}
|
||||
|
||||
/** Normalized, validated configuration. */
|
||||
export interface Config {
|
||||
mealStartDate: Date;
|
||||
mealEndDate: Date;
|
||||
plans: Plan[];
|
||||
missingPeriods: MissingPeriod[];
|
||||
}
|
||||
|
||||
/** Current user-adjustable inputs. */
|
||||
export interface Selections {
|
||||
selectedPlanId: string;
|
||||
targetDate: Date;
|
||||
activeMissingPeriodIds: Set<string>;
|
||||
extraExcludedDays: number;
|
||||
}
|
||||
|
||||
/** Output of the estimation engine. */
|
||||
export interface EstimationResult {
|
||||
perWeekMealBlocks: number;
|
||||
perWeekFlexDollars: number;
|
||||
consumedMealBlocks: number;
|
||||
consumedFlexDollars: number;
|
||||
remainingMealBlocks: number;
|
||||
remainingFlexDollars: number;
|
||||
totalActiveDays: number;
|
||||
activeDaysConsumed: number;
|
||||
}
|
||||
|
||||
/** Result of loading + validating the raw config. */
|
||||
export type ConfigResult =
|
||||
| { ok: true; config: Config }
|
||||
| { ok: false; errors: string[] };
|
||||
Reference in New Issue
Block a user