mirror of
https://github.com/prdlk/leetcode.git
synced 2026-09-16 23:16:26 +00:00
feat(cli): send bucket info to SRS API and render due list from worker
This commit is contained in:
+26
-25
@@ -19,14 +19,12 @@
|
|||||||
* SRS_ADMIN_KEY for the D1 push (unset = skip it); SRS_API overrides
|
* SRS_ADMIN_KEY for the D1 push (unset = skip it); SRS_API overrides
|
||||||
* the Worker URL for local `wrangler dev` runs.
|
* the Worker URL for local `wrangler dev` runs.
|
||||||
*/
|
*/
|
||||||
import { basename, join } from "node:path";
|
import { join } from "node:path";
|
||||||
|
|
||||||
import { github } from "./github.ts";
|
import { github } from "./github.ts";
|
||||||
import { markdownTable, printTable, writeStepSummary } from "./report.ts";
|
import { markdownTable, printTable, writeStepSummary } from "./report.ts";
|
||||||
import { isImplemented } from "./source.ts";
|
import { isImplemented } from "./source.ts";
|
||||||
|
import { WORK, parseWorkPath, type Bucket } from "./work.ts";
|
||||||
const ROOT = join(import.meta.dir, "..", "..");
|
|
||||||
const WORK = join(ROOT, "work");
|
|
||||||
|
|
||||||
const DRY =
|
const DRY =
|
||||||
process.argv.includes("--dry-run") ||
|
process.argv.includes("--dry-run") ||
|
||||||
@@ -41,27 +39,27 @@ const gh = await github();
|
|||||||
|
|
||||||
interface WorkEntry {
|
interface WorkEntry {
|
||||||
files: string[];
|
files: string[];
|
||||||
|
/** Any bucket implemented — this is what closes the problem issue. */
|
||||||
implemented: boolean;
|
implemented: boolean;
|
||||||
|
/** Buckets holding a real solution: 1 = first solve, 3/7 = the re-solves. */
|
||||||
|
solved: Set<Bucket>;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** LC number -> solution files (a problem may have both js and py). */
|
/** LC number -> its solution files across every bucket (js and py both count). */
|
||||||
const work = new Map<number, WorkEntry>();
|
const work = new Map<number, WorkEntry>();
|
||||||
for await (const rel of new Bun.Glob("**/*.{js,py}").scan(WORK)) {
|
for await (const rel of new Bun.Glob("**/*.{js,py}").scan(WORK)) {
|
||||||
const m = basename(rel).match(/^(\d+)\.(.+)\.(?:js|py)$/);
|
const file = parseWorkPath(rel);
|
||||||
if (!m) {
|
if (!file) {
|
||||||
console.warn(`skip (unrecognized name): work/${rel}`);
|
console.warn(`skip (off-layout): work/${rel}`);
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
const num = Number(m[1]);
|
|
||||||
const src = await Bun.file(join(WORK, rel)).text();
|
const src = await Bun.file(join(WORK, rel)).text();
|
||||||
const implemented = isImplemented(src, rel.endsWith(".py") ? "py" : "js");
|
const implemented = isImplemented(src, rel.endsWith(".py") ? "py" : "js");
|
||||||
const entry = work.get(num);
|
const entry = work.get(file.lc) ?? { files: [], implemented: false, solved: new Set<Bucket>() };
|
||||||
if (entry) {
|
entry.files.push(`work/${rel}`);
|
||||||
entry.files.push(`work/${rel}`);
|
entry.implemented ||= implemented;
|
||||||
entry.implemented ||= implemented;
|
if (implemented) entry.solved.add(file.bucket);
|
||||||
} else {
|
work.set(file.lc, entry);
|
||||||
work.set(num, { files: [`work/${rel}`], implemented });
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── open problem issues ──────────────────────────────────────────
|
// ── open problem issues ──────────────────────────────────────────
|
||||||
@@ -157,14 +155,17 @@ for (const num of [...work.keys()].sort((a, b) => a - b)) {
|
|||||||
// or the charts, the digest's "already solved" ticks, and the drill/gate
|
// or the charts, the digest's "already solved" ticks, and the drill/gate
|
||||||
// pools all keep treating it as untouched.
|
// pools all keep treating it as untouched.
|
||||||
//
|
//
|
||||||
// The whole implemented set goes over, not just this run's closes: the
|
// Each entry carries the BUCKET the file sits in, which is the rung it
|
||||||
// Worker drops anything already on the ladder, so the call is a total
|
// settles: work/1 is the first solve, work/3 the 3-day review, work/7 the
|
||||||
// recompute and backfills whatever earlier runs missed.
|
// 7-day one. That is what makes a pushed re-solve advance the ladder instead
|
||||||
|
// of vanishing — and the Worker writes only when a problem is standing on the
|
||||||
|
// rung named, so the whole implemented set can go over on every push (a total
|
||||||
|
// recompute that backfills whatever earlier runs missed) and re-runs write
|
||||||
|
// nothing.
|
||||||
const SRS_API = process.env.SRS_API ?? "https://srs-api.prdlk.workers.dev";
|
const SRS_API = process.env.SRS_API ?? "https://srs-api.prdlk.workers.dev";
|
||||||
const solved = [...work.entries()]
|
const solved = [...work.entries()]
|
||||||
.filter(([, entry]) => entry.implemented)
|
.flatMap(([lc, entry]) => [...entry.solved].sort().map((bucket) => ({ lc, bucket })))
|
||||||
.map(([lc]) => lc)
|
.sort((a, b) => a.lc - b.lc || a.bucket - b.bucket);
|
||||||
.sort((a, b) => a - b);
|
|
||||||
|
|
||||||
let syncNote: string;
|
let syncNote: string;
|
||||||
let syncFailed = false;
|
let syncFailed = false;
|
||||||
@@ -180,7 +181,7 @@ if (!process.env.SRS_ADMIN_KEY) {
|
|||||||
authorization: `Bearer ${process.env.SRS_ADMIN_KEY}`,
|
authorization: `Bearer ${process.env.SRS_ADMIN_KEY}`,
|
||||||
"content-type": "application/json",
|
"content-type": "application/json",
|
||||||
},
|
},
|
||||||
body: JSON.stringify({ lc: solved }),
|
body: JSON.stringify({ solved }),
|
||||||
});
|
});
|
||||||
if (!res.ok) throw new Error(`${res.status} ${await res.text()}`);
|
if (!res.ok) throw new Error(`${res.status} ${await res.text()}`);
|
||||||
// Narrow the report payload instead of casting it; a shape change should
|
// Narrow the report payload instead of casting it; a shape change should
|
||||||
@@ -209,7 +210,7 @@ printTable(rows);
|
|||||||
|
|
||||||
const actionable = report.filter((r) => r[3] !== "no open issue");
|
const actionable = report.filter((r) => r[3] !== "no open issue");
|
||||||
console.log(
|
console.log(
|
||||||
`\n${work.size} in work/ · ${solved.length} implemented · ` +
|
`\n${work.size} problems in work/ · ${solved.length} solved files across buckets · ` +
|
||||||
`${actionable.length} matched an open issue · ` +
|
`${actionable.length} matched an open issue · ` +
|
||||||
`${DRY ? `would close ${actionable.filter((r) => r[3] === "would close").length}` : `closed ${closed}`}` +
|
`${DRY ? `would close ${actionable.filter((r) => r[3] === "would close").length}` : `closed ${closed}`}` +
|
||||||
`\nd1: ${syncNote}`,
|
`\nd1: ${syncNote}`,
|
||||||
@@ -217,7 +218,7 @@ console.log(
|
|||||||
|
|
||||||
await writeStepSummary(
|
await writeStepSummary(
|
||||||
`### close-solved${DRY ? " (dry run)" : ""}\n\n` +
|
`### close-solved${DRY ? " (dry run)" : ""}\n\n` +
|
||||||
`${work.size} files in \`work/\`, ${solved.length} implemented, ` +
|
`${work.size} problems in \`work/\`, ${solved.length} solved files across buckets, ` +
|
||||||
`${actionable.length} matched an open issue.\n\n` +
|
`${actionable.length} matched an open issue.\n\n` +
|
||||||
(actionable.length
|
(actionable.length
|
||||||
? `${markdownTable([rows[0]!, ...actionable])}\n`
|
? `${markdownTable([rows[0]!, ...actionable])}\n`
|
||||||
|
|||||||
+156
-119
@@ -1,39 +1,42 @@
|
|||||||
#!/usr/bin/env bun
|
#!/usr/bin/env bun
|
||||||
/**
|
/**
|
||||||
* Open today's spaced-repetition issue: everything whose problem issue closed
|
* Open today's spaced-repetition issue: whatever the SRS ladder has due,
|
||||||
* 3 and 7 days ago, linked straight to LeetCode.
|
* linked straight to LeetCode.
|
||||||
*
|
*
|
||||||
* The issue itself holds no state and there is no scheduler here: "what do I
|
* D1 is the schedule and this script is a renderer. `POST /admin/due` returns
|
||||||
* re-solve today" is a pure function of the tracker's close dates and today's
|
* exactly the rows the 8 AM digest mails — reviews standing at the `+3` and
|
||||||
* ET date, recomputed from scratch on every run. A day's issue is keyed by its
|
* `+7` rungs, oldest first — so the issue and the email can never disagree
|
||||||
* title (`Spaced Repetition — <Month D, YYYY>`), so re-runs rewrite that one
|
* about a day, and there is no second scheduler to drift. The rung is also
|
||||||
* body instead of stacking duplicates, and a backfilled close shows up in the
|
* the `work/<n>` bucket the blind re-solve belongs in, which is the section
|
||||||
* next run's windows for free.
|
* `bun run pick` opens on Tab.
|
||||||
*
|
*
|
||||||
* Windows are +3 and +7 because that is the campaign's ladder: WINDOWS lives
|
* How a problem gets here: closing its issue (a push, a digest tap, a `/done`
|
||||||
* in apps/api/src/srs.ts, names the `work/3` and `work/7` buckets the picker
|
* comment) enters it on the ladder at `+3` with a concrete next_review, and
|
||||||
* scaffolds into, and is chosen so a solve never comes back on the Sunday rest
|
* passing that review moves it to `+7`. Nothing is "activated" by this script
|
||||||
* day — the +3 pass catches a problem while the solution is half remembered,
|
* — it asks what is standing today and writes it down. A day's issue is keyed
|
||||||
* the +7 pass after a week of interference. The one exception, a Thursday
|
* by its title (`Spaced Repetition — <Month D, YYYY>`), so re-runs rewrite
|
||||||
* solve's +3, is slid to Monday by the same workingDay() the Worker schedules
|
* that one body instead of stacking duplicates.
|
||||||
* with, which is why a window can have two source days. Each window asks for
|
|
||||||
* at most WINDOW_CAP problems, so a normal day is 3 new + up to 6 re-solves
|
|
||||||
* with no levelling logic needed to bound the load. On Sunday this script
|
|
||||||
* writes nothing at all.
|
|
||||||
*
|
*
|
||||||
* Links come from the problem issue body's first line (the canonical LeetCode
|
* Sunday writes nothing: the ladder never schedules the rest day (WINDOWS of
|
||||||
* URL written by the campaign-issues convention). A body with no URL falls back
|
* 3 and 7 exist for exactly that reason, see apps/api/src/srs.ts), so there is
|
||||||
* to a slug guessed from the title and is flagged `inferred` in the report,
|
* nothing to render. Each rung shows at most WINDOW_CAP problems, with the
|
||||||
* because repo titles are sometimes abbreviated ("LC 167 · Two Sum II") and a
|
* overflow listed as optional — the digest's leveller moves that overflow to
|
||||||
* guessed slug can 404.
|
* later working days the next morning.
|
||||||
|
*
|
||||||
|
* Links: D1 stores titles, not URLs, so each due problem's issue body is read
|
||||||
|
* for the canonical LeetCode link. A body with no URL falls back to a slug
|
||||||
|
* guessed from the title and is flagged in the report, because repo titles are
|
||||||
|
* sometimes abbreviated ("LC 167 · Two Sum II") and a guess can 404.
|
||||||
*
|
*
|
||||||
* bun apps/cli/spaced-repetition.ts # create/update today's issue
|
* bun apps/cli/spaced-repetition.ts # create/update today's issue
|
||||||
* bun apps/cli/spaced-repetition.ts --dry-run # print the body, touch nothing
|
* bun apps/cli/spaced-repetition.ts --dry-run # print the body, touch nothing
|
||||||
* bun apps/cli/spaced-repetition.ts --date=2026-09-02 # pretend it is that ET day
|
* bun apps/cli/spaced-repetition.ts --date=2026-09-02 # pretend it is that ET day
|
||||||
*
|
*
|
||||||
* Auth: GH_TOKEN / GITHUB_TOKEN, else falls back to `gh auth token`.
|
* Auth: GH_TOKEN / GITHUB_TOKEN, else falls back to `gh auth token`.
|
||||||
|
* SRS_ADMIN_KEY reads the due list; SRS_API overrides the Worker URL for
|
||||||
|
* local `wrangler dev` runs.
|
||||||
*/
|
*/
|
||||||
import { WINDOWS, addDays, etDate, isRestDay, workingDay } from "../api/src/srs.ts";
|
import { WINDOWS, etDate, isRestDay } from "../api/src/srs.ts";
|
||||||
import { github } from "./github.ts";
|
import { github } from "./github.ts";
|
||||||
import { markdownTable, printTable, writeStepSummary } from "./report.ts";
|
import { markdownTable, printTable, writeStepSummary } from "./report.ts";
|
||||||
|
|
||||||
@@ -93,42 +96,87 @@ if (isRestDay(today)) {
|
|||||||
process.exit(0);
|
process.exit(0);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
// ── what is due today (D1, via the Worker) ───────────────────────
|
||||||
* The close dates whose `days`-day review comes due today, in close order.
|
|
||||||
*
|
// The ladder is the schedule: `POST /admin/due` returns exactly the rows the
|
||||||
* Normally one day, `today - days`. Two when the plain interval would have
|
// morning digest mails, so the issue and the email can never disagree about a
|
||||||
* landed on the rest day: workingDay() slides that review to Monday, so a
|
// day. This script adds no scheduling of its own — it renders, caps, and
|
||||||
* Monday run also owes Thursday's +3 (Thu + 3 = Sunday). Nothing is dropped
|
// links.
|
||||||
* and nothing is counted twice — a close date qualifies only when its shifted
|
const SRS_API = process.env.SRS_API ?? "https://srs-api.prdlk.workers.dev";
|
||||||
* review date IS today, which is exactly the rule srs.ts schedules by.
|
|
||||||
*/
|
/** One problem the ladder wants re-solved today, as the Worker reports it. */
|
||||||
function windowSources(days: number): string[] {
|
interface Due {
|
||||||
return [addDays(today, -days - 1), addDays(today, -days)].filter(
|
lc: number;
|
||||||
(closed) => workingDay(addDays(closed, days)) === today,
|
issue: number;
|
||||||
);
|
title: string;
|
||||||
|
difficulty: string;
|
||||||
|
/** The rung being settled: "+3" or "+7". */
|
||||||
|
stage: string;
|
||||||
|
/** The rung's number — also the `work/<n>` bucket the re-solve belongs in. */
|
||||||
|
bucket: number;
|
||||||
|
due: string;
|
||||||
|
late: number;
|
||||||
|
last_seen: string | null;
|
||||||
|
days_since: number | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const targets = new Set(WINDOWS.flatMap((days) => windowSources(days)));
|
function readDue(value: unknown): Due | undefined {
|
||||||
|
if (!value || typeof value !== "object") return;
|
||||||
|
const num = (key: string): number | undefined =>
|
||||||
|
key in value && typeof (value as Record<string, unknown>)[key] === "number"
|
||||||
|
? ((value as Record<string, unknown>)[key] as number)
|
||||||
|
: undefined;
|
||||||
|
const str = (key: string): string | undefined =>
|
||||||
|
key in value && typeof (value as Record<string, unknown>)[key] === "string"
|
||||||
|
? ((value as Record<string, unknown>)[key] as string)
|
||||||
|
: undefined;
|
||||||
|
const lc = num("lc");
|
||||||
|
const issue = num("issue");
|
||||||
|
const bucket = num("bucket");
|
||||||
|
const stage = str("stage");
|
||||||
|
const title = str("title");
|
||||||
|
const dueOn = str("due");
|
||||||
|
if (lc === undefined || issue === undefined || bucket === undefined) return;
|
||||||
|
if (stage === undefined || title === undefined || dueOn === undefined) return;
|
||||||
|
const difficulty = str("difficulty") ?? "unrated";
|
||||||
|
return {
|
||||||
|
lc,
|
||||||
|
issue,
|
||||||
|
title,
|
||||||
|
difficulty: difficulty.charAt(0).toUpperCase() + difficulty.slice(1),
|
||||||
|
stage,
|
||||||
|
bucket,
|
||||||
|
due: dueOn,
|
||||||
|
late: num("late") ?? 0,
|
||||||
|
last_seen: str("last_seen") ?? null,
|
||||||
|
days_since: num("days_since") ?? null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!process.env.SRS_ADMIN_KEY) {
|
||||||
|
// Failing loudly beats writing an issue from a second, guessed schedule:
|
||||||
|
// there is only one schedule now, and it lives in D1.
|
||||||
|
console.error("SRS_ADMIN_KEY unset — cannot read today's due list from the Worker.");
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
const res = await fetch(`${SRS_API}/admin/due?date=${today}`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { authorization: `Bearer ${process.env.SRS_ADMIN_KEY}` },
|
||||||
|
});
|
||||||
|
if (!res.ok) throw new Error(`GET due list -> ${res.status} ${await res.text()}`);
|
||||||
|
const payload: unknown = await res.json();
|
||||||
|
const dueRaw: unknown = payload && typeof payload === "object" && "due" in payload ? payload.due : null;
|
||||||
|
const due: Due[] = (Array.isArray(dueRaw) ? dueRaw : []).flatMap((r) => {
|
||||||
|
const row = readDue(r);
|
||||||
|
return row ? [row] : [];
|
||||||
|
});
|
||||||
|
|
||||||
// ── repo + auth ──────────────────────────────────────────────────
|
// ── repo + auth ──────────────────────────────────────────────────
|
||||||
|
|
||||||
const gh = await github();
|
const gh = await github();
|
||||||
|
|
||||||
// ── closed problem issues ────────────────────────────────────────
|
// ── canonical LeetCode links ─────────────────────────────────────
|
||||||
|
|
||||||
interface Solved {
|
|
||||||
lc: number;
|
|
||||||
name: string;
|
|
||||||
issue: number;
|
|
||||||
url: string;
|
|
||||||
/** false when the URL was guessed from the title instead of read from the body. */
|
|
||||||
linked: boolean;
|
|
||||||
difficulty: string;
|
|
||||||
/** ET date the issue closed. */
|
|
||||||
closed: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
const TITLE = /^LC\s+(\d+)\s+·\s+(.+?)\s*$/;
|
|
||||||
const LC_URL = /https:\/\/leetcode\.com\/problems\/[a-z0-9-]+/;
|
const LC_URL = /https:\/\/leetcode\.com\/problems\/[a-z0-9-]+/;
|
||||||
|
|
||||||
/** LeetCode's own slug shape, for bodies that lost their URL line. */
|
/** LeetCode's own slug shape, for bodies that lost their URL line. */
|
||||||
@@ -140,92 +188,79 @@ function slugify(name: string): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Narrow one element of the /issues payload to the fields this script needs.
|
* D1 stores titles, not URLs, so each due problem's issue body is read for the
|
||||||
* Drops pull requests (the `problem` label filter cannot), anything whose title
|
* canonical LeetCode link written by the campaign-issues convention. That is
|
||||||
* is not a `LC <num> · <Name>` problem, and anything closed outside the two
|
* one request per due problem — at most REVIEW_CAP per rung — instead of the
|
||||||
* review windows.
|
* old walk over every closed issue in the repo. A body with no URL falls back
|
||||||
|
* to a slug guessed from the title and is flagged in the report, because repo
|
||||||
|
* titles are sometimes abbreviated ("LC 167 · Two Sum II") and a guess can 404.
|
||||||
*/
|
*/
|
||||||
function readSolved(value: unknown): Solved | undefined {
|
interface Linked extends Due {
|
||||||
if (!value || typeof value !== "object") return;
|
url: string;
|
||||||
if ("pull_request" in value) return; // the /issues route also lists PRs
|
linked: boolean;
|
||||||
if (!("number" in value) || typeof value.number !== "number") return;
|
}
|
||||||
if (!("title" in value) || typeof value.title !== "string") return;
|
|
||||||
if (!("closed_at" in value) || typeof value.closed_at !== "string") return;
|
|
||||||
// Closed "not planned" means dropped from the curriculum, not solved — the
|
|
||||||
// reconcilers only ever close as completed, so this is a human's decision.
|
|
||||||
if ("state_reason" in value && value.state_reason === "not_planned") return;
|
|
||||||
|
|
||||||
const closed = etDate(new Date(value.closed_at));
|
|
||||||
if (!targets.has(closed)) return;
|
|
||||||
|
|
||||||
const title = TITLE.exec(value.title);
|
|
||||||
if (!title) return;
|
|
||||||
|
|
||||||
const body = "body" in value && typeof value.body === "string" ? value.body : "";
|
|
||||||
const url = LC_URL.exec(body)?.[0];
|
|
||||||
const labels =
|
|
||||||
"labels" in value && Array.isArray(value.labels)
|
|
||||||
? value.labels.flatMap((l) =>
|
|
||||||
l && typeof l === "object" && "name" in l && typeof l.name === "string" ? [l.name] : [],
|
|
||||||
)
|
|
||||||
: [];
|
|
||||||
const diff = labels.find((n) => n.startsWith("diff:"))?.slice(5) ?? "unrated";
|
|
||||||
|
|
||||||
|
async function link(row: Due): Promise<Linked> {
|
||||||
|
let url: string | undefined;
|
||||||
|
try {
|
||||||
|
const issue: unknown = await gh.api(`/repos/${gh.repo}/issues/${row.issue}`);
|
||||||
|
const body =
|
||||||
|
issue && typeof issue === "object" && "body" in issue && typeof issue.body === "string"
|
||||||
|
? issue.body
|
||||||
|
: "";
|
||||||
|
url = LC_URL.exec(body)?.[0];
|
||||||
|
} catch (err) {
|
||||||
|
console.warn(`#${row.issue}: could not read the issue body (${err})`);
|
||||||
|
}
|
||||||
return {
|
return {
|
||||||
lc: Number(title[1]),
|
...row,
|
||||||
name: title[2]!,
|
url: `${url ?? `https://leetcode.com/problems/${slugify(row.title)}`}/`,
|
||||||
issue: value.number,
|
|
||||||
url: `${url ?? `https://leetcode.com/problems/${slugify(title[2]!)}`}/`,
|
|
||||||
linked: url !== undefined,
|
linked: url !== undefined,
|
||||||
difficulty: diff.charAt(0).toUpperCase() + diff.slice(1),
|
|
||||||
closed,
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// The whole closed set is walked rather than filtered with `since`: it is two
|
|
||||||
// pages for the entire 161-problem curriculum, and `since` filters on
|
|
||||||
// updated_at, which a stray comment moves off the close date.
|
|
||||||
const solved: Solved[] = [];
|
|
||||||
for await (const raw of gh.list(`/repos/${gh.repo}/issues?labels=problem&state=closed`)) {
|
|
||||||
const row = readSolved(raw);
|
|
||||||
if (row) solved.push(row);
|
|
||||||
}
|
|
||||||
solved.sort((a, b) => a.issue - b.issue);
|
|
||||||
|
|
||||||
// ── issue body ───────────────────────────────────────────────────
|
// ── issue body ───────────────────────────────────────────────────
|
||||||
|
|
||||||
const title = `Spaced Repetition — ${longDate(today)}`;
|
const title = `Spaced Repetition — ${longDate(today)}`;
|
||||||
|
|
||||||
/** Every problem whose issue closed on one of a window's source days. */
|
/** The window's due problems, capped, oldest scheduled first. */
|
||||||
function bucket(days: number): Solved[] {
|
const byWindow = new Map<number, Linked[]>();
|
||||||
const dates = new Set(windowSources(days));
|
for (const days of WINDOWS) {
|
||||||
return solved.filter((s) => dates.has(s.closed));
|
const rows = due.filter((d) => d.bucket === days);
|
||||||
|
byWindow.set(days, await Promise.all(rows.map(link)));
|
||||||
|
}
|
||||||
|
|
||||||
|
function bucket(days: number): Linked[] {
|
||||||
|
return byWindow.get(days) ?? [];
|
||||||
}
|
}
|
||||||
|
|
||||||
/** One checkbox line, straight to LeetCode. */
|
/** One checkbox line, straight to LeetCode. */
|
||||||
function line(s: Solved, box: boolean): string {
|
function line(s: Linked, box: boolean): string {
|
||||||
|
const seen = s.days_since === null ? "unlogged" : `last seen ${s.days_since}d ago`;
|
||||||
return (
|
return (
|
||||||
`${box ? "- [ ] " : "- "}[LC ${s.lc} · ${s.name}](${s.url}) · ${s.difficulty} · #${s.issue}` +
|
`${box ? "- [ ] " : "- "}[LC ${s.lc} · ${s.title}](${s.url}) · ${s.difficulty} · #${s.issue}` +
|
||||||
|
` · ${seen}${s.late > 0 ? ` · ⏰ ${s.late}d late` : ""}` +
|
||||||
(s.linked ? "" : " · ⚠️ link guessed from title")
|
(s.linked ? "" : " · ⚠️ link guessed from title")
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const sections = WINDOWS.map((days) => {
|
const sections = WINDOWS.map((days) => {
|
||||||
const from = windowSources(days).map((d) => longDate(d)).join(" and ");
|
|
||||||
const rows = bucket(days);
|
const rows = bucket(days);
|
||||||
const due = rows.slice(0, WINDOW_CAP);
|
const required = rows.slice(0, WINDOW_CAP);
|
||||||
const spill = rows.slice(WINDOW_CAP);
|
const spill = rows.slice(WINDOW_CAP);
|
||||||
return [
|
return [
|
||||||
`### +${days} days — solved ${from}`,
|
`### +${days} days — re-solve into \`work/${days}/\``,
|
||||||
"",
|
"",
|
||||||
...(due.length ? due.map((s) => line(s, true)) : ["_Nothing closed then._"]),
|
...(required.length
|
||||||
// Overflow is shown, not dropped: a day that closed more than WINDOW_CAP
|
? required.map((s) => line(s, true))
|
||||||
// problems (a backfill, or a catch-up weekend) would otherwise silently
|
: [`_Nothing at the +${days} rung today._`]),
|
||||||
// lose reviews, and this issue is the only record of what was due.
|
// Overflow is shown, not dropped: the ladder levels everything past the
|
||||||
|
// cap onto later working days, but until that morning's digest runs this
|
||||||
|
// issue is the only record of what was standing.
|
||||||
...(spill.length
|
...(spill.length
|
||||||
? [
|
? [
|
||||||
"",
|
"",
|
||||||
`<details><summary>${spill.length} more solved then — optional</summary>`,
|
`<details><summary>${spill.length} more at this rung — optional</summary>`,
|
||||||
"",
|
"",
|
||||||
...spill.map((s) => line(s, false)),
|
...spill.map((s) => line(s, false)),
|
||||||
"",
|
"",
|
||||||
@@ -243,12 +278,13 @@ const body = [
|
|||||||
"",
|
"",
|
||||||
"- 15 minutes per problem, then stop and read your own solution.",
|
"- 15 minutes per problem, then stop and read your own solution.",
|
||||||
"- Say the invariant and the complexity out loud before typing.",
|
"- Say the invariant and the complexity out loud before typing.",
|
||||||
"- Failed one? Tick it anyway and expect it back in the next window.",
|
"- Failed one? Tick it anyway and expect it back at +3.",
|
||||||
|
"- `bun run pick` → Tab to this rung's section scaffolds the blind re-solve.",
|
||||||
"",
|
"",
|
||||||
...sections,
|
...sections,
|
||||||
`<sub>Recomputed from the problem issues closed ${WINDOWS.join(" and ")} days ago ` +
|
"<sub>Today's due list straight from the SRS ladder in D1 (`POST /admin/due`), rendered by " +
|
||||||
"by `.github/workflows/spaced-repetition.yml` — re-runs on the same day rewrite this body. " +
|
"`.github/workflows/spaced-repetition.yml` — re-runs on the same day rewrite this body. " +
|
||||||
"A window that would fall on Sunday is served on the Monday instead.</sub>",
|
"The ladder never schedules a Sunday, so there is no issue on the rest day.</sub>",
|
||||||
].join("\n");
|
].join("\n");
|
||||||
|
|
||||||
// ── create or refresh today's issue ──────────────────────────────
|
// ── create or refresh today's issue ──────────────────────────────
|
||||||
@@ -301,7 +337,7 @@ if (total === 0) {
|
|||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
name: LABEL,
|
name: LABEL,
|
||||||
color: "0E8A16",
|
color: "0E8A16",
|
||||||
description: "Daily blind re-solve of problems closed 3 and 7 days ago",
|
description: "Daily blind re-solve of whatever the SRS ladder has due",
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -323,18 +359,19 @@ const report = WINDOWS.flatMap((days) =>
|
|||||||
String(s.lc),
|
String(s.lc),
|
||||||
`#${s.issue}`,
|
`#${s.issue}`,
|
||||||
s.difficulty,
|
s.difficulty,
|
||||||
|
s.due + (s.late > 0 ? ` (${s.late}d late)` : ""),
|
||||||
i < WINDOW_CAP ? "due" : "optional",
|
i < WINDOW_CAP ? "due" : "optional",
|
||||||
s.linked ? s.url : `${s.url} (inferred)`,
|
s.linked ? s.url : `${s.url} (inferred)`,
|
||||||
]),
|
]),
|
||||||
);
|
);
|
||||||
const rows = [["window", "lc", "issue", "diff", "state", "leetcode"], ...report];
|
const rows = [["rung", "lc", "issue", "diff", "scheduled", "state", "leetcode"], ...report];
|
||||||
if (report.length) printTable(rows);
|
if (report.length) printTable(rows);
|
||||||
|
|
||||||
console.log(
|
console.log(
|
||||||
`\n${title}${DRY ? " (dry run)" : ""}\n` +
|
`\n${title}${DRY ? " (dry run)" : ""}\n` +
|
||||||
WINDOWS.map((days) => {
|
WINDOWS.map((days) => {
|
||||||
const n = bucket(days).length;
|
const n = bucket(days).length;
|
||||||
return `+${days}d ${windowSources(days).join("+")}: ${Math.min(n, WINDOW_CAP)} due${n > WINDOW_CAP ? ` (+${n - WINDOW_CAP} optional)` : ""}`;
|
return `+${days}d: ${Math.min(n, WINDOW_CAP)} due${n > WINDOW_CAP ? ` (+${n - WINDOW_CAP} optional)` : ""}`;
|
||||||
}).join(" · ") +
|
}).join(" · ") +
|
||||||
`\n${status}`,
|
`\n${status}`,
|
||||||
);
|
);
|
||||||
@@ -343,5 +380,5 @@ if (DRY) console.log(`\n${body}`);
|
|||||||
await writeStepSummary(
|
await writeStepSummary(
|
||||||
`### ${title}${DRY ? " (dry run)" : ""}\n\n` +
|
`### ${title}${DRY ? " (dry run)" : ""}\n\n` +
|
||||||
`${total} problem${total === 1 ? "" : "s"} due · ${status}\n\n` +
|
`${total} problem${total === 1 ? "" : "s"} due · ${status}\n\n` +
|
||||||
(report.length ? `${markdownTable(rows)}\n` : "Nothing closed in either window.\n"),
|
(report.length ? `${markdownTable(rows)}\n` : "The ladder has nothing due today.\n"),
|
||||||
);
|
);
|
||||||
|
|||||||
Reference in New Issue
Block a user