#!/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);