mirror of
https://github.com/prdlk/leetcode.git
synced 2026-09-16 23:16:26 +00:00
feat(api): add dueToday endpoint, shared DUE_WHERE constant, and commit rung handling
This commit is contained in:
+35
-14
@@ -19,7 +19,9 @@ import { type Mirror, projectMirror } from "./mirror.ts";
|
|||||||
import {
|
import {
|
||||||
CAMPAIGN_START,
|
CAMPAIGN_START,
|
||||||
type LogOutcome,
|
type LogOutcome,
|
||||||
|
type Stage,
|
||||||
daysBetween,
|
daysBetween,
|
||||||
|
dueToday,
|
||||||
etDate,
|
etDate,
|
||||||
etHour,
|
etHour,
|
||||||
getProblem,
|
getProblem,
|
||||||
@@ -202,43 +204,56 @@ async function adminAuthorized(request: Request, env: Env): Promise<boolean> {
|
|||||||
/**
|
/**
|
||||||
* Solutions landing in `work/` are the third way a problem gets solved, after
|
* Solutions landing in `work/` are the third way a problem gets solved, after
|
||||||
* the digest tap and a /done comment. `close-solved` posts its whole
|
* the digest tap and a /done comment. `close-solved` posts its whole
|
||||||
* implemented set here on every push to main: logAttempt ignores anything
|
* implemented set here on every push to main, each entry tagged with the
|
||||||
* already on the ladder, so this is a total recompute like the reconcilers
|
* BUCKET the file sits in — 1 for the first solve, 3 and 7 for the blind
|
||||||
* that call it — a re-run changes nothing.
|
* re-solves the picker scaffolds. The bucket names the ladder rung the file
|
||||||
|
* settles, and logAttempt writes only when the problem is standing on that
|
||||||
|
* rung, so this is a total recompute like the reconcilers that call it: a
|
||||||
|
* re-run changes nothing, and a pushed re-solve advances +3 → +7 instead of
|
||||||
|
* being invisible.
|
||||||
*
|
*
|
||||||
* Closing the sub-issue stays with close-solved: it is the side that knows
|
* Closing the sub-issue stays with close-solved: it is the side that knows
|
||||||
* which files at which commit, so this path mirrors Project fields only.
|
* which files at which commit, so this path mirrors Project fields only.
|
||||||
* Charts read D1 live, so a push moves them within the 5-minute cache.
|
* Charts read D1 live, so a push moves them within the 5-minute cache.
|
||||||
*/
|
*/
|
||||||
|
const RUNG_OF_BUCKET: Record<number, Stage> = { 1: "new", 3: "+3", 7: "+7" };
|
||||||
|
|
||||||
async function handleSolved(request: Request, env: Env, date: string): Promise<Response> {
|
async function handleSolved(request: Request, env: Env, date: string): Promise<Response> {
|
||||||
const body: unknown = await request.json().catch(() => null);
|
const body: unknown = await request.json().catch(() => null);
|
||||||
const raw: unknown = body && typeof body === "object" && "lc" in body ? body.lc : null;
|
const raw: unknown = body && typeof body === "object" && "solved" in body ? body.solved : null;
|
||||||
if (!Array.isArray(raw)) return new Response('expected {"lc": [<number>, ...]}', { status: 400 });
|
const shape = 'expected {"solved": [{"lc": <number>, "bucket": 1|3|7}, ...]}';
|
||||||
const lcs: number[] = [];
|
if (!Array.isArray(raw)) return new Response(shape, { status: 400 });
|
||||||
|
const entries: { lc: number; rung: Stage; bucket: number }[] = [];
|
||||||
for (const item of raw) {
|
for (const item of raw) {
|
||||||
const lc: unknown = item;
|
if (!item || typeof item !== "object") return new Response(shape, { status: 400 });
|
||||||
|
const lc: unknown = "lc" in item ? item.lc : null;
|
||||||
|
const bucket: unknown = "bucket" in item ? item.bucket : null;
|
||||||
if (typeof lc !== "number" || !Number.isFinite(lc)) {
|
if (typeof lc !== "number" || !Number.isFinite(lc)) {
|
||||||
return new Response(`not an LC number: ${JSON.stringify(lc)}`, { status: 400 });
|
return new Response(`not an LC number: ${JSON.stringify(lc)}`, { status: 400 });
|
||||||
}
|
}
|
||||||
lcs.push(lc);
|
const rung = typeof bucket === "number" ? RUNG_OF_BUCKET[bucket] : undefined;
|
||||||
|
if (rung === undefined) return new Response(`not a work/ bucket: ${JSON.stringify(bucket)}`, { status: 400 });
|
||||||
|
entries.push({ lc, rung, bucket });
|
||||||
}
|
}
|
||||||
const dry = new URL(request.url).searchParams.get("dry") === "1";
|
const dry = new URL(request.url).searchParams.get("dry") === "1";
|
||||||
|
|
||||||
const logged: string[] = [];
|
const logged: string[] = [];
|
||||||
const skipped: string[] = [];
|
const skipped: string[] = [];
|
||||||
let mirror: Mirror | undefined;
|
let mirror: Mirror | undefined;
|
||||||
for (const lc of lcs) {
|
for (const { lc, rung, bucket } of entries) {
|
||||||
if (dry) {
|
if (dry) {
|
||||||
const p = await getProblem(env.DB, lc);
|
const p = await getProblem(env.DB, lc);
|
||||||
if (!p) skipped.push(`LC ${lc} is not in the curriculum`);
|
if (!p) skipped.push(`LC ${lc} is not in the curriculum`);
|
||||||
else if (p.stage !== "new") skipped.push(`LC ${lc}: already at stage ${p.stage}`);
|
else if (p.stage !== rung) skipped.push(`LC ${lc}: work/${bucket} settles ${rung}, stage is ${p.stage}`);
|
||||||
else logged.push(`LC ${lc}: would enter the ladder at +3`);
|
else if (rung === "new") logged.push(`LC ${lc}: would enter the ladder at +3`);
|
||||||
|
else logged.push(`LC ${lc}: would pass ${rung} and move on`);
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
const outcome = await logAttempt(env.DB, { lc, date, result: "pass", source: "commit" });
|
const outcome = await logAttempt(env.DB, { lc, date, result: "pass", source: "commit", rung });
|
||||||
if (outcome.error) skipped.push(outcome.error);
|
if (outcome.error) skipped.push(outcome.error);
|
||||||
else if (outcome.duplicate) skipped.push(`LC ${lc}: already at stage ${outcome.stage}`);
|
else if (outcome.duplicate) {
|
||||||
else {
|
skipped.push(`LC ${lc}: work/${bucket} settles ${rung}, stage is ${outcome.stage}`);
|
||||||
|
} else {
|
||||||
logged.push(outcomeLine(outcome));
|
logged.push(outcomeLine(outcome));
|
||||||
// One mirror for the batch: field IDs resolve once, not per problem.
|
// One mirror for the batch: field IDs resolve once, not per problem.
|
||||||
mirror ??= projectMirror(github(env.GH_PAT, env.REPO), env.REPO.split("/")[0]!);
|
mirror ??= projectMirror(github(env.GH_PAT, env.REPO), env.REPO.split("/")[0]!);
|
||||||
@@ -271,6 +286,12 @@ async function handleAdmin(request: Request, env: Env, path: string): Promise<Re
|
|||||||
if (path === "/admin/review") {
|
if (path === "/admin/review") {
|
||||||
return Response.json(await createReviewIssue(env, gh(), date));
|
return Response.json(await createReviewIssue(env, gh(), date));
|
||||||
}
|
}
|
||||||
|
// What is due on `date`, straight out of the ladder — the ONE answer to
|
||||||
|
// "which problems are open today", so the daily review issue and the digest
|
||||||
|
// cannot disagree. Read-only; the caller decides what to render.
|
||||||
|
if (path === "/admin/due") {
|
||||||
|
return Response.json(await dueToday(env.DB, date));
|
||||||
|
}
|
||||||
return new Response("not found", { status: 404 });
|
return new Response("not found", { status: 404 });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+87
-14
@@ -203,20 +203,85 @@ export async function getProblem(db: D1Database, lc: number): Promise<ProblemRow
|
|||||||
return db.prepare("SELECT * FROM problems WHERE lc_number = ?").bind(lc).first<ProblemRow>();
|
return db.prepare("SELECT * FROM problems WHERE lc_number = ?").bind(lc).first<ProblemRow>();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* What "due" means, in one place: not retired, scheduled on/before ?1, and
|
||||||
|
* past its deferral if it has one. Every caller that asks "what is open on
|
||||||
|
* this day" — the digest, the review issue, the docs queue chart — shares
|
||||||
|
* this clause so they cannot answer differently.
|
||||||
|
*/
|
||||||
|
export const DUE_WHERE = `stage != 'retired' AND next_review IS NOT NULL AND next_review <= ?1
|
||||||
|
AND (defer_until IS NULL OR defer_until <= ?1)`;
|
||||||
|
|
||||||
/** Reviews due on/before `date`, oldest first — the overflow carry order. */
|
/** Reviews due on/before `date`, oldest first — the overflow carry order. */
|
||||||
export async function dueReviews(db: D1Database, date: string): Promise<ProblemRow[]> {
|
export async function dueReviews(db: D1Database, date: string): Promise<ProblemRow[]> {
|
||||||
const { results } = await db
|
const { results } = await db
|
||||||
.prepare(
|
.prepare(`SELECT * FROM problems WHERE ${DUE_WHERE} ORDER BY next_review, lc_number`)
|
||||||
`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)
|
.bind(date)
|
||||||
.all<ProblemRow>();
|
.all<ProblemRow>();
|
||||||
return results;
|
return results;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** One problem the ladder wants re-solved today. */
|
||||||
|
export interface DueRow {
|
||||||
|
lc: number;
|
||||||
|
issue: number;
|
||||||
|
title: string;
|
||||||
|
difficulty: string;
|
||||||
|
/** The rung being settled: also the `work/<n>` bucket the re-solve goes in. */
|
||||||
|
stage: Stage;
|
||||||
|
bucket: number;
|
||||||
|
/** Scheduled day; earlier than `date` when a day was missed. */
|
||||||
|
due: string;
|
||||||
|
/** Days late (0 = due today), so a renderer can say so. */
|
||||||
|
late: number;
|
||||||
|
/** Last logged attempt, and how long ago — the "last seen" line. */
|
||||||
|
last_seen: string | null;
|
||||||
|
days_since: number | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Everything the ladder wants re-solved on `date`, oldest first.
|
||||||
|
*
|
||||||
|
* The read side of "which problems are open today": the same rows the digest
|
||||||
|
* mails, shaped for whoever renders them (the daily Spaced Repetition issue).
|
||||||
|
* Nothing here writes, so it is safe to call repeatedly, and there is no
|
||||||
|
* second schedule to drift — D1's ladder is the schedule.
|
||||||
|
*/
|
||||||
|
export async function dueToday(db: D1Database, date: string): Promise<{ date: string; due: DueRow[] }> {
|
||||||
|
const { results } = await db
|
||||||
|
.prepare(
|
||||||
|
`SELECT lc_number, issue, title, difficulty, stage, next_review,
|
||||||
|
(SELECT MAX(a.date) FROM attempts a WHERE a.lc_number = problems.lc_number) AS last_seen
|
||||||
|
FROM problems WHERE ${DUE_WHERE} ORDER BY next_review, lc_number`,
|
||||||
|
)
|
||||||
|
.bind(date)
|
||||||
|
.all<{
|
||||||
|
lc_number: number;
|
||||||
|
issue: number;
|
||||||
|
title: string;
|
||||||
|
difficulty: string;
|
||||||
|
stage: Stage;
|
||||||
|
next_review: string;
|
||||||
|
last_seen: string | null;
|
||||||
|
}>();
|
||||||
|
return {
|
||||||
|
date,
|
||||||
|
due: results.map((r) => ({
|
||||||
|
lc: r.lc_number,
|
||||||
|
issue: r.issue,
|
||||||
|
title: r.title,
|
||||||
|
difficulty: r.difficulty,
|
||||||
|
stage: r.stage,
|
||||||
|
// "+3" -> 3: the rung's number IS its work/ bucket.
|
||||||
|
bucket: Number(r.stage.slice(1)),
|
||||||
|
due: r.next_review,
|
||||||
|
late: daysBetween(r.next_review, date),
|
||||||
|
last_seen: r.last_seen,
|
||||||
|
days_since: r.last_seen === null ? null : daysBetween(r.last_seen, date),
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
/** Reviews surfaced per day; the digest levels everything past this forward. */
|
/** Reviews surfaced per day; the digest levels everything past this forward. */
|
||||||
export const REVIEW_CAP = 3;
|
export const REVIEW_CAP = 3;
|
||||||
|
|
||||||
@@ -326,9 +391,14 @@ export interface LogOutcome {
|
|||||||
* Email idempotency comes from the partial unique index on
|
* Email idempotency comes from the partial unique index on
|
||||||
* (lc_number, date, kind) WHERE source='email': a replayed link inserts
|
* (lc_number, date, kind) WHERE source='email': a replayed link inserts
|
||||||
* nothing and must not touch the ladder. Webhook corrections (pass then fail
|
* nothing and must not touch the ladder. Webhook corrections (pass then fail
|
||||||
* on the same day) remain legal — every webhook attempt appends. Commit
|
* on the same day) remain legal — every webhook attempt appends.
|
||||||
* idempotency needs no index: a commit records a FIRST solve only, so the
|
*
|
||||||
* stage check below turns every re-push into a no-op.
|
* Commit idempotency needs no index either. A commit names the rung the file
|
||||||
|
* it landed in settles — `work/1` settles `new`, `work/3` settles `+3`,
|
||||||
|
* `work/7` settles `+7` — and the stage check below writes only when the
|
||||||
|
* ladder is actually standing on that rung. So re-pushing a solution, or the
|
||||||
|
* reconciler re-sending its whole implemented set on every push, moves
|
||||||
|
* nothing: the rung it names has already been left behind.
|
||||||
*/
|
*/
|
||||||
export async function logAttempt(
|
export async function logAttempt(
|
||||||
db: D1Database,
|
db: D1Database,
|
||||||
@@ -337,6 +407,8 @@ export async function logAttempt(
|
|||||||
date: string;
|
date: string;
|
||||||
result: Result;
|
result: Result;
|
||||||
source: "email" | "webhook" | "commit";
|
source: "email" | "webhook" | "commit";
|
||||||
|
/** Commit only: the stage this file settles. Default `new` = first solve. */
|
||||||
|
rung?: Stage;
|
||||||
gate?: boolean;
|
gate?: boolean;
|
||||||
},
|
},
|
||||||
): Promise<LogOutcome> {
|
): Promise<LogOutcome> {
|
||||||
@@ -353,11 +425,12 @@ export async function logAttempt(
|
|||||||
duplicate: false,
|
duplicate: false,
|
||||||
};
|
};
|
||||||
if (!p) return { ...nothing, error: `LC ${opts.lc} is not in the curriculum` };
|
if (!p) return { ...nothing, error: `LC ${opts.lc} is not in the curriculum` };
|
||||||
// A commit only ever reports a first solve: pushing the file again — or the
|
// A commit reports a file, not an event: it may only settle the rung that
|
||||||
// reconciler re-sending the whole solved set — must never move the ladder,
|
// file's bucket IS. Any other stage means the push is old news (the ladder
|
||||||
// and must never wake a retired problem. Reviews come from the digest tap,
|
// already moved past it) or premature — no write either way, and a retired
|
||||||
// a /done comment, or a gate.
|
// problem can never be woken. Fresh reviews otherwise come from the digest
|
||||||
if (opts.source === "commit" && p.stage !== "new") {
|
// tap, a /done comment, or a gate.
|
||||||
|
if (opts.source === "commit" && p.stage !== (opts.rung ?? "new")) {
|
||||||
return {
|
return {
|
||||||
...nothing,
|
...nothing,
|
||||||
title: p.title,
|
title: p.title,
|
||||||
|
|||||||
@@ -7,6 +7,7 @@
|
|||||||
import {
|
import {
|
||||||
CAMPAIGN_DAYS,
|
CAMPAIGN_DAYS,
|
||||||
CAMPAIGN_START,
|
CAMPAIGN_START,
|
||||||
|
DUE_WHERE,
|
||||||
addDays,
|
addDays,
|
||||||
campaignDay,
|
campaignDay,
|
||||||
campaignWeek,
|
campaignWeek,
|
||||||
@@ -78,11 +79,7 @@ export async function buildStats(db: D1Database, today: string): Promise<object>
|
|||||||
for (let i = 0; i < 14; i++) {
|
for (let i = 0; i < 14; i++) {
|
||||||
const date = addDays(today, i);
|
const date = addDays(today, i);
|
||||||
const row = await db
|
const row = await db
|
||||||
.prepare(
|
.prepare(`SELECT COUNT(*) AS n FROM problems WHERE ${DUE_WHERE}`)
|
||||||
`SELECT COUNT(*) AS n FROM problems
|
|
||||||
WHERE stage != 'retired' AND next_review IS NOT NULL AND next_review <= ?1
|
|
||||||
AND (defer_until IS NULL OR defer_until <= ?1)`,
|
|
||||||
)
|
|
||||||
.bind(date)
|
.bind(date)
|
||||||
.first<{ n: number }>();
|
.first<{ n: number }>();
|
||||||
queue.push({ date, due: row?.n ?? 0 });
|
queue.push({ date, due: row?.n ?? 0 });
|
||||||
|
|||||||
Reference in New Issue
Block a user