feat(api): enforce REVIEW_CAP, level overflow reviews, and display difficulty in digest

This commit is contained in:
Prad Nukala
2026-08-26 10:34:24 -04:00
parent f697a986b8
commit 1b4ad8ca5b
4 changed files with 110 additions and 26 deletions
+21 -6
View File
@@ -8,22 +8,28 @@
* Retrieval rules: review and drill rows carry number + difficulty only — * Retrieval rules: review and drill rows carry number + difficulty only —
* never the topic, never a solution link. `DigestRow` has no field for * 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 * either, so the rule holds by construction. The learning day's core list is
* the only labeled section. Reviews + drills ≤ 6, reviews first, overflow * the only labeled section. At most REVIEW_CAP (3) reviews per day; drills
* simply stays due (oldest tomorrow). Sunday is a two-line rest note. * 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 * 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 * 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 { import {
CAMPAIGN_DAYS, CAMPAIGN_DAYS,
type ProblemRow, type ProblemRow,
REVIEW_CAP,
SCHEDULE, SCHEDULE,
addDays, addDays,
campaignDay, campaignDay,
campaignWeek, campaignWeek,
dueReviews, dueReviews,
isoWeek, isoWeek,
levelReviews,
pickDrills, pickDrills,
prettyDate, prettyDate,
streak, streak,
@@ -32,6 +38,7 @@ import {
import { type DigestData, type DigestRow, renderDigest } from "./email.tsx"; import { type DigestData, type DigestRow, renderDigest } from "./email.tsx";
import { signLink } from "./links.ts"; import { signLink } from "./links.ts";
/** Total daily ceiling: drills only fill what the whole due queue leaves. */
const DAILY_CAP = 6; const DAILY_CAP = 6;
export interface Digest { export interface Digest {
@@ -61,10 +68,12 @@ export async function collectDigest(env: Env, date: string): Promise<DigestData>
const db = env.DB; const db = env.DB;
const rest = weekdayOf(date) === 0; 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 due = rest ? [] : await dueReviews(db, date);
const capped = due.slice(0, DAILY_CAP); const capped = due.slice(0, REVIEW_CAP);
const drills = rest ? [] : await pickDrills(db, date, DAILY_CAP - capped.length); const drills = rest ? [] : await pickDrills(db, date, DAILY_CAP - Math.min(due.length, DAILY_CAP));
const data: DigestData = { const data: DigestData = {
day: prettyDate(date), day: prettyDate(date),
@@ -96,6 +105,7 @@ export async function collectDigest(env: Env, date: string): Promise<DigestData>
name: topic?.name ?? `#${topicIssue}`, name: topic?.name ?? `#${topicIssue}`,
core: core.map((p) => ({ core: core.map((p) => ({
lc: p.lc_number, lc: p.lc_number,
difficulty: p.difficulty,
url: `https://github.com/${env.REPO}/issues/${p.issue}`, url: `https://github.com/${env.REPO}/issues/${p.issue}`,
solved: p.stage !== "new", solved: p.stage !== "new",
})), })),
@@ -164,6 +174,11 @@ export async function sendDigest(
.first(); .first();
if (already && !opts.force) return { sent: false, reason: "already sent today", digest }; 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({ await env.EMAIL.send({
to: env.TO_EMAIL, to: env.TO_EMAIL,
from: { email: env.FROM_EMAIL, name: "SRS" }, from: { email: env.FROM_EMAIL, name: "SRS" },
+63 -19
View File
@@ -35,6 +35,7 @@ import {
} from "@react-email/components"; } from "@react-email/components";
import { render } from "@react-email/render"; import { render } from "@react-email/render";
import type { CSSProperties } from "react"; import type { CSSProperties } from "react";
import { REVIEW_CAP } from "./srs.ts";
// ── palette (GitHub dark) ──────────────────────────────────────── // ── palette (GitHub dark) ────────────────────────────────────────
const CANVAS = "#010409"; const CANVAS = "#010409";
@@ -82,6 +83,14 @@ export interface DigestRow {
failUrl: string; 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 { export interface DigestData {
/** "Tuesday, August 25" */ /** "Tuesday, August 25" */
day: string; day: string;
@@ -89,9 +98,9 @@ export interface DigestData {
progress: string; progress: string;
streak: number; streak: number;
rest: boolean; rest: boolean;
topic?: { name: string; core: { lc: number; url: string; solved: boolean }[] }; topic?: { name: string; core: TopicRow[] };
reviews: DigestRow[]; reviews: DigestRow[];
/** Reviews past the daily cap; they simply stay due. */ /** Reviews past the daily cap; levelled onto the coming days. */
carried: number; carried: number;
drills: DigestRow[]; drills: DigestRow[];
gate?: { week: number; url: string }; 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 (
<table
width="100%"
border={0}
cellPadding={0}
cellSpacing={0}
style={{ borderCollapse: "collapse", width: "100%" }}
>
<thead>
<tr>
<th style={th}>Problem</th>
<th style={th}>Level</th>
<th style={{ ...th, textAlign: "right" }}>Solved</th>
</tr>
</thead>
<tbody>
{rows.map((p) => {
const difficulty = DIFFICULTY[p.difficulty];
return (
<tr key={p.lc}>
<td style={{ ...td, fontWeight: 600 }}>
<Link href={p.url} style={{ color: p.solved ? MUTED : LINK, textDecoration: "none" }}>
LC {p.lc}
</Link>
</td>
<td style={{ ...td, color: difficulty?.color ?? INK }}>{difficulty?.label ?? p.difficulty}</td>
<td style={{ ...td, color: p.solved ? GREEN : MUTED, textAlign: "right" }}>
{p.solved ? "✓" : "—"}
</td>
</tr>
);
})}
</tbody>
</table>
);
}
function Block({ function Block({
title, title,
note, note,
@@ -265,18 +316,8 @@ function DigestEmail({ data }: { data: DigestData }) {
<Text style={{ color: INK, fontSize: "16px", fontWeight: 600, margin: "0 0 8px" }}> <Text style={{ color: INK, fontSize: "16px", fontWeight: 600, margin: "0 0 8px" }}>
{data.topic.name} {data.topic.name}
</Text> </Text>
<Text style={{ ...hint, margin: 0 }}> <Text style={hint}>Work through these from the top a tick means it is already solved.</Text>
Work through these ticked ones you have already solved:{" "} <TopicTable rows={data.topic.core} />
{data.topic.core.map((p, i) => (
<span key={p.lc}>
{i > 0 ? " · " : ""}
<Link href={p.url} style={{ color: p.solved ? MUTED : LINK, textDecoration: "none" }}>
LC {p.lc}
</Link>
{p.solved ? <span style={{ color: GREEN }}> </span> : null}
</span>
))}
</Text>
</Section> </Section>
) : null} ) : null}
@@ -289,8 +330,8 @@ function DigestEmail({ data }: { data: DigestData }) {
/> />
{data.carried > 0 ? ( {data.carried > 0 ? (
<Text style={{ ...hint, margin: "12px 0 0" }}> <Text style={{ ...hint, margin: "12px 0 0" }}>
{data.carried} more {data.carried === 1 ? "is" : "are"} waiting they come back {data.carried} more {data.carried === 1 ? "is" : "are"} waiting spread over the
tomorrow, oldest first. coming days, oldest first, never more than {REVIEW_CAP} a day.
</Text> </Text>
) : null} ) : null}
@@ -373,8 +414,11 @@ function plainDigest(data: DigestData): string {
out.push( out.push(
"", "",
`SOMETHING NEW TODAY — ${data.topic.name}`, `SOMETHING NEW TODAY — ${data.topic.name}`,
"Work through these; the ones marked done you have already solved.", "Work through these from the top; the ones marked done you have already solved.",
...data.topic.core.map((p) => ` LC ${p.lc}${p.solved ? " (done)" : ""}${p.url}`), ...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) { if (data.carried > 0) {
out.push( 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) { if (data.gate) {
+25
View File
@@ -165,6 +165,31 @@ export async function dueReviews(db: D1Database, date: string): Promise<ProblemR
return results; return results;
} }
/** Reviews surfaced per day; the digest levels everything past this forward. */
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
* 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.
*/
export async function levelReviews(db: D1Database, date: string): Promise<number> {
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 — * Blind drills: unsolved optional problems from topics ALREADY LEARNED —
* scheduled in an earlier week (the current week's optional pool is reserved * scheduled in an earlier week (the current week's optional pool is reserved
+1 -1
View File
@@ -15,7 +15,7 @@
{ "binding": "DB", "database_name": "srs", "database_id": "2ee2c34a-c2e2-4ebb-934e-c9e7dc8aa4f1" } { "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. // restricted to the one verified destination inbox.
"send_email": [ "send_email": [
{ "name": "EMAIL", "allowed_destination_addresses": ["prnk28@gmail.com"] } { "name": "EMAIL", "allowed_destination_addresses": ["prnk28@gmail.com"] }