feat(api): add review day handling and dynamic campaign length

This commit is contained in:
Prad Nukala committed 2026-09-30 17:33:32 -04:00
1 parent 7e9ea8d7c2
commit 239c7013f8
9 files changed
+176 -32

No files matched your search

+33 -9
View File
@@ -22,8 +22,8 @@ import scheduleJson from "../data/schedule.json";
// ── dates (America/New_York) ─────────────────────────────────────
/** Day 1. The last day is not a constant: see CAMPAIGN_END below the schedule. */
export const CAMPAIGN_START = "2026-08-17";
export const CAMPAIGN_DAYS = 56;
const ET_DATE = new Intl.DateTimeFormat("en-CA", {
timeZone: "America/New_York",
@@ -130,15 +130,26 @@ export function prettyDate(date: string): string {
* One calendar day of the plan. Most days teach topics (one, or two when the
* plan doubles up); the rest are named by what they assign instead: a fixed
* gate set for Saturday's review issue, a fixed problem set (a "B-set" day),
* or a sweep — redo every miss, nothing new. Absent dates are unplanned:
* the ladder still runs, the digest just has nothing new to say.
* a transfer-phase review day, or a sweep — redo every miss, nothing new.
* Absent dates are unplanned: the ladder still runs, the digest just has
* nothing new to say.
*/
export interface ScheduledDay {
/** Topic issues taught that day, in the order the digest presents them. */
topics?: number[];
/** Problems assigned by number instead of by topic. */
problems?: number[];
/** Saturday: the blind set, fixed by the plan rather than sampled. */
/**
* A `Review <N> — <Phase>` day (Mon/Tue/Thu/Fri only): the issue, its title
* and its problems in solve order. The issue's sub-issues are the truth; this
* copy is what the digest and RESERVED read without calling GitHub, and the
* catalog reconcile reports any drift between the two.
*/
review?: { issue: number; title: string; problems: number[] };
/**
* Saturday: the blind set, fixed by the plan rather than sampled. An empty
* list skips that week's checkpoint — no issue is created.
*/
gate?: number[];
/** Miss-log sweep: every problem whose last attempt failed, and no new ones. */
sweep?: boolean;
@@ -146,6 +157,15 @@ export interface ScheduledDay {
export const SCHEDULE: Record<string, ScheduledDay> = scheduleJson;
/**
* The last day the plan books anything — the campaign ends there. Derived,
* not declared: booking a later day (the review phase ran the plan past its
* original eight weeks) moves the digest's "Day N/M", the stats' `heat` and
* the heatmap charts with it, and nothing can disagree about when it ends.
*/
export const CAMPAIGN_END = Object.keys(SCHEDULE).sort().at(-1)!;
export const CAMPAIGN_DAYS = daysBetween(CAMPAIGN_START, CAMPAIGN_END) + 1;
/** Date the schedule teaches a topic on, if it teaches it at all. */
export function topicDate(topic: number): string | undefined {
for (const [date, day] of Object.entries(SCHEDULE)) {
@@ -161,13 +181,17 @@ export function topicWeek(topic: number): number | undefined {
}
/**
* Every problem the plan names outright — a gate's blind set or a set day's
* list. The plan owns these: the samplers (daily drills, a sampled gate)
* never draw them, so a gate problem stays unseen until its Saturday and a
* set day's problems wait for their day.
* Every problem the plan names outright — a gate's blind set, a set day's
* list, or a review day's. The plan owns these: the samplers (daily drills,
* a sampled gate) never draw them, so a gate problem stays unseen until its
* Saturday and a set or review day's problems wait for their day.
*/
export const RESERVED: ReadonlySet<number> = new Set(
Object.values(SCHEDULE).flatMap((day) => [...(day.gate ?? []), ...(day.problems ?? [])]),
Object.values(SCHEDULE).flatMap((day) => [
...(day.gate ?? []),
...(day.problems ?? []),
...(day.review?.problems ?? []),
]),
);
// ── deterministic sampling ───────────────────────────────────────