Math tracker (/trackers/math): Evan Chen's Napkin, chapter by chapter
Some checks failed
Deploy production / deploy (push) Failing after 9s

Schema math (parts, chapters, sections, activity), seeded with all 100 chapters
(Ch 0-94, appendix A-E), 647 sections, PDF pages, exercise counts and approximate
prereqs. Triggers stamp started_at/completed_at and log status/exercise/note/section
changes; math.apply_progress(jsonb) applies the JSON format bots send. Private by
default via MATH_PRIVATE in #lib/math (owner-only prefix + lock in every tracker list).
Overall + per-part progress, current chapter, prereq hints, quick status buttons,
exercises stepper, notes, optional section ticks, activity log, dependency chart.
Deploy preflight checks the math schema (4 tables, 100 chapters).
This commit is contained in:
2026-10-26 10:55:16 +00:00
parent 23a43049a0
commit d99245e9fc
16 changed files with 45741 additions and 4 deletions

134
src/lib/server/math.ts Normal file
View File

@@ -0,0 +1,134 @@
// Math tracker (/trackers/math): loads the Napkin outline with progress, and applies progress
// updates through math.apply_progress (the same JSON format bots use, see the migration).
import { client } from '#lib/server/db/index.js';
import { MATH_STATUSES, type MathStatus } from '#lib/math.js';
import * as z from 'zod';
export type MathSection = { id: number; key: string; title: string; done: boolean };
export type MathChapter = {
id: number;
key: string;
partKey: string;
title: string;
pageStart: number;
pageEnd: number;
exerciseCount: number | null;
prereqs: string[];
status: MathStatus;
exercisesDone: number;
notes: string | null;
startedAt: string | null;
completedAt: string | null;
updatedAt: string;
sections: MathSection[];
};
export type MathPart = { key: string; num: number | null; title: string };
export type MathActivity = {
id: number;
chapterKey: string;
chapterTitle: string;
sectionKey: string | null;
kind: 'status' | 'exercises' | 'note' | 'section';
fromValue: string | null;
toValue: string | null;
delta: number | null;
source: string;
at: string;
};
const iso = (d: Date | string | null) => (d ? new Date(d).toISOString() : null);
export async function loadMath(owner: boolean) {
const [parts, chapters, sections, activity] = await Promise.all([
client`SELECT key, num, title FROM math.parts ORDER BY sort_order`,
client`SELECT c.id, c.key, p.key AS part_key, c.title, c.page_start, c.page_end,
c.exercise_count, c.prereqs, c.status, c.exercises_done, c.notes,
c.started_at, c.completed_at, c.updated_at
FROM math.chapters c JOIN math.parts p ON p.id = c.part_id
ORDER BY c.sort_order`,
client`SELECT id, chapter_id, key, title, done_at FROM math.sections
ORDER BY chapter_id, sort_order`,
client`SELECT a.id, c.key AS chapter_key, c.title AS chapter_title, s.key AS section_key,
a.kind, a.from_value, a.to_value, a.delta, a.source, a.created_at
FROM math.activity a
JOIN math.chapters c ON c.id = a.chapter_id
LEFT JOIN math.sections s ON s.id = a.section_id
ORDER BY a.created_at DESC, a.id DESC LIMIT 25`
]);
const byChapter = new Map<number, MathSection[]>();
for (const s of sections) {
const list = byChapter.get(s.chapter_id) ?? [];
list.push({ id: s.id, key: s.key, title: s.title, done: !!s.done_at });
byChapter.set(s.chapter_id, list);
}
return {
parts: parts.map((p) => ({ key: p.key, num: p.num, title: p.title }) as MathPart),
chapters: chapters.map((c): MathChapter => ({
id: c.id,
key: c.key,
partKey: c.part_key,
title: c.title,
pageStart: c.page_start,
pageEnd: c.page_end,
exerciseCount: c.exercise_count,
prereqs: c.prereqs ?? [],
status: c.status,
exercisesDone: c.exercises_done,
notes: owner ? c.notes : null,
startedAt: iso(c.started_at),
completedAt: iso(c.completed_at),
updatedAt: iso(c.updated_at)!,
sections: byChapter.get(c.id) ?? []
})),
activity: activity
.filter((a) => owner || a.kind !== 'note')
.map((a): MathActivity => ({
id: a.id,
chapterKey: a.chapter_key,
chapterTitle: a.chapter_title,
sectionKey: a.section_key,
kind: a.kind,
fromValue: a.kind === 'note' && !owner ? null : a.from_value,
toValue: a.kind === 'note' && !owner ? null : a.to_value,
delta: a.delta,
source: a.source,
at: iso(a.created_at)!
}))
};
}
const progressInput = z
.object({
chapter: z.string().trim().min(1).max(4),
status: z.enum(MATH_STATUSES).optional(),
exercises_add: z.coerce.number().int().min(-50).max(50).optional(),
exercises_done: z.coerce.number().int().min(0).max(500).optional(),
notes: z.string().max(2000).optional(),
section: z.string().trim().max(12).optional(),
section_done: z.enum(['1', '0']).optional()
})
.strict();
/** One owner update from the page (form fields), applied as a math.apply_progress JSON. */
export async function applyFromForm(form: FormData) {
const raw: Record<string, string> = {};
for (const [k, v] of form) if (typeof v === 'string' && v !== '') raw[k] = v;
if (form.has('notes')) raw.notes = String(form.get('notes') ?? '');
const parsed = progressInput.safeParse(raw);
if (!parsed.success) return { ok: false as const, message: 'Invalid update' };
const p = parsed.data;
const json: Record<string, unknown> = { chapter: p.chapter, source: 'site' };
if (p.status) json.status = p.status;
if (p.exercises_add !== undefined) json.exercises_add = p.exercises_add;
if (p.exercises_done !== undefined) json.exercises_done = p.exercises_done;
if (p.notes !== undefined) json.notes = p.notes;
if (p.section) json[p.section_done === '0' ? 'sections_undone' : 'sections_done'] = [p.section];
try {
await client`SELECT math.apply_progress(${JSON.stringify(json)}::jsonb)`;
return { ok: true as const };
} catch (e) {
const msg = e instanceof Error ? e.message : '';
if (msg.includes('unknown chapter')) return { ok: false as const, message: 'Unknown chapter' };
throw e;
}
}