mirror of
https://github.com/prdlk/leetcode.git
synced 2026-09-17 07:26:27 +00:00
feat(api): enforce REVIEW_CAP, level overflow reviews, and display difficulty in digest
This commit is contained in:
+21
-6
@@ -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" },
|
||||
|
||||
+63
-19
@@ -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 (
|
||||
<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({
|
||||
title,
|
||||
note,
|
||||
@@ -265,18 +316,8 @@ function DigestEmail({ data }: { data: DigestData }) {
|
||||
<Text style={{ color: INK, fontSize: "16px", fontWeight: 600, margin: "0 0 8px" }}>
|
||||
{data.topic.name}
|
||||
</Text>
|
||||
<Text style={{ ...hint, margin: 0 }}>
|
||||
Work through these — ticked ones you have already solved:{" "}
|
||||
{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>
|
||||
<Text style={hint}>Work through these from the top — a tick means it is already solved.</Text>
|
||||
<TopicTable rows={data.topic.core} />
|
||||
</Section>
|
||||
) : null}
|
||||
|
||||
@@ -289,8 +330,8 @@ function DigestEmail({ data }: { data: DigestData }) {
|
||||
/>
|
||||
{data.carried > 0 ? (
|
||||
<Text style={{ ...hint, margin: "12px 0 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.
|
||||
</Text>
|
||||
) : 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) {
|
||||
|
||||
@@ -165,6 +165,31 @@ export async function dueReviews(db: D1Database, date: string): Promise<ProblemR
|
||||
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 —
|
||||
* scheduled in an earlier week (the current week's optional pool is reserved
|
||||
|
||||
@@ -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"] }
|
||||
|
||||
Reference in New Issue
Block a user