Files
website/src/lib/server/math.ts
Stan Runge d99245e9fc
Some checks failed
Deploy production / deploy (push) Failing after 9s
Math tracker (/trackers/math): Evan Chen's Napkin, chapter by chapter
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).
2026-10-26 10:55:16 +00:00

135 lines
4.9 KiB
TypeScript

// 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;
}
}