/** * The work/ layout: one directory per spaced-repetition window. * * work/1///.. first solve * work/3///.. 3-day blind re-solve * work/7///.. 7-day blind re-solve * * The bucket name is the review window in days — the same +3/+7 windows the * campaign's `Spaced Repetition — ` issues use (WINDOWS in * spaced-repetition.ts). Below the bucket the layout is leetcode-cli's own * `workDir/Difficulty/Category/` shape, so a re-solve lands beside its first * solve under a different bucket. * * A window is *owed* when the first solve is at least that many days old and * the bucket has no file for that problem yet. Reconciled from the filesystem * on every run the way close-solved.ts reconciles issues: no state file, no * bookkeeping, and a re-solve closes its window just by existing — presence, * not content, so a stub in work/3 counts as done the same way `bun run pick` * treats a scaffold under work/1 as picked. The first solve is the one place * content is read: an empty scaffold in work/1 owes nothing, it is simply * unsolved (isImplemented() in source.ts, the same gate close-solved.ts uses * before it closes an issue). * * "First solve" is the ET date of the commit that first added the problem's * work/ file — the same push that closes its problem issue and therefore * starts the campaign's +3/+7 windows. An uncommitted solution has no date and * is never owed a review yet: you just solved it. ET dates and the date * arithmetic come from apps/api/src/srs.ts so the local picker and D1 cannot * disagree about what day it is. * * Library: side-effect-free on import — nothing scans or shells out until a * function is called. */ import { readFileSync } from "node:fs"; import { join } from "node:path"; import { WINDOWS, type Window, daysBetween } from "../api/src/srs.ts"; import { isImplemented } from "./source.ts"; export const ROOT = join(import.meta.dir, "..", ".."); export const WORK = join(ROOT, "work"); // The windows are the SRS ladder's rungs: srs.ts defines them once and the // bucket directories are named after them. Re-exported so the picker needs // only this module to know the work/ layout. export { WINDOWS, type Window }; /** work/1 is the first solve, the rest are the review windows. */ export const BUCKETS = [1, ...WINDOWS] as const; export type Bucket = (typeof BUCKETS)[number]; const BUCKET_BY_DIR: Record = { "1": 1, "3": 3, "7": 7 }; export function bucketDir(bucket: Bucket): string { return join(WORK, String(bucket)); } export interface WorkFile { lc: number; slug: string; bucket: Bucket; /** Path relative to work/, bucket included. */ rel: string; difficulty: string; category: string; } /** `..` — the only file name the campaign scripts parse. */ function parseName(name: string): { lc: number; slug: string } | null { const m = name.match(/^(\d+)\.(.+)\.[A-Za-z0-9]+$/); return m ? { lc: Number(m[1]), slug: m[2]! } : null; } /** Parse `1/Medium/Array/15.3sum.py` — null for anything off-layout. */ export function parseWorkPath(rel: string): WorkFile | null { const parts = rel.split("/"); const name = parts.pop(); const bucket = BUCKET_BY_DIR[parts[0] ?? ""]; if (name === undefined || bucket === undefined) return null; const parsed = parseName(name); if (!parsed) return null; return { ...parsed, bucket, rel, difficulty: parts[1] ?? "", category: parts[2] ?? "" }; } /** LC number -> its files in that bucket (a problem may have both js and py). */ export function scanBucket(bucket: Bucket): Map { const files = new Map(); for (const name of new Bun.Glob("**/*.*").scanSync({ cwd: bucketDir(bucket) })) { const file = parseWorkPath(`${bucket}/${name}`); if (!file) continue; const found = files.get(file.lc); if (found) found.push(file); else files.set(file.lc, [file]); } return files; } /** * LC number -> ET date of the commit that first added a work/ file for it. * * One `git log` over the whole of work/, oldest commit first, so the first * date seen for a problem wins. Keyed by LC number rather than by path, which * makes every past reshuffle of the directory layout — including the move into * work/1 — irrelevant: the original add still counts. TZ + `format-local` * put the dates on the ET calendar srs.ts speaks. */ export function firstSolved(): Map { const git = Bun.spawnSync( [ "git", "log", "--reverse", "--no-merges", "--diff-filter=A", "--date=format-local:%Y-%m-%d", "--format=%ad", "--name-only", "--", "work", ], { cwd: ROOT, env: { ...process.env, TZ: "America/New_York" } }, ); if (git.exitCode !== 0) { throw new Error(`git log failed: ${git.stderr.toString().trim()}`); } const dates = new Map(); let date = ""; for (const line of git.stdout.toString().split("\n")) { if (/^\d{4}-\d{2}-\d{2}$/.test(line)) { date = line; continue; } const parsed = line === "" ? null : parseName(line.slice(line.lastIndexOf("/") + 1)); if (parsed && !dates.has(parsed.lc)) dates.set(parsed.lc, date); } return dates; } export interface Owed extends WorkFile { /** ET date of the first solve. */ solved: string; /** Days since the first solve — at least the window. */ days: number; } /** * Both review windows for `today`, oldest solve first. * * Uncapped, unlike the review issue's WINDOW_CAP: the issue is one day's * assignment, this is everything still owed. A window skipped for a day stays * on the list until its file exists. */ export function reviewQueues(today: string): Map { const dates = firstSolved(); // An empty scaffold under work/1 is a problem still unsolved, not one owing // a review — the same call close-solved.ts makes before it closes an issue. const solved: { file: WorkFile; date: string }[] = []; for (const files of scanBucket(1).values()) { const date = dates.get(files[0]!.lc); if (date === undefined) continue; // uncommitted: you only just solved it const file = files.find((f) => isImplemented(readFileSync(join(WORK, f.rel), "utf8"), f.rel.endsWith(".py") ? "py" : "js"), ); if (file) solved.push({ file, date }); } const queues = new Map(); for (const window of WINDOWS) { const done = scanBucket(window); const owed: Owed[] = []; for (const { file, date } of solved) { if (done.has(file.lc)) continue; const days = daysBetween(date, today); if (days >= window) owed.push({ ...file, solved: date, days }); } owed.sort((a, b) => b.days - a.days || a.lc - b.lc); queues.set(window, owed); } return queues; }