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

This commit is contained in:
Prad Nukala committed 2026-08-26 10:34:24 -04:00
1 parent f697a986b8
commit 1b4ad8ca5b
4 files changed
+110 -26

No files matched your search

+21 -6
View File
@@ -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<DigestData>
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<DigestData>
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" },