feat(api): add dueToday endpoint, shared DUE_WHERE constant, and commit rung handling

This commit is contained in:
Prad Nukala committed 2026-08-31 10:48:24 -04:00
1 parent 38fbb18f32
commit 5ea123e46d
3 files changed
+124 -33

No files matched your search

+35 -14
View File
@@ -19,7 +19,9 @@ import { type Mirror, projectMirror } from "./mirror.ts";
import {
CAMPAIGN_START,
type LogOutcome,
type Stage,
daysBetween,
dueToday,
etDate,
etHour,
getProblem,
@@ -202,43 +204,56 @@ async function adminAuthorized(request: Request, env: Env): Promise<boolean> {
/**
* Solutions landing in `work/` are the third way a problem gets solved, after
* the digest tap and a /done comment. `close-solved` posts its whole
* implemented set here on every push to main: logAttempt ignores anything
* already on the ladder, so this is a total recompute like the reconcilers
* that call it — a re-run changes nothing.
* implemented set here on every push to main, each entry tagged with the
* BUCKET the file sits in — 1 for the first solve, 3 and 7 for the blind
* re-solves the picker scaffolds. The bucket names the ladder rung the file
* settles, and logAttempt writes only when the problem is standing on that
* rung, so this is a total recompute like the reconcilers that call it: a
* re-run changes nothing, and a pushed re-solve advances +3 → +7 instead of
* being invisible.
*
* Closing the sub-issue stays with close-solved: it is the side that knows
* which files at which commit, so this path mirrors Project fields only.
* Charts read D1 live, so a push moves them within the 5-minute cache.
*/
const RUNG_OF_BUCKET: Record<number, Stage> = { 1: "new", 3: "+3", 7: "+7" };
async function handleSolved(request: Request, env: Env, date: string): Promise<Response> {
const body: unknown = await request.json().catch(() => null);
const raw: unknown = body && typeof body === "object" && "lc" in body ? body.lc : null;
if (!Array.isArray(raw)) return new Response('expected {"lc": [<number>, ...]}', { status: 400 });
const lcs: number[] = [];
const raw: unknown = body && typeof body === "object" && "solved" in body ? body.solved : null;
const shape = 'expected {"solved": [{"lc": <number>, "bucket": 1|3|7}, ...]}';
if (!Array.isArray(raw)) return new Response(shape, { status: 400 });
const entries: { lc: number; rung: Stage; bucket: number }[] = [];
for (const item of raw) {
const lc: unknown = item;
if (!item || typeof item !== "object") return new Response(shape, { status: 400 });
const lc: unknown = "lc" in item ? item.lc : null;
const bucket: unknown = "bucket" in item ? item.bucket : null;
if (typeof lc !== "number" || !Number.isFinite(lc)) {
return new Response(`not an LC number: ${JSON.stringify(lc)}`, { status: 400 });
}
lcs.push(lc);
const rung = typeof bucket === "number" ? RUNG_OF_BUCKET[bucket] : undefined;
if (rung === undefined) return new Response(`not a work/ bucket: ${JSON.stringify(bucket)}`, { status: 400 });
entries.push({ lc, rung, bucket });
}
const dry = new URL(request.url).searchParams.get("dry") === "1";
const logged: string[] = [];
const skipped: string[] = [];
let mirror: Mirror | undefined;
for (const lc of lcs) {
for (const { lc, rung, bucket } of entries) {
if (dry) {
const p = await getProblem(env.DB, lc);
if (!p) skipped.push(`LC ${lc} is not in the curriculum`);
else if (p.stage !== "new") skipped.push(`LC ${lc}: already at stage ${p.stage}`);
else logged.push(`LC ${lc}: would enter the ladder at +3`);
else if (p.stage !== rung) skipped.push(`LC ${lc}: work/${bucket} settles ${rung}, stage is ${p.stage}`);
else if (rung === "new") logged.push(`LC ${lc}: would enter the ladder at +3`);
else logged.push(`LC ${lc}: would pass ${rung} and move on`);
continue;
}
const outcome = await logAttempt(env.DB, { lc, date, result: "pass", source: "commit" });
const outcome = await logAttempt(env.DB, { lc, date, result: "pass", source: "commit", rung });
if (outcome.error) skipped.push(outcome.error);
else if (outcome.duplicate) skipped.push(`LC ${lc}: already at stage ${outcome.stage}`);
else {
else if (outcome.duplicate) {
skipped.push(`LC ${lc}: work/${bucket} settles ${rung}, stage is ${outcome.stage}`);
} else {
logged.push(outcomeLine(outcome));
// One mirror for the batch: field IDs resolve once, not per problem.
mirror ??= projectMirror(github(env.GH_PAT, env.REPO), env.REPO.split("/")[0]!);
@@ -271,6 +286,12 @@ async function handleAdmin(request: Request, env: Env, path: string): Promise<Re
if (path === "/admin/review") {
return Response.json(await createReviewIssue(env, gh(), date));
}
// What is due on `date`, straight out of the ladder — the ONE answer to
// "which problems are open today", so the daily review issue and the digest
// cannot disagree. Read-only; the caller decides what to render.
if (path === "/admin/due") {
return Response.json(await dueToday(env.DB, date));
}
return new Response("not found", { status: 404 });
}