mirror of
https://github.com/prdlk/leetcode.git
synced 2026-09-17 07:26:27 +00:00
202 lines
7.1 KiB
TypeScript
Executable File
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);
|