feat(cli): send bucket info to SRS API and render due list from worker

This commit is contained in:
Prad Nukala
2026-08-31 10:49:50 -04:00
parent 1622bf39b7
commit a3657732f6
2 changed files with 182 additions and 144 deletions
+24 -23
View File
@@ -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;
} else { if (implemented) entry.solved.add(file.bucket);
work.set(num, { files: [`work/${rel}`], implemented }); work.set(file.lc, entry);
}
} }
// ── 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
View File
@@ -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"),
); );