Files
leetcode/scripts/srs-scheduler.ts
T

202 lines
7.1 KiB
TypeScript
Executable File

#!/usr/bin/env bun
/**
* Rewrite the pinned `📋 Today` daily-brief issue from SRS state.
*
* Runs every day at 10:00 UTC (6 AM ET) and on demand. Stateless by design:
* it reads srs.json + schedule.json and rewrites one issue body, so running
* it twice in a day is a byte-identical no-op (drill picks are seeded by the
* date). Overflow needs no bookkeeping either — anything past the daily cap
* simply stays due and is the oldest entry tomorrow.
*
* Retrieval rules enforced here:
* - Review and drill lines carry number + difficulty only. Never the topic,
* never a link — recognizing the pattern unaided is the exercise.
* - Blind drills come only from optional pools of topics covered in EARLIER
* weeks; the current week's optional pool is reserved for Saturday's gate.
* - Boosted topics (failed gate problem / repeated drill misses) inject up to
* 2 extra drills, inside the same daily cap.
*
* bun scripts/srs-scheduler.ts # rewrite the live issue
* SRS_DRY=1 SRS_TODAY=2026-08-30 bun ... # print the body, touch nothing
*/
import { github } from "./github.ts";
import { projectMirror, reportMirror } from "./srs-project.ts";
import {
CAMPAIGN_DAYS,
type Catalog,
type State,
TODAY_TITLE,
campaignDay,
campaignWeek,
dueReviews,
fetchCatalog,
loadSchedule,
loadState,
prettyDate,
rng,
sample,
todayET,
weekdayOf,
} from "./srs.ts";
const DRY = process.env.SRS_DRY === "1";
const DAILY_CAP = 6;
const gh = await github();
const state = await loadState();
const schedule = await loadSchedule();
const today = todayET();
const week = campaignWeek(today);
// ── compose the body ─────────────────────────────────────────────
/** Week in which a topic was (or will be) taught, from the schedule file. */
function topicWeek(topic: number): number | undefined {
for (const [date, t] of Object.entries(schedule)) {
if (t === topic) return campaignWeek(date);
}
return undefined;
}
interface Drill {
lc: number;
difficulty: string;
}
function pickDrills(catalog: Catalog, budget: number): Drill[] {
// Optional problems from earlier weeks, unseen by both ladder and drills.
const pool = [...catalog.problems.values()]
.filter((p) => {
if (p.set !== "optional" || !p.open) return false;
if (state.problems[p.lc] || state.drill_pool_used.includes(p.lc)) return false;
const w = topicWeek(p.topic);
return w !== undefined && w < week;
})
.sort((a, b) => a.lc - b.lc); // stable base order for the seeded shuffle
const boosted = pool.filter((p) => state.topics[p.topic]?.boost);
const regular = pool.filter((p) => !state.topics[p.topic]?.boost);
const picks = [
...sample(boosted, Math.min(2, budget), rng(`boost-${today}`)),
...sample(regular, Math.min(2, Math.max(0, budget - Math.min(2, boosted.length))), rng(`drill-${today}`)),
].slice(0, budget);
return picks.map((p) => ({ lc: p.lc, difficulty: p.difficulty }));
}
function composeBody(catalog: Catalog): string {
if (weekdayOf(today) === 0) {
return "Rest day. Nothing is due. Overdue reviews moved to Monday.";
}
const due = dueReviews(state, today);
const reviews = due.slice(0, DAILY_CAP);
const carried = due.length - reviews.length;
const drills = pickDrills(catalog, DAILY_CAP - reviews.length);
const lines: string[] = [
`## ${prettyDate(today)} — Day ${campaignDay(today)}/${CAMPAIGN_DAYS} · Week ${week}`,
"",
];
const topicIssue = schedule[today];
const topic = topicIssue === undefined ? undefined : catalog.topics.get(topicIssue);
if (topic) {
const core = [...catalog.problems.values()]
.filter((p) => p.topic === topic.issue && p.set === "core")
.sort((a, b) => a.lc - b.lc)
.map((p) => `[LC ${p.lc}](${p.url})${p.open ? "" : " ✓"}`);
lines.push(`### New topic: ${topic.name} (#${topic.issue})`, `Core: ${core.join(" · ")}`, "");
} else {
lines.push("_No new topic today — reviews and drills only._", "");
}
lines.push(`### Reviews due (${reviews.length})`);
if (reviews.length) {
lines.push("Solve each from scratch. Do not open your old solution first.");
for (const [lc, p] of reviews) {
const difficulty = catalog.problems.get(Number(lc))?.difficulty ?? p.difficulty;
lines.push(`- LC ${lc}${difficulty} — stage ${p.stage}`);
}
if (carried > 0) lines.push(`\n${carried} more carried to tomorrow (cap ${DAILY_CAP}).`);
} else {
lines.push("None.");
}
lines.push("");
lines.push(`### Blind drills (${drills.length})`);
if (drills.length) {
lines.push("No topic given. Name the pattern out loud before you code.");
for (const d of drills) lines.push(`- LC ${d.lc}${d.difficulty}`);
} else {
lines.push("None today.");
}
lines.push("");
lines.push(
"### Log your results",
"Comment on this issue, one line per problem:",
"`/done 704 pass` · `/done 15 fail`",
"",
"### Rules",
"90-minute cap · core first, then reviews, then drills · close-out",
"ritual on every submit.",
);
return lines.join("\n");
}
// ── find / create / pin the Today issue ──────────────────────────
async function todayIssue(): Promise<{ number: number; created: boolean }> {
for await (const raw of gh.list(`/repos/${gh.repo}/issues?state=open`)) {
const issue = raw as { number: number; title: string; pull_request?: unknown };
if (!issue.pull_request && issue.title === TODAY_TITLE) {
return { number: issue.number, created: false };
}
}
const created = (await gh.api(`/repos/${gh.repo}/issues`, {
method: "POST",
body: JSON.stringify({ title: TODAY_TITLE, body: "(initializing)" }),
})) as { number: number; node_id: string };
try {
await gh.api("/graphql", {
method: "POST",
body: JSON.stringify({
query: `mutation($id: ID!) { pinIssue(input: { issueId: $id }) { issue { number } } }`,
variables: { id: created.node_id },
}),
});
} catch (err) {
console.warn(`could not pin #${created.number}: ${err} — pin it by hand`);
}
return { number: created.number, created: true };
}
// ── run ──────────────────────────────────────────────────────────
const catalog = await fetchCatalog(gh);
const body = composeBody(catalog);
if (DRY) {
console.log(body);
process.exit(0);
}
const issue = await todayIssue();
await gh.api(`/repos/${gh.repo}/issues/${issue.number}`, {
method: "PATCH",
body: JSON.stringify({ body }),
});
console.log(`${issue.created ? "created + pinned" : "rewrote"} #${issue.number} ${TODAY_TITLE}`);
// Mirror every due problem's next_review so the project's Target Date views
// stay a live review calendar (problem rows only — never topic rows).
const mirror = await projectMirror(gh.repo.split("/")[0]!, gh.repo);
if (weekdayOf(today) !== 0) {
for (const [, p] of dueReviews(state as State, today)) {
await mirror.setTargetDate(p.issue, p.next_review!);
}
}
await reportMirror(mirror);
console.log(body);