2026-08-25 11:19:19 -04:00
|
|
|
|
/**
|
2026-08-31 10:41:45 -04:00
|
|
|
|
* SRS domain: ET dates, the work week, the interval ladder, deterministic
|
|
|
|
|
|
* sampling, and the one write path for attempts.
|
2026-08-25 11:19:19 -04:00
|
|
|
|
*
|
|
|
|
|
|
* D1 is the single source of truth. The stage names the review a problem must
|
2026-08-31 10:41:45 -04:00
|
|
|
|
* pass NEXT (`+3` = due 3 working days after the last clean solve). Passing
|
|
|
|
|
|
* advances new → +3 → +7 → retired; any failure resets to +3. A problem's
|
|
|
|
|
|
* FIRST-ever log enters the ladder at +3 regardless of result: a pass earns a
|
|
|
|
|
|
* 3-day review, a fail must be re-solved just as soon. Two rungs, 3 and 7 —
|
|
|
|
|
|
* the same numbers as the `work/3` and `work/7` buckets a re-solve is
|
|
|
|
|
|
* scaffolded into, and as the review-issue windows in
|
|
|
|
|
|
* apps/cli/spaced-repetition.ts.
|
|
|
|
|
|
*
|
|
|
|
|
|
* Sunday is off, structurally: every scheduled date comes out of
|
|
|
|
|
|
* workingDay(), which never returns one. Nothing "falls on" the rest day and
|
|
|
|
|
|
* gets swallowed or carried — it is simply never booked there.
|
2026-08-25 11:19:19 -04:00
|
|
|
|
*
|
|
|
|
|
|
* All dates are America/New_York calendar strings (YYYY-MM-DD); arithmetic is
|
|
|
|
|
|
* anchored at noon UTC so DST edges cannot shift a date. Never raw Date math.
|
|
|
|
|
|
*/
|
|
|
|
|
|
import scheduleJson from "../data/schedule.json";
|
|
|
|
|
|
|
|
|
|
|
|
// ── dates (America/New_York) ─────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
|
|
export const CAMPAIGN_START = "2026-08-17";
|
|
|
|
|
|
export const CAMPAIGN_DAYS = 56;
|
|
|
|
|
|
|
|
|
|
|
|
const ET_DATE = new Intl.DateTimeFormat("en-CA", {
|
|
|
|
|
|
timeZone: "America/New_York",
|
|
|
|
|
|
dateStyle: "short",
|
|
|
|
|
|
});
|
|
|
|
|
|
const ET_HOUR = new Intl.DateTimeFormat("en-US", {
|
|
|
|
|
|
timeZone: "America/New_York",
|
|
|
|
|
|
hour: "numeric",
|
|
|
|
|
|
hourCycle: "h23",
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
/** ET calendar date of an instant. */
|
|
|
|
|
|
export function etDate(now: Date): string {
|
|
|
|
|
|
return ET_DATE.format(now);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/** ET hour 0–23 of an instant — the DST-proof cron guard. */
|
|
|
|
|
|
export function etHour(now: Date): number {
|
|
|
|
|
|
return Number(ET_HOUR.format(now));
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/** Noon-UTC anchor: date-only arithmetic immune to DST edges. */
|
|
|
|
|
|
function atNoon(date: string): Date {
|
|
|
|
|
|
return new Date(`${date}T12:00:00Z`);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
export function addDays(date: string, days: number): string {
|
|
|
|
|
|
return new Date(atNoon(date).getTime() + days * 86_400_000).toISOString().slice(0, 10);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
export function daysBetween(from: string, to: string): number {
|
|
|
|
|
|
return Math.round((atNoon(to).getTime() - atNoon(from).getTime()) / 86_400_000);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/** 0 = Sunday … 6 = Saturday. */
|
|
|
|
|
|
export function weekdayOf(date: string): number {
|
|
|
|
|
|
return atNoon(date).getUTCDay();
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-08-31 10:41:45 -04:00
|
|
|
|
// ── the work week ────────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* Sunday, the one day the campaign never schedules: topics run Mon–Fri
|
|
|
|
|
|
* (data/schedule.json), the gate is Saturday, and the review windows below are
|
|
|
|
|
|
* picked so a solve comes back on a working day.
|
|
|
|
|
|
*/
|
|
|
|
|
|
const REST_DAY = 0;
|
|
|
|
|
|
|
|
|
|
|
|
export function isRestDay(date: string): boolean {
|
|
|
|
|
|
return weekdayOf(date) === REST_DAY;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* The `offset`-th working day counting from `from` (0 = `from` itself), with
|
|
|
|
|
|
* Sundays skipped — the ONE place a scheduled date is minted.
|
|
|
|
|
|
*
|
|
|
|
|
|
* Work may slide later, never earlier: pulling a review back to Saturday would
|
|
|
|
|
|
* shorten the very interval it exists to test, so a Sunday landing becomes
|
|
|
|
|
|
* Monday. With the +3/+7 ladder that only ever happens to a Thursday solve's
|
|
|
|
|
|
* 3-day review; +7 lands on the solve's own weekday, which is never a Sunday.
|
|
|
|
|
|
*/
|
|
|
|
|
|
export function workingDay(from: string, offset = 0): string {
|
|
|
|
|
|
let cursor = isRestDay(from) ? addDays(from, 1) : from;
|
|
|
|
|
|
for (let i = 0; i < offset; i++) {
|
|
|
|
|
|
cursor = addDays(cursor, 1);
|
|
|
|
|
|
if (isRestDay(cursor)) cursor = addDays(cursor, 1);
|
|
|
|
|
|
}
|
|
|
|
|
|
return cursor;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-08-25 11:19:19 -04:00
|
|
|
|
export function campaignDay(date: string): number {
|
|
|
|
|
|
return daysBetween(CAMPAIGN_START, date) + 1;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/** 1-based campaign week (Mon–Sun), aligned to CAMPAIGN_START. */
|
|
|
|
|
|
export function campaignWeek(date: string): number {
|
|
|
|
|
|
return Math.floor(daysBetween(CAMPAIGN_START, date) / 7) + 1;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/** Monday of the date's campaign week. */
|
|
|
|
|
|
export function weekMonday(date: string): string {
|
|
|
|
|
|
return addDays(CAMPAIGN_START, (campaignWeek(date) - 1) * 7);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
export function isoWeek(date: string): number {
|
|
|
|
|
|
const d = atNoon(date);
|
|
|
|
|
|
d.setUTCDate(d.getUTCDate() + 4 - (d.getUTCDay() || 7));
|
|
|
|
|
|
const jan1 = new Date(Date.UTC(d.getUTCFullYear(), 0, 1));
|
|
|
|
|
|
return Math.ceil(((d.getTime() - jan1.getTime()) / 86_400_000 + 1) / 7);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
export function prettyDate(date: string): string {
|
|
|
|
|
|
return new Intl.DateTimeFormat("en-US", {
|
|
|
|
|
|
timeZone: "UTC",
|
|
|
|
|
|
weekday: "long",
|
|
|
|
|
|
month: "long",
|
|
|
|
|
|
day: "numeric",
|
|
|
|
|
|
}).format(atNoon(date));
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// ── the schedule (bundled; humans edit apps/api/data/schedule.json) ───
|
|
|
|
|
|
|
|
|
|
|
|
export const SCHEDULE: Record<string, number> = scheduleJson;
|
|
|
|
|
|
|
|
|
|
|
|
/** Week in which a topic was (or will be) taught. */
|
|
|
|
|
|
export function topicWeek(topic: number): number | undefined {
|
|
|
|
|
|
for (const [date, t] of Object.entries(SCHEDULE)) {
|
|
|
|
|
|
if (t === topic) return campaignWeek(date);
|
|
|
|
|
|
}
|
|
|
|
|
|
return undefined;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// ── deterministic sampling ───────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
|
|
/** FNV-1a → mulberry32: seeded PRNG so re-runs pick identical problems. */
|
|
|
|
|
|
export function rng(seed: string): () => number {
|
|
|
|
|
|
let h = 0x811c9dc5;
|
|
|
|
|
|
for (let i = 0; i < seed.length; i++) {
|
|
|
|
|
|
h ^= seed.charCodeAt(i);
|
|
|
|
|
|
h = Math.imul(h, 0x01000193);
|
|
|
|
|
|
}
|
|
|
|
|
|
return () => {
|
|
|
|
|
|
h = Math.imul(h ^ (h >>> 15), h | 1);
|
|
|
|
|
|
h ^= h + Math.imul(h ^ (h >>> 7), h | 61);
|
|
|
|
|
|
return ((h ^ (h >>> 14)) >>> 0) / 4294967296;
|
|
|
|
|
|
};
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/** Up to n elements, Fisher–Yates order driven by the seeded PRNG. */
|
|
|
|
|
|
export function sample<T>(pool: T[], n: number, random: () => number): T[] {
|
|
|
|
|
|
const copy = [...pool];
|
|
|
|
|
|
for (let i = copy.length - 1; i > 0; i--) {
|
|
|
|
|
|
const j = Math.floor(random() * (i + 1));
|
|
|
|
|
|
[copy[i], copy[j]] = [copy[j]!, copy[i]!];
|
|
|
|
|
|
}
|
|
|
|
|
|
return copy.slice(0, n);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// ── rows ─────────────────────────────────────────────────────────
|
|
|
|
|
|
|
2026-08-31 10:41:45 -04:00
|
|
|
|
/**
|
|
|
|
|
|
* Review windows in days. One vocabulary for the whole campaign: these are the
|
|
|
|
|
|
* ladder rungs, the `work/<n>` buckets the picker scaffolds into, and the
|
|
|
|
|
|
* windows the `Spaced Repetition — <date>` issues are built from.
|
|
|
|
|
|
*
|
|
|
|
|
|
* 3 and 7 keep the rest day free. +7 returns a solve to its own weekday, and
|
|
|
|
|
|
* of the five learning weekdays only Thursday's +3 touches a Sunday — which
|
|
|
|
|
|
* workingDay() slides to Monday.
|
|
|
|
|
|
*/
|
|
|
|
|
|
export const WINDOWS = [3, 7] as const;
|
|
|
|
|
|
export type Window = (typeof WINDOWS)[number];
|
|
|
|
|
|
|
|
|
|
|
|
export type Stage = "new" | `+${Window}` | "retired";
|
|
|
|
|
|
/** Chart / stats order, low to high. */
|
|
|
|
|
|
export const STAGES: Stage[] = ["new", "+3", "+7", "retired"];
|
2026-08-25 11:19:19 -04:00
|
|
|
|
export type Result = "pass" | "fail";
|
|
|
|
|
|
export type Kind = "first" | "review" | "drill" | "gate";
|
|
|
|
|
|
|
|
|
|
|
|
export interface ProblemRow {
|
|
|
|
|
|
lc_number: number;
|
|
|
|
|
|
issue: number;
|
|
|
|
|
|
topic_issue: number;
|
|
|
|
|
|
title: string;
|
|
|
|
|
|
difficulty: string;
|
|
|
|
|
|
set_label: string;
|
|
|
|
|
|
stage: Stage;
|
|
|
|
|
|
next_review: string | null;
|
|
|
|
|
|
defer_until: string | null;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-08-31 10:41:45 -04:00
|
|
|
|
export const INTERVAL: Record<string, number> = { "+3": 3, "+7": 7 };
|
|
|
|
|
|
const NEXT_STAGE: Record<string, Stage> = { new: "+3", "+3": "+7", "+7": "retired" };
|
2026-08-25 11:19:19 -04:00
|
|
|
|
|
|
|
|
|
|
// ── queries ──────────────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
|
|
export async function getProblem(db: D1Database, lc: number): Promise<ProblemRow | null> {
|
|
|
|
|
|
return db.prepare("SELECT * FROM problems WHERE lc_number = ?").bind(lc).first<ProblemRow>();
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/** Reviews due on/before `date`, oldest first — the overflow carry order. */
|
|
|
|
|
|
export async function dueReviews(db: D1Database, date: string): Promise<ProblemRow[]> {
|
|
|
|
|
|
const { results } = await db
|
|
|
|
|
|
.prepare(
|
|
|
|
|
|
`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)
|
|
|
|
|
|
.all<ProblemRow>();
|
|
|
|
|
|
return results;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-08-26 10:34:24 -04:00
|
|
|
|
/** 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
|
2026-08-31 10:41:45 -04:00
|
|
|
|
* REVIEW_CAP is pushed to a concrete future WORKING day — 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
|
2026-08-26 10:34:24 -04:00
|
|
|
|
* 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
|
2026-08-31 10:41:45 -04:00
|
|
|
|
* second pass moves nothing. The spill starts tomorrow, or Monday when
|
|
|
|
|
|
* tomorrow is the rest day.
|
2026-08-26 10:34:24 -04:00
|
|
|
|
*/
|
|
|
|
|
|
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 = ?")
|
2026-08-31 10:41:45 -04:00
|
|
|
|
.bind(workingDay(addDays(date, 1), Math.floor(i / REVIEW_CAP)), p.lc_number),
|
2026-08-26 10:34:24 -04:00
|
|
|
|
),
|
|
|
|
|
|
);
|
|
|
|
|
|
return overflow.length;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-08-25 11:19:19 -04:00
|
|
|
|
/**
|
|
|
|
|
|
* Blind drills: unsolved optional problems from topics ALREADY LEARNED —
|
|
|
|
|
|
* scheduled in an earlier week (the current week's optional pool is reserved
|
|
|
|
|
|
* for Saturday's gate) AND showing learning evidence: at least one of the
|
|
|
|
|
|
* topic's core problems has entered the ladder. A skipped learning day never
|
|
|
|
|
|
* feeds drills just because its calendar week lapsed. Never repeated,
|
|
|
|
|
|
* seeded by date. Boosted topics contribute up to 2 extra.
|
|
|
|
|
|
*/
|
|
|
|
|
|
export async function pickDrills(
|
|
|
|
|
|
db: D1Database,
|
|
|
|
|
|
date: string,
|
|
|
|
|
|
budget: number,
|
|
|
|
|
|
): Promise<ProblemRow[]> {
|
|
|
|
|
|
if (budget <= 0) return [];
|
|
|
|
|
|
const week = campaignWeek(date);
|
|
|
|
|
|
const { results } = await db
|
|
|
|
|
|
.prepare(
|
|
|
|
|
|
`SELECT p.*, t.boost AS boost FROM problems p
|
|
|
|
|
|
JOIN topics t ON t.issue = p.topic_issue
|
|
|
|
|
|
WHERE p.set_label = 'optional' AND p.stage = 'new'
|
|
|
|
|
|
AND p.lc_number NOT IN (SELECT lc_number FROM drill_pool_used)
|
|
|
|
|
|
AND NOT EXISTS (SELECT 1 FROM attempts a WHERE a.lc_number = p.lc_number)
|
|
|
|
|
|
AND EXISTS (SELECT 1 FROM problems c
|
|
|
|
|
|
WHERE c.topic_issue = p.topic_issue
|
|
|
|
|
|
AND c.set_label = 'core' AND c.stage != 'new')
|
|
|
|
|
|
ORDER BY p.lc_number`,
|
|
|
|
|
|
)
|
|
|
|
|
|
.all<ProblemRow & { boost: number }>();
|
|
|
|
|
|
const pool = results.filter((p) => {
|
|
|
|
|
|
const w = topicWeek(p.topic_issue);
|
|
|
|
|
|
return w !== undefined && w < week;
|
|
|
|
|
|
});
|
|
|
|
|
|
const boosted = pool.filter((p) => p.boost === 1);
|
|
|
|
|
|
const regular = pool.filter((p) => p.boost !== 1);
|
|
|
|
|
|
const boostPicks = sample(boosted, Math.min(2, budget), rng(`boost-${date}`));
|
|
|
|
|
|
const regularPicks = sample(
|
|
|
|
|
|
regular,
|
|
|
|
|
|
Math.min(2, Math.max(0, budget - boostPicks.length)),
|
|
|
|
|
|
rng(`drill-${date}`),
|
|
|
|
|
|
);
|
|
|
|
|
|
return [...boostPicks, ...regularPicks].slice(0, budget);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/** Consecutive days with ≥1 attempt, ending today or yesterday. */
|
|
|
|
|
|
export async function streak(db: D1Database, today: string): Promise<number> {
|
|
|
|
|
|
const { results } = await db
|
|
|
|
|
|
.prepare("SELECT DISTINCT date FROM attempts ORDER BY date DESC LIMIT 90")
|
|
|
|
|
|
.all<{ date: string }>();
|
|
|
|
|
|
const days = new Set(results.map((r) => r.date));
|
|
|
|
|
|
let cursor = days.has(today) ? today : addDays(today, -1);
|
|
|
|
|
|
let n = 0;
|
|
|
|
|
|
while (days.has(cursor)) {
|
|
|
|
|
|
n++;
|
|
|
|
|
|
cursor = addDays(cursor, -1);
|
|
|
|
|
|
}
|
|
|
|
|
|
return n;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// ── the one write path ───────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
|
|
export interface LogOutcome {
|
|
|
|
|
|
lc: number;
|
|
|
|
|
|
title: string;
|
|
|
|
|
|
kind: Kind;
|
|
|
|
|
|
result: Result;
|
|
|
|
|
|
stage: Stage;
|
|
|
|
|
|
next_review: string | null;
|
|
|
|
|
|
/** First-ever attempt — caller closes the sub-issue on pass. */
|
|
|
|
|
|
first: boolean;
|
|
|
|
|
|
issue: number;
|
|
|
|
|
|
/** Email one-tap replay: nothing changed. */
|
|
|
|
|
|
duplicate: boolean;
|
|
|
|
|
|
error?: string;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* Record an attempt and move the ladder. Ladder semantics live here and only
|
2026-08-25 15:43:21 -04:00
|
|
|
|
* here — email one-taps, webhook /done lines, gate scoring, and solutions
|
|
|
|
|
|
* landing in work/ all converge.
|
2026-08-25 11:19:19 -04:00
|
|
|
|
*
|
|
|
|
|
|
* Email idempotency comes from the partial unique index on
|
|
|
|
|
|
* (lc_number, date, kind) WHERE source='email': a replayed link inserts
|
|
|
|
|
|
* nothing and must not touch the ladder. Webhook corrections (pass then fail
|
2026-08-25 15:43:21 -04:00
|
|
|
|
* on the same day) remain legal — every webhook attempt appends. Commit
|
|
|
|
|
|
* idempotency needs no index: a commit records a FIRST solve only, so the
|
|
|
|
|
|
* stage check below turns every re-push into a no-op.
|
2026-08-25 11:19:19 -04:00
|
|
|
|
*/
|
|
|
|
|
|
export async function logAttempt(
|
|
|
|
|
|
db: D1Database,
|
2026-08-25 15:43:21 -04:00
|
|
|
|
opts: {
|
|
|
|
|
|
lc: number;
|
|
|
|
|
|
date: string;
|
|
|
|
|
|
result: Result;
|
|
|
|
|
|
source: "email" | "webhook" | "commit";
|
|
|
|
|
|
gate?: boolean;
|
|
|
|
|
|
},
|
2026-08-25 11:19:19 -04:00
|
|
|
|
): Promise<LogOutcome> {
|
|
|
|
|
|
const p = await getProblem(db, opts.lc);
|
|
|
|
|
|
const nothing: LogOutcome = {
|
|
|
|
|
|
lc: opts.lc,
|
|
|
|
|
|
title: "",
|
|
|
|
|
|
kind: "review",
|
|
|
|
|
|
result: opts.result,
|
|
|
|
|
|
stage: "new",
|
|
|
|
|
|
next_review: null,
|
|
|
|
|
|
first: false,
|
|
|
|
|
|
issue: 0,
|
|
|
|
|
|
duplicate: false,
|
|
|
|
|
|
};
|
|
|
|
|
|
if (!p) return { ...nothing, error: `LC ${opts.lc} is not in the curriculum` };
|
2026-08-25 15:43:21 -04:00
|
|
|
|
// A commit only ever reports a first solve: pushing the file again — or the
|
|
|
|
|
|
// reconciler re-sending the whole solved set — must never move the ladder,
|
|
|
|
|
|
// and must never wake a retired problem. Reviews come from the digest tap,
|
|
|
|
|
|
// a /done comment, or a gate.
|
|
|
|
|
|
if (opts.source === "commit" && p.stage !== "new") {
|
|
|
|
|
|
return {
|
|
|
|
|
|
...nothing,
|
|
|
|
|
|
title: p.title,
|
|
|
|
|
|
stage: p.stage,
|
|
|
|
|
|
next_review: p.next_review,
|
|
|
|
|
|
issue: p.issue,
|
|
|
|
|
|
duplicate: true,
|
|
|
|
|
|
};
|
|
|
|
|
|
}
|
2026-08-25 11:19:19 -04:00
|
|
|
|
if (p.stage === "retired") {
|
|
|
|
|
|
return { ...nothing, title: p.title, issue: p.issue, error: `LC ${opts.lc} is already retired` };
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const attempted = await db
|
|
|
|
|
|
.prepare("SELECT 1 AS x FROM attempts WHERE lc_number = ? LIMIT 1")
|
|
|
|
|
|
.bind(opts.lc)
|
|
|
|
|
|
.first();
|
|
|
|
|
|
const first = !attempted && p.stage === "new";
|
|
|
|
|
|
const kind: Kind = opts.gate ? "gate" : first ? (p.set_label === "optional" ? "drill" : "first") : "review";
|
|
|
|
|
|
|
|
|
|
|
|
const inserted = await db
|
|
|
|
|
|
.prepare(
|
|
|
|
|
|
`INSERT INTO attempts (lc_number, date, kind, result, source) VALUES (?, ?, ?, ?, ?)
|
|
|
|
|
|
ON CONFLICT DO NOTHING`,
|
|
|
|
|
|
)
|
|
|
|
|
|
.bind(opts.lc, opts.date, kind, opts.result, opts.source)
|
|
|
|
|
|
.run();
|
|
|
|
|
|
if (opts.source === "email" && inserted.meta.changes === 0) {
|
|
|
|
|
|
return { ...nothing, title: p.title, kind, stage: p.stage, next_review: p.next_review, issue: p.issue, duplicate: true };
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-08-31 10:41:45 -04:00
|
|
|
|
// Ladder move. First-ever logs enter at +3 for pass AND fail.
|
2026-08-25 11:19:19 -04:00
|
|
|
|
let stage: Stage;
|
|
|
|
|
|
if (first) {
|
2026-08-31 10:41:45 -04:00
|
|
|
|
stage = "+3";
|
2026-08-25 11:19:19 -04:00
|
|
|
|
} else if (opts.result === "pass") {
|
|
|
|
|
|
stage = NEXT_STAGE[p.stage] ?? "retired";
|
|
|
|
|
|
} else {
|
2026-08-31 10:41:45 -04:00
|
|
|
|
stage = "+3";
|
2026-08-25 11:19:19 -04:00
|
|
|
|
}
|
2026-08-31 10:41:45 -04:00
|
|
|
|
// workingDay(), not addDays(): a Thursday solve's +3 would land on the rest
|
|
|
|
|
|
// day, and it takes Monday instead.
|
|
|
|
|
|
const next = stage === "retired" ? null : workingDay(addDays(opts.date, INTERVAL[stage]!));
|
2026-08-25 11:19:19 -04:00
|
|
|
|
|
|
|
|
|
|
const writes = [
|
|
|
|
|
|
db.prepare(
|
|
|
|
|
|
"UPDATE problems SET stage = ?, next_review = ?, defer_until = NULL WHERE lc_number = ?",
|
|
|
|
|
|
).bind(stage, next, opts.lc),
|
|
|
|
|
|
];
|
|
|
|
|
|
if (first && kind === "drill") {
|
|
|
|
|
|
writes.push(
|
|
|
|
|
|
db.prepare("INSERT OR IGNORE INTO drill_pool_used (lc_number) VALUES (?)").bind(opts.lc),
|
|
|
|
|
|
);
|
|
|
|
|
|
if (opts.result === "fail") {
|
|
|
|
|
|
writes.push(
|
|
|
|
|
|
db.prepare("UPDATE topics SET misses = misses + 1 WHERE issue = ?").bind(p.topic_issue),
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
await db.batch(writes);
|
|
|
|
|
|
|
|
|
|
|
|
return {
|
|
|
|
|
|
lc: opts.lc,
|
|
|
|
|
|
title: p.title,
|
|
|
|
|
|
kind,
|
|
|
|
|
|
result: opts.result,
|
|
|
|
|
|
stage,
|
|
|
|
|
|
next_review: next,
|
|
|
|
|
|
first,
|
|
|
|
|
|
issue: p.issue,
|
|
|
|
|
|
duplicate: false,
|
|
|
|
|
|
};
|
|
|
|
|
|
}
|