mirror of
https://github.com/prdlk/leetcode.git
synced 2026-09-17 07:26:27 +00:00
331 lines
11 KiB
TypeScript
331 lines
11 KiB
TypeScript
/**
|
||
* 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.
|
||
*
|
||
* 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.
|
||
*
|
||
* 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.
|
||
*/
|
||
import scheduleJson from "../data/schedule.json";
|
||
|
||
// ── dates (America/New_York) ─────────────────────────────────────
|
||
|
||
export const CAMPAIGN_START = "2026-08-17";
|
||
export const CAMPAIGN_DAYS = 56;
|
||
|
||
const ET_DATE = new Intl.DateTimeFormat("en-CA", {
|
||
timeZone: "America/New_York",
|
||
dateStyle: "short",
|
||
});
|
||
const ET_HOUR = new Intl.DateTimeFormat("en-US", {
|
||
timeZone: "America/New_York",
|
||
hour: "numeric",
|
||
hourCycle: "h23",
|
||
});
|
||
|
||
/** ET calendar date of an instant. */
|
||
export function etDate(now: Date): string {
|
||
return ET_DATE.format(now);
|
||
}
|
||
|
||
/** ET hour 0–23 of an instant — the DST-proof cron guard. */
|
||
export function etHour(now: Date): number {
|
||
return Number(ET_HOUR.format(now));
|
||
}
|
||
|
||
/** Noon-UTC anchor: date-only arithmetic immune to DST edges. */
|
||
function atNoon(date: string): Date {
|
||
return new Date(`${date}T12:00:00Z`);
|
||
}
|
||
|
||
export function addDays(date: string, days: number): string {
|
||
return new Date(atNoon(date).getTime() + days * 86_400_000).toISOString().slice(0, 10);
|
||
}
|
||
|
||
export function daysBetween(from: string, to: string): number {
|
||
return Math.round((atNoon(to).getTime() - atNoon(from).getTime()) / 86_400_000);
|
||
}
|
||
|
||
/** 0 = Sunday … 6 = Saturday. */
|
||
export function weekdayOf(date: string): number {
|
||
return atNoon(date).getUTCDay();
|
||
}
|
||
|
||
export function campaignDay(date: string): number {
|
||
return daysBetween(CAMPAIGN_START, date) + 1;
|
||
}
|
||
|
||
/** 1-based campaign week (Mon–Sun), aligned to CAMPAIGN_START. */
|
||
export function campaignWeek(date: string): number {
|
||
return Math.floor(daysBetween(CAMPAIGN_START, date) / 7) + 1;
|
||
}
|
||
|
||
/** Monday of the date's campaign week. */
|
||
export function weekMonday(date: string): string {
|
||
return addDays(CAMPAIGN_START, (campaignWeek(date) - 1) * 7);
|
||
}
|
||
|
||
export function isoWeek(date: string): number {
|
||
const d = atNoon(date);
|
||
d.setUTCDate(d.getUTCDate() + 4 - (d.getUTCDay() || 7));
|
||
const jan1 = new Date(Date.UTC(d.getUTCFullYear(), 0, 1));
|
||
return Math.ceil(((d.getTime() - jan1.getTime()) / 86_400_000 + 1) / 7);
|
||
}
|
||
|
||
export function prettyDate(date: string): string {
|
||
return new Intl.DateTimeFormat("en-US", {
|
||
timeZone: "UTC",
|
||
weekday: "long",
|
||
month: "long",
|
||
day: "numeric",
|
||
}).format(atNoon(date));
|
||
}
|
||
|
||
// ── the schedule (bundled; humans edit apps/api/data/schedule.json) ───
|
||
|
||
export const SCHEDULE: Record<string, number> = scheduleJson;
|
||
|
||
/** Week in which a topic was (or will be) taught. */
|
||
export function topicWeek(topic: number): number | undefined {
|
||
for (const [date, t] of Object.entries(SCHEDULE)) {
|
||
if (t === topic) return campaignWeek(date);
|
||
}
|
||
return undefined;
|
||
}
|
||
|
||
// ── deterministic sampling ───────────────────────────────────────
|
||
|
||
/** FNV-1a → mulberry32: seeded PRNG so re-runs pick identical problems. */
|
||
export function rng(seed: string): () => number {
|
||
let h = 0x811c9dc5;
|
||
for (let i = 0; i < seed.length; i++) {
|
||
h ^= seed.charCodeAt(i);
|
||
h = Math.imul(h, 0x01000193);
|
||
}
|
||
return () => {
|
||
h = Math.imul(h ^ (h >>> 15), h | 1);
|
||
h ^= h + Math.imul(h ^ (h >>> 7), h | 61);
|
||
return ((h ^ (h >>> 14)) >>> 0) / 4294967296;
|
||
};
|
||
}
|
||
|
||
/** Up to n elements, Fisher–Yates order driven by the seeded PRNG. */
|
||
export function sample<T>(pool: T[], n: number, random: () => number): T[] {
|
||
const copy = [...pool];
|
||
for (let i = copy.length - 1; i > 0; i--) {
|
||
const j = Math.floor(random() * (i + 1));
|
||
[copy[i], copy[j]] = [copy[j]!, copy[i]!];
|
||
}
|
||
return copy.slice(0, n);
|
||
}
|
||
|
||
// ── rows ─────────────────────────────────────────────────────────
|
||
|
||
export type Stage = "new" | "+2" | "+5" | "+10" | "retired";
|
||
export type Result = "pass" | "fail";
|
||
export type Kind = "first" | "review" | "drill" | "gate";
|
||
|
||
export interface ProblemRow {
|
||
lc_number: number;
|
||
issue: number;
|
||
topic_issue: number;
|
||
title: string;
|
||
difficulty: string;
|
||
set_label: string;
|
||
stage: Stage;
|
||
next_review: string | null;
|
||
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" };
|
||
|
||
// ── queries ──────────────────────────────────────────────────────
|
||
|
||
export async function getProblem(db: D1Database, lc: number): Promise<ProblemRow | null> {
|
||
return db.prepare("SELECT * FROM problems WHERE lc_number = ?").bind(lc).first<ProblemRow>();
|
||
}
|
||
|
||
/** Reviews due on/before `date`, oldest first — the overflow carry order. */
|
||
export async function dueReviews(db: D1Database, date: string): Promise<ProblemRow[]> {
|
||
const { results } = await db
|
||
.prepare(
|
||
`SELECT * FROM problems
|
||
WHERE stage != 'retired' AND next_review IS NOT NULL AND next_review <= ?1
|
||
AND (defer_until IS NULL OR defer_until <= ?1)
|
||
ORDER BY next_review, lc_number`,
|
||
)
|
||
.bind(date)
|
||
.all<ProblemRow>();
|
||
return results;
|
||
}
|
||
|
||
/**
|
||
* Blind drills: unsolved optional problems from topics ALREADY LEARNED —
|
||
* scheduled in an earlier week (the current week's optional pool is reserved
|
||
* for Saturday's gate) AND showing learning evidence: at least one of the
|
||
* topic's core problems has entered the ladder. A skipped learning day never
|
||
* feeds drills just because its calendar week lapsed. Never repeated,
|
||
* seeded by date. Boosted topics contribute up to 2 extra.
|
||
*/
|
||
export async function pickDrills(
|
||
db: D1Database,
|
||
date: string,
|
||
budget: number,
|
||
): Promise<ProblemRow[]> {
|
||
if (budget <= 0) return [];
|
||
const week = campaignWeek(date);
|
||
const { results } = await db
|
||
.prepare(
|
||
`SELECT p.*, t.boost AS boost FROM problems p
|
||
JOIN topics t ON t.issue = p.topic_issue
|
||
WHERE p.set_label = 'optional' AND p.stage = 'new'
|
||
AND p.lc_number NOT IN (SELECT lc_number FROM drill_pool_used)
|
||
AND NOT EXISTS (SELECT 1 FROM attempts a WHERE a.lc_number = p.lc_number)
|
||
AND EXISTS (SELECT 1 FROM problems c
|
||
WHERE c.topic_issue = p.topic_issue
|
||
AND c.set_label = 'core' AND c.stage != 'new')
|
||
ORDER BY p.lc_number`,
|
||
)
|
||
.all<ProblemRow & { boost: number }>();
|
||
const pool = results.filter((p) => {
|
||
const w = topicWeek(p.topic_issue);
|
||
return w !== undefined && w < week;
|
||
});
|
||
const boosted = pool.filter((p) => p.boost === 1);
|
||
const regular = pool.filter((p) => p.boost !== 1);
|
||
const boostPicks = sample(boosted, Math.min(2, budget), rng(`boost-${date}`));
|
||
const regularPicks = sample(
|
||
regular,
|
||
Math.min(2, Math.max(0, budget - boostPicks.length)),
|
||
rng(`drill-${date}`),
|
||
);
|
||
return [...boostPicks, ...regularPicks].slice(0, budget);
|
||
}
|
||
|
||
/** Consecutive days with ≥1 attempt, ending today or yesterday. */
|
||
export async function streak(db: D1Database, today: string): Promise<number> {
|
||
const { results } = await db
|
||
.prepare("SELECT DISTINCT date FROM attempts ORDER BY date DESC LIMIT 90")
|
||
.all<{ date: string }>();
|
||
const days = new Set(results.map((r) => r.date));
|
||
let cursor = days.has(today) ? today : addDays(today, -1);
|
||
let n = 0;
|
||
while (days.has(cursor)) {
|
||
n++;
|
||
cursor = addDays(cursor, -1);
|
||
}
|
||
return n;
|
||
}
|
||
|
||
// ── the one write path ───────────────────────────────────────────
|
||
|
||
export interface LogOutcome {
|
||
lc: number;
|
||
title: string;
|
||
kind: Kind;
|
||
result: Result;
|
||
stage: Stage;
|
||
next_review: string | null;
|
||
/** First-ever attempt — caller closes the sub-issue on pass. */
|
||
first: boolean;
|
||
issue: number;
|
||
/** Email one-tap replay: nothing changed. */
|
||
duplicate: boolean;
|
||
error?: string;
|
||
}
|
||
|
||
/**
|
||
* Record an attempt and move the ladder. Ladder semantics live here and only
|
||
* here — email one-taps, webhook /done lines, and gate scoring all converge.
|
||
*
|
||
* Email idempotency comes from the partial unique index on
|
||
* (lc_number, date, kind) WHERE source='email': a replayed link inserts
|
||
* nothing and must not touch the ladder. Webhook corrections (pass then fail
|
||
* on the same day) remain legal — every webhook attempt appends.
|
||
*/
|
||
export async function logAttempt(
|
||
db: D1Database,
|
||
opts: { lc: number; date: string; result: Result; source: "email" | "webhook"; gate?: boolean },
|
||
): Promise<LogOutcome> {
|
||
const p = await getProblem(db, opts.lc);
|
||
const nothing: LogOutcome = {
|
||
lc: opts.lc,
|
||
title: "",
|
||
kind: "review",
|
||
result: opts.result,
|
||
stage: "new",
|
||
next_review: null,
|
||
first: false,
|
||
issue: 0,
|
||
duplicate: false,
|
||
};
|
||
if (!p) return { ...nothing, error: `LC ${opts.lc} is not in the curriculum` };
|
||
if (p.stage === "retired") {
|
||
return { ...nothing, title: p.title, issue: p.issue, error: `LC ${opts.lc} is already retired` };
|
||
}
|
||
|
||
const attempted = await db
|
||
.prepare("SELECT 1 AS x FROM attempts WHERE lc_number = ? LIMIT 1")
|
||
.bind(opts.lc)
|
||
.first();
|
||
const first = !attempted && p.stage === "new";
|
||
const kind: Kind = opts.gate ? "gate" : first ? (p.set_label === "optional" ? "drill" : "first") : "review";
|
||
|
||
const inserted = await db
|
||
.prepare(
|
||
`INSERT INTO attempts (lc_number, date, kind, result, source) VALUES (?, ?, ?, ?, ?)
|
||
ON CONFLICT DO NOTHING`,
|
||
)
|
||
.bind(opts.lc, opts.date, kind, opts.result, opts.source)
|
||
.run();
|
||
if (opts.source === "email" && inserted.meta.changes === 0) {
|
||
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.
|
||
let stage: Stage;
|
||
if (first) {
|
||
stage = "+2";
|
||
} else if (opts.result === "pass") {
|
||
stage = NEXT_STAGE[p.stage] ?? "retired";
|
||
} else {
|
||
stage = "+2";
|
||
}
|
||
const next = stage === "retired" ? null : addDays(opts.date, INTERVAL[stage]!);
|
||
|
||
const writes = [
|
||
db.prepare(
|
||
"UPDATE problems SET stage = ?, next_review = ?, defer_until = NULL WHERE lc_number = ?",
|
||
).bind(stage, next, opts.lc),
|
||
];
|
||
if (first && kind === "drill") {
|
||
writes.push(
|
||
db.prepare("INSERT OR IGNORE INTO drill_pool_used (lc_number) VALUES (?)").bind(opts.lc),
|
||
);
|
||
if (opts.result === "fail") {
|
||
writes.push(
|
||
db.prepare("UPDATE topics SET misses = misses + 1 WHERE issue = ?").bind(p.topic_issue),
|
||
);
|
||
}
|
||
}
|
||
await db.batch(writes);
|
||
|
||
return {
|
||
lc: opts.lc,
|
||
title: p.title,
|
||
kind,
|
||
result: opts.result,
|
||
stage,
|
||
next_review: next,
|
||
first,
|
||
issue: p.issue,
|
||
duplicate: false,
|
||
};
|
||
}
|