From 1b4ad8ca5be1ad532b9e1386eff21acd61c23362 Mon Sep 17 00:00:00 2001 From: Prad Nukala Date: Wed, 26 Aug 2026 10:34:24 -0400 Subject: [PATCH] feat(api): enforce REVIEW_CAP, level overflow reviews, and display difficulty in digest --- apps/api/src/digest.ts | 27 +++++++++++--- apps/api/src/email.tsx | 82 +++++++++++++++++++++++++++++++---------- apps/api/src/srs.ts | 25 +++++++++++++ apps/api/wrangler.jsonc | 2 +- 4 files changed, 110 insertions(+), 26 deletions(-) diff --git a/apps/api/src/digest.ts b/apps/api/src/digest.ts index fd8b89d..d904447 100644 --- a/apps/api/src/digest.ts +++ b/apps/api/src/digest.ts @@ -8,22 +8,28 @@ * Retrieval rules: review and drill rows carry number + difficulty only — * never the topic, never a solution link. `DigestRow` has no field for * either, so the rule holds by construction. The learning day's core list is - * the only labeled section. Reviews + drills ≤ 6, reviews first, overflow - * simply stays due (oldest tomorrow). Sunday is a two-line rest note. + * the only labeled section. At most REVIEW_CAP (3) reviews per day; drills + * fill toward DAILY_CAP (6) only when the due queue is light. Overflow is + * levelled, not stacked: sendDigest gives every review past the cap a + * concrete future date, ≤ REVIEW_CAP per day, oldest first. Sunday is a + * two-line rest note. * * Idempotency: email_log keys sends by ET date — a same-day re-send is a * no-op unless forced; the body itself is deterministic (drill picks are - * seeded by the date). + * seeded by the date). Levelling keeps that: it runs once, before the send, + * and a re-run finds nothing left over the cap. */ import { CAMPAIGN_DAYS, type ProblemRow, + REVIEW_CAP, SCHEDULE, addDays, campaignDay, campaignWeek, dueReviews, isoWeek, + levelReviews, pickDrills, prettyDate, streak, @@ -32,6 +38,7 @@ import { import { type DigestData, type DigestRow, renderDigest } from "./email.tsx"; import { signLink } from "./links.ts"; +/** Total daily ceiling: drills only fill what the whole due queue leaves. */ const DAILY_CAP = 6; export interface Digest { @@ -61,10 +68,12 @@ export async function collectDigest(env: Env, date: string): Promise const db = env.DB; const rest = weekdayOf(date) === 0; - // Reviews take the cap first; drills fill whatever is left. + // Reviews surface at most REVIEW_CAP; drills fill toward DAILY_CAP only + // when the WHOLE due queue is light — a heavy backlog must never trade + // review slots for brand-new drill problems. const due = rest ? [] : await dueReviews(db, date); - const capped = due.slice(0, DAILY_CAP); - const drills = rest ? [] : await pickDrills(db, date, DAILY_CAP - capped.length); + const capped = due.slice(0, REVIEW_CAP); + const drills = rest ? [] : await pickDrills(db, date, DAILY_CAP - Math.min(due.length, DAILY_CAP)); const data: DigestData = { day: prettyDate(date), @@ -96,6 +105,7 @@ export async function collectDigest(env: Env, date: string): Promise name: topic?.name ?? `#${topicIssue}`, core: core.map((p) => ({ lc: p.lc_number, + difficulty: p.difficulty, url: `https://github.com/${env.REPO}/issues/${p.issue}`, solved: p.stage !== "new", })), @@ -164,6 +174,11 @@ export async function sendDigest( .first(); if (already && !opts.force) return { sent: false, reason: "already sent today", digest }; + // The digest above was built against the un-levelled queue (so `carried` + // reports the real backlog); now give everything past the cap its future + // date. Dry runs never reach here, and a forced re-send finds nothing left. + await levelReviews(env.DB, date); + await env.EMAIL.send({ to: env.TO_EMAIL, from: { email: env.FROM_EMAIL, name: "SRS" }, diff --git a/apps/api/src/email.tsx b/apps/api/src/email.tsx index f023638..e6d8cdd 100644 --- a/apps/api/src/email.tsx +++ b/apps/api/src/email.tsx @@ -35,6 +35,7 @@ import { } from "@react-email/components"; import { render } from "@react-email/render"; import type { CSSProperties } from "react"; +import { REVIEW_CAP } from "./srs.ts"; // ── palette (GitHub dark) ──────────────────────────────────────── const CANVAS = "#010409"; @@ -82,6 +83,14 @@ export interface DigestRow { failUrl: string; } +/** A learning-day core problem — the only rows allowed a link. */ +export interface TopicRow { + lc: number; + difficulty: string; + url: string; + solved: boolean; +} + export interface DigestData { /** "Tuesday, August 25" */ day: string; @@ -89,9 +98,9 @@ export interface DigestData { progress: string; streak: number; rest: boolean; - topic?: { name: string; core: { lc: number; url: string; solved: boolean }[] }; + topic?: { name: string; core: TopicRow[] }; reviews: DigestRow[]; - /** Reviews past the daily cap; they simply stay due. */ + /** Reviews past the daily cap; levelled onto the coming days. */ carried: number; drills: DigestRow[]; gate?: { week: number; url: string }; @@ -196,6 +205,48 @@ function ProblemTable({ rows, staged }: { rows: DigestRow[]; staged: boolean }) ); } +/** + * The learning day's core list as the same table shape as reviews — this is + * the one section allowed to link problems, and ticks replace tap buttons. + */ +function TopicTable({ rows }: { rows: TopicRow[] }) { + return ( + + + + + + + + + + {rows.map((p) => { + const difficulty = DIFFICULTY[p.difficulty]; + return ( + + + + + + ); + })} + +
ProblemLevelSolved
+ + LC {p.lc} + + {difficulty?.label ?? p.difficulty} + {p.solved ? "✓" : "—"} +
+ ); +} + function Block({ title, note, @@ -265,18 +316,8 @@ function DigestEmail({ data }: { data: DigestData }) { {data.topic.name} - - Work through these — ticked ones you have already solved:{" "} - {data.topic.core.map((p, i) => ( - - {i > 0 ? " · " : ""} - - LC {p.lc} - - {p.solved ? : null} - - ))} - + Work through these from the top — a tick means it is already solved. + ) : null} @@ -289,8 +330,8 @@ function DigestEmail({ data }: { data: DigestData }) { /> {data.carried > 0 ? ( - {data.carried} more {data.carried === 1 ? "is" : "are"} waiting — they come back - tomorrow, oldest first. + {data.carried} more {data.carried === 1 ? "is" : "are"} waiting — spread over the + coming days, oldest first, never more than {REVIEW_CAP} a day. ) : null} @@ -373,8 +414,11 @@ function plainDigest(data: DigestData): string { out.push( "", `SOMETHING NEW TODAY — ${data.topic.name}`, - "Work through these; the ones marked done you have already solved.", - ...data.topic.core.map((p) => ` LC ${p.lc}${p.solved ? " (done)" : ""} — ${p.url}`), + "Work through these from the top; the ones marked done you have already solved.", + ...data.topic.core.map( + (p) => + ` LC ${p.lc} — ${DIFFICULTY[p.difficulty]?.label ?? p.difficulty}${p.solved ? " (done)" : ""} — ${p.url}`, + ), ); } @@ -411,7 +455,7 @@ function plainDigest(data: DigestData): string { if (data.carried > 0) { out.push( "", - `${data.carried} more ${data.carried === 1 ? "is" : "are"} waiting — they come back tomorrow, oldest first.`, + `${data.carried} more ${data.carried === 1 ? "is" : "are"} waiting — spread over the coming days, oldest first, never more than ${REVIEW_CAP} a day.`, ); } if (data.gate) { diff --git a/apps/api/src/srs.ts b/apps/api/src/srs.ts index dc391ef..e45b6ce 100644 --- a/apps/api/src/srs.ts +++ b/apps/api/src/srs.ts @@ -165,6 +165,31 @@ export async function dueReviews(db: D1Database, date: string): Promise { + const overflow = (await dueReviews(db, date)).slice(REVIEW_CAP); + if (overflow.length === 0) return 0; + await db.batch( + 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), + ), + ); + return overflow.length; +} + /** * Blind drills: unsolved optional problems from topics ALREADY LEARNED — * scheduled in an earlier week (the current week's optional pool is reserved diff --git a/apps/api/wrangler.jsonc b/apps/api/wrangler.jsonc index 9260aa6..f2aa727 100644 --- a/apps/api/wrangler.jsonc +++ b/apps/api/wrangler.jsonc @@ -15,7 +15,7 @@ { "binding": "DB", "database_name": "srs", "database_id": "2ee2c34a-c2e2-4ebb-934e-c9e7dc8aa4f1" } ], - // Sender domain prdlk.com is onboarded to Email Sending; the binding is + // Sender domain prdlk.com is onboarded to Email Service; the binding is // restricted to the one verified destination inbox. "send_email": [ { "name": "EMAIL", "allowed_destination_addresses": ["prnk28@gmail.com"] }