mirror of
https://github.com/prdlk/leetcode.git
synced 2026-09-16 23:16:26 +00:00
feat(srs): switch ladder to +3/+7 and enforce rest‑day logic
This commit is contained in:
@@ -10,7 +10,7 @@
|
||||
* so relabelling a problem is enough to move it and titles stay readable.
|
||||
* The `problem` label is what marks a sub-issue as curriculum.
|
||||
*/
|
||||
import { addDays } from "./srs.ts";
|
||||
import { workingDay } from "./srs.ts";
|
||||
import type { GitHub } from "./github.ts";
|
||||
|
||||
const TITLE_RE = /^LC (\d+) · (.+)$/;
|
||||
@@ -18,7 +18,7 @@ const TITLE_RE = /^LC (\d+) · (.+)$/;
|
||||
const DIFFICULTIES = ["easy", "medium", "hard"] as const;
|
||||
const SETS = ["core", "optional", "deferred"] as const;
|
||||
|
||||
/** Deferred Hards enter the queue from this date, two per day. */
|
||||
/** Deferred Hards enter the queue from this date, two per working day. */
|
||||
const DEFER_FROM = "2026-09-28";
|
||||
|
||||
export interface ReconcileReport {
|
||||
@@ -96,9 +96,10 @@ export async function reconcileCatalog(db: D1Database, gh: GitHub): Promise<Reco
|
||||
continue;
|
||||
}
|
||||
const lc = Number(m[1]);
|
||||
// Two deferred Hards per day from DEFER_FROM, in catalog walk order —
|
||||
// applied only when the row is first created (SRS owns it afterwards).
|
||||
const defer = set === "deferred" ? addDays(DEFER_FROM, Math.floor(deferredSeen / 2)) : null;
|
||||
// Two deferred Hards per WORKING day from DEFER_FROM, in catalog walk
|
||||
// order — applied only when the row is first created (SRS owns it
|
||||
// afterwards). Sunday is skipped like every other scheduled date.
|
||||
const defer = set === "deferred" ? workingDay(DEFER_FROM, Math.floor(deferredSeen / 2)) : null;
|
||||
if (set === "deferred") deferredSeen++;
|
||||
statements.push(
|
||||
db
|
||||
|
||||
@@ -14,6 +14,7 @@
|
||||
* badges: rounded #09090b cards, #fafafa/#a1a1aa text, GitHub dark-mode
|
||||
* green ramp. Every function renders on a zero-row DB.
|
||||
*/
|
||||
import { STAGES } from "./srs.ts";
|
||||
import { Canvas, textWidth } from "./png.ts";
|
||||
|
||||
// D1Database comes from the generated worker-configuration.d.ts runtime types.
|
||||
@@ -43,8 +44,6 @@ const PHASES = [
|
||||
{ milestone: 5, name: "V — Decision Space" },
|
||||
];
|
||||
|
||||
const STAGES = ["new", "+2", "+5", "+10", "retired"];
|
||||
|
||||
// ── svg helpers ──────────────────────────────────────────────────
|
||||
|
||||
/** Opening tag plus the rounded dark card every chart starts with. */
|
||||
@@ -159,6 +158,9 @@ export async function progressChart(db: D1Database): Promise<string> {
|
||||
|
||||
// ── ladder: bar per stage ────────────────────────────────────────
|
||||
|
||||
// One bar per rung, labelled and ordered by srs.ts's STAGES, so changing the
|
||||
// ladder can never leave a stale bar here: the slot width is derived from the
|
||||
// rung count, only the bar width inside a slot is fixed.
|
||||
export async function ladderChart(db: D1Database): Promise<string> {
|
||||
const { results } = await db
|
||||
.prepare(`SELECT stage, COUNT(*) AS count FROM problems GROUP BY stage`)
|
||||
|
||||
@@ -62,12 +62,12 @@ const DIFFICULTY: Record<string, { label: string; color: string }> = {
|
||||
hard: { label: "Hard", color: RED },
|
||||
};
|
||||
|
||||
// The SRS ladder in plain English — "+5" means the last look was 5 days back.
|
||||
// The SRS ladder in plain English — stage "+3" means the last clean look was
|
||||
// three days back, so that is what the reader is told.
|
||||
const LAST_SEEN: Record<string, string> = {
|
||||
new: "first look",
|
||||
"+2": "2 days ago",
|
||||
"+5": "5 days ago",
|
||||
"+10": "10 days ago",
|
||||
"+3": "3 days ago",
|
||||
"+7": "7 days ago",
|
||||
retired: "retired",
|
||||
};
|
||||
|
||||
@@ -302,7 +302,7 @@ function DigestEmail({ data }: { data: DigestData }) {
|
||||
<Text style={{ color: MUTED, fontSize: "13px", margin: "8px 0 0" }}>
|
||||
{data.progress}
|
||||
{data.rest
|
||||
? " · nothing is due today, and anything overdue waits for Monday."
|
||||
? " · the ladder never books a Sunday, so nothing is due — anything still open waits for Monday."
|
||||
: data.streak === 0
|
||||
? " · no streak going yet — today is a good day to start one."
|
||||
: ` · 🔥 ${data.streak} day${data.streak === 1 ? "" : "s"} in a row, keep it going.`}
|
||||
@@ -403,7 +403,7 @@ function plainDigest(data: DigestData): string {
|
||||
data.rest ? `Take the day off — ${data.day}` : `Good morning. It's ${data.day}.`,
|
||||
data.progress +
|
||||
(data.rest
|
||||
? " · nothing is due today, and anything overdue waits for Monday."
|
||||
? " · the ladder never books a Sunday, so nothing is due — anything still open waits for Monday."
|
||||
: data.streak === 0
|
||||
? " · no streak going yet — today is a good day to start one."
|
||||
: ` · ${data.streak} day${data.streak === 1 ? "" : "s"} in a row, keep it going.`),
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
* The gate half is a topic-blind quiz — one unsolved optional problem per
|
||||
* topic scheduled this week (which is why daily drills only draw from
|
||||
* earlier weeks). The recap half is generated data, deliberately NOT a task
|
||||
* list: the week's solved problems are already scheduled by the +2 ladder,
|
||||
* list: the week's solved problems are already scheduled by the +3/+7 ladder,
|
||||
* and re-assigning them on Saturday would be massed practice.
|
||||
*
|
||||
* Label is `review` (the retired Actions system owned `gate`). Creation is
|
||||
|
||||
@@ -44,7 +44,7 @@ async function mirrorOutcome(env: Env, outcome: LogOutcome, source: string): Pro
|
||||
try {
|
||||
await gh.closeIssue(
|
||||
outcome.issue,
|
||||
`First attempt passed (${outcome.kind}, via ${source}) — entering the review ladder at +2. Logged by the SRS Worker.`,
|
||||
`First attempt passed (${outcome.kind}, via ${source}) — entering the review ladder at +3. Logged by the SRS Worker.`,
|
||||
);
|
||||
} catch (err) {
|
||||
console.warn(`close #${outcome.issue}: ${err}`);
|
||||
@@ -96,7 +96,7 @@ async function handleTap(request: Request, env: Env, ctx: ExecutionContext): Pro
|
||||
}
|
||||
ctx.waitUntil(mirrorOutcome(env, outcome, "email"));
|
||||
return page(
|
||||
result === "pass" ? "Logged ✅" : "Logged — back to +2",
|
||||
result === "pass" ? "Logged ✅" : "Logged — back to +3",
|
||||
outcomeLine(outcome),
|
||||
);
|
||||
}
|
||||
@@ -232,7 +232,7 @@ async function handleSolved(request: Request, env: Env, date: string): Promise<R
|
||||
const p = await getProblem(env.DB, lc);
|
||||
if (!p) skipped.push(`LC ${lc} is not in the curriculum`);
|
||||
else if (p.stage !== "new") skipped.push(`LC ${lc}: already at stage ${p.stage}`);
|
||||
else logged.push(`LC ${lc}: would enter the ladder at +2`);
|
||||
else logged.push(`LC ${lc}: would enter the ladder at +3`);
|
||||
continue;
|
||||
}
|
||||
const outcome = await logAttempt(env.DB, { lc, date, result: "pass", source: "commit" });
|
||||
|
||||
+73
-18
@@ -1,12 +1,19 @@
|
||||
/**
|
||||
* SRS domain: ET dates, the interval ladder, deterministic sampling, and the
|
||||
* one write path for attempts — ported from scripts/srs.ts, re-homed on D1.
|
||||
* SRS domain: ET dates, the work week, the interval ladder, deterministic
|
||||
* sampling, and the one write path for attempts.
|
||||
*
|
||||
* D1 is the single source of truth. The stage names the review a problem must
|
||||
* pass NEXT (`+2` = due 2 days after last clean solve). Passing advances
|
||||
* new → +2 → +5 → +10 → retired; any failure resets to +2. A problem's
|
||||
* FIRST-ever log enters the ladder at +2 regardless of result: a pass earns
|
||||
* a +2 review, a fail must be re-solved just as soon.
|
||||
* pass NEXT (`+3` = due 3 working days after the last clean solve). Passing
|
||||
* advances new → +3 → +7 → retired; any failure resets to +3. A problem's
|
||||
* FIRST-ever log enters the ladder at +3 regardless of result: a pass earns a
|
||||
* 3-day review, a fail must be re-solved just as soon. Two rungs, 3 and 7 —
|
||||
* the same numbers as the `work/3` and `work/7` buckets a re-solve is
|
||||
* scaffolded into, and as the review-issue windows in
|
||||
* apps/cli/spaced-repetition.ts.
|
||||
*
|
||||
* Sunday is off, structurally: every scheduled date comes out of
|
||||
* workingDay(), which never returns one. Nothing "falls on" the rest day and
|
||||
* gets swallowed or carried — it is simply never booked there.
|
||||
*
|
||||
* All dates are America/New_York calendar strings (YYYY-MM-DD); arithmetic is
|
||||
* anchored at noon UTC so DST edges cannot shift a date. Never raw Date math.
|
||||
@@ -56,6 +63,37 @@ export function weekdayOf(date: string): number {
|
||||
return atNoon(date).getUTCDay();
|
||||
}
|
||||
|
||||
// ── the work week ────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Sunday, the one day the campaign never schedules: topics run Mon–Fri
|
||||
* (data/schedule.json), the gate is Saturday, and the review windows below are
|
||||
* picked so a solve comes back on a working day.
|
||||
*/
|
||||
const REST_DAY = 0;
|
||||
|
||||
export function isRestDay(date: string): boolean {
|
||||
return weekdayOf(date) === REST_DAY;
|
||||
}
|
||||
|
||||
/**
|
||||
* The `offset`-th working day counting from `from` (0 = `from` itself), with
|
||||
* Sundays skipped — the ONE place a scheduled date is minted.
|
||||
*
|
||||
* Work may slide later, never earlier: pulling a review back to Saturday would
|
||||
* shorten the very interval it exists to test, so a Sunday landing becomes
|
||||
* Monday. With the +3/+7 ladder that only ever happens to a Thursday solve's
|
||||
* 3-day review; +7 lands on the solve's own weekday, which is never a Sunday.
|
||||
*/
|
||||
export function workingDay(from: string, offset = 0): string {
|
||||
let cursor = isRestDay(from) ? addDays(from, 1) : from;
|
||||
for (let i = 0; i < offset; i++) {
|
||||
cursor = addDays(cursor, 1);
|
||||
if (isRestDay(cursor)) cursor = addDays(cursor, 1);
|
||||
}
|
||||
return cursor;
|
||||
}
|
||||
|
||||
export function campaignDay(date: string): number {
|
||||
return daysBetween(CAMPAIGN_START, date) + 1;
|
||||
}
|
||||
@@ -126,7 +164,21 @@ export function sample<T>(pool: T[], n: number, random: () => number): T[] {
|
||||
|
||||
// ── rows ─────────────────────────────────────────────────────────
|
||||
|
||||
export type Stage = "new" | "+2" | "+5" | "+10" | "retired";
|
||||
/**
|
||||
* Review windows in days. One vocabulary for the whole campaign: these are the
|
||||
* ladder rungs, the `work/<n>` buckets the picker scaffolds into, and the
|
||||
* windows the `Spaced Repetition — <date>` issues are built from.
|
||||
*
|
||||
* 3 and 7 keep the rest day free. +7 returns a solve to its own weekday, and
|
||||
* of the five learning weekdays only Thursday's +3 touches a Sunday — which
|
||||
* workingDay() slides to Monday.
|
||||
*/
|
||||
export const WINDOWS = [3, 7] as const;
|
||||
export type Window = (typeof WINDOWS)[number];
|
||||
|
||||
export type Stage = "new" | `+${Window}` | "retired";
|
||||
/** Chart / stats order, low to high. */
|
||||
export const STAGES: Stage[] = ["new", "+3", "+7", "retired"];
|
||||
export type Result = "pass" | "fail";
|
||||
export type Kind = "first" | "review" | "drill" | "gate";
|
||||
|
||||
@@ -142,8 +194,8 @@ export interface ProblemRow {
|
||||
defer_until: string | null;
|
||||
}
|
||||
|
||||
export const INTERVAL: Record<string, number> = { "+2": 2, "+5": 5, "+10": 10 };
|
||||
const NEXT_STAGE: Record<string, Stage> = { new: "+2", "+2": "+5", "+5": "+10", "+10": "retired" };
|
||||
export const INTERVAL: Record<string, number> = { "+3": 3, "+7": 7 };
|
||||
const NEXT_STAGE: Record<string, Stage> = { new: "+3", "+3": "+7", "+7": "retired" };
|
||||
|
||||
// ── queries ──────────────────────────────────────────────────────
|
||||
|
||||
@@ -170,12 +222,13 @@ export const REVIEW_CAP = 3;
|
||||
|
||||
/**
|
||||
* Load-level an overloaded review queue: everything due beyond today's
|
||||
* REVIEW_CAP is pushed to a concrete future date — at most REVIEW_CAP per
|
||||
* day, oldest first — instead of piling up as "due today". Runs every digest
|
||||
* morning, so a future day that grows past the cap (spill plus newly
|
||||
* REVIEW_CAP is pushed to a concrete future WORKING day — at most REVIEW_CAP
|
||||
* per day, oldest first — instead of piling up as "due today". Runs every
|
||||
* digest morning, so a future day that grows past the cap (spill plus newly
|
||||
* maturing reviews) is simply re-levelled when it arrives. Idempotent within
|
||||
* a date: after one pass at most REVIEW_CAP problems remain due today, so a
|
||||
* second pass moves nothing.
|
||||
* second pass moves nothing. The spill starts tomorrow, or Monday when
|
||||
* tomorrow is the rest day.
|
||||
*/
|
||||
export async function levelReviews(db: D1Database, date: string): Promise<number> {
|
||||
const overflow = (await dueReviews(db, date)).slice(REVIEW_CAP);
|
||||
@@ -184,7 +237,7 @@ export async function levelReviews(db: D1Database, date: string): Promise<number
|
||||
overflow.map((p, i) =>
|
||||
db
|
||||
.prepare("UPDATE problems SET next_review = ? WHERE lc_number = ?")
|
||||
.bind(addDays(date, 1 + Math.floor(i / REVIEW_CAP)), p.lc_number),
|
||||
.bind(workingDay(addDays(date, 1), Math.floor(i / REVIEW_CAP)), p.lc_number),
|
||||
),
|
||||
);
|
||||
return overflow.length;
|
||||
@@ -336,16 +389,18 @@ export async function logAttempt(
|
||||
return { ...nothing, title: p.title, kind, stage: p.stage, next_review: p.next_review, issue: p.issue, duplicate: true };
|
||||
}
|
||||
|
||||
// Ladder move. First-ever logs enter at +2 for pass AND fail.
|
||||
// Ladder move. First-ever logs enter at +3 for pass AND fail.
|
||||
let stage: Stage;
|
||||
if (first) {
|
||||
stage = "+2";
|
||||
stage = "+3";
|
||||
} else if (opts.result === "pass") {
|
||||
stage = NEXT_STAGE[p.stage] ?? "retired";
|
||||
} else {
|
||||
stage = "+2";
|
||||
stage = "+3";
|
||||
}
|
||||
const next = stage === "retired" ? null : addDays(opts.date, INTERVAL[stage]!);
|
||||
// workingDay(), not addDays(): a Thursday solve's +3 would land on the rest
|
||||
// day, and it takes Monday instead.
|
||||
const next = stage === "retired" ? null : workingDay(addDays(opts.date, INTERVAL[stage]!));
|
||||
|
||||
const writes = [
|
||||
db.prepare(
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
campaignDay,
|
||||
campaignWeek,
|
||||
isoWeek,
|
||||
STAGES,
|
||||
streak,
|
||||
} from "./srs.ts";
|
||||
|
||||
@@ -59,7 +60,8 @@ export async function buildStats(db: D1Database, today: string): Promise<object>
|
||||
const { results: ladderRows } = await db
|
||||
.prepare("SELECT stage, COUNT(*) AS n FROM problems GROUP BY stage")
|
||||
.all<{ stage: string; n: number }>();
|
||||
const ladder: Record<string, number> = { new: 0, "+2": 0, "+5": 0, "+10": 0, retired: 0 };
|
||||
// Every rung is present even at zero: the page's bar list is this object.
|
||||
const ladder: Record<string, number> = Object.fromEntries(STAGES.map((s) => [s, 0]));
|
||||
for (const row of ladderRows) ladder[row.stage] = row.n;
|
||||
|
||||
// gates rows are keyed by ISO week (that's the digest/gate contract), but
|
||||
|
||||
Reference in New Issue
Block a user