Math tracker (/trackers/math): Evan Chen's Napkin, chapter by chapter
Some checks failed
Deploy production / deploy (push) Failing after 9s
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:
134
src/lib/server/math.ts
Normal file
134
src/lib/server/math.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user