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

39
src/lib/math.ts Normal file
View File

@@ -0,0 +1,39 @@
// Math tracker (/trackers/math): Evan Chen's "An Infinitely Large Napkin". Shared by the
// server (owner gate, loads) and the page.
/**
* The privacy switch. true: owner-only (404 for everyone else, lock icon in every tracker
* list). false: a public, read-only page for everyone else (notes stay owner-only, edits
* are always owner-only). Flip this one constant; owner.ts and projects.ts read it.
*/
export const MATH_PRIVATE = true;
export const MATH_STATUSES = ['not_started', 'reading', 'exercises', 'done', 'skipped'] as const;
export type MathStatus = (typeof MATH_STATUSES)[number];
export const STATUS_LABEL: Record<MathStatus, string> = {
not_started: 'not started',
reading: 'reading',
exercises: 'exercises',
done: 'done',
skipped: 'skipped'
};
/** The free PDF of the book; chapters link to it with #page=N (PDF page numbers). */
export const NAPKIN_PDF = 'https://venhance.github.io/napkin/Napkin.pdf';
export const pdfPage = (page: number) => `${NAPKIN_PDF}#page=${page}`;
/** "9,10,11,12,18" → "9–12, 18" (numeric keys only; letters are listed as-is). */
export function compactKeys(keys: readonly string[]): string {
const nums = keys.filter((k) => /^\d+$/.test(k)).map(Number);
const other = keys.filter((k) => !/^\d+$/.test(k));
nums.sort((a, b) => a - b);
const out: string[] = [];
for (let i = 0; i < nums.length;) {
let j = i;
while (j + 1 < nums.length && nums[j + 1] === nums[j] + 1) j++;
out.push(j - i >= 2 ? `${nums[i]}–${nums[j]}` : nums.slice(i, j + 1).join(', '));
i = j + 1;
}
return [...out, ...other].join(', ');
}

View File

@@ -1,3 +1,4 @@
import { MATH_PRIVATE } from './math';
// Real projects (tools and experiments). Personal data trackers live in `trackers`.
export const projects = [
{
@@ -386,6 +387,13 @@ const allTrackers: readonly (Tracker & { private: boolean })[] = [
external: false,
private: true
},
{
name: 'math',
subtitle: "working through Evan Chen's Napkin: chapters, exercises and notes",
href: '/trackers/math',
external: false,
private: MATH_PRIVATE
},
{
name: 'health',
subtitle: 'body, workouts, sleep, meds and vaccinations',

View File

@@ -33,3 +33,4 @@ export * from './movies';
export * from './worldtodo';
export * from './osu-goals';
export * from './notes';
export * from './math';

View File

@@ -0,0 +1,112 @@
import { sql } from 'drizzle-orm';
import { check, index, integer, text, uniqueIndex } from 'drizzle-orm/pg-core';
import { mathSchema } from './schemas';
import { id, timestamps, timestamptz } from './helpers';
// Math tracker (/trackers/math): working through Evan Chen's "An Infinitely Large Napkin".
// Parts and chapters (with sections) are seeded from the book's outline in the migration.
// Progress lives on the chapter rows; triggers (in the migration) keep started_at/completed_at
// and log every status / exercise / note / section change to `activity`. Bots and scripts
// update progress with `select math.apply_progress('{"chapter":"3","status":"done"}')` or a
// plain UPDATE (both are logged). Prereqs are hints only, never locks.
// Statuses (kept in sync with MATH_STATUSES in #lib/math; the schema folder must not import
// from outside it): not_started, reading, exercises, done, skipped.
export const mathParts = mathSchema.table(
'parts',
{
id: id(),
/** "1".."22" or "appendix". */
key: text('key').notNull(),
/** Part number; null for the appendix. */
num: integer('num'),
title: text('title').notNull(),
sortOrder: integer('sort_order').notNull()
},
(t) => [uniqueIndex('parts_key_key').on(t.key)]
);
export const mathChapters = mathSchema.table(
'chapters',
{
id: id(),
/** Chapter number as text ("0".."94") or the appendix letter ("A".."E"). */
key: text('key').notNull(),
partId: integer('part_id')
.notNull()
.references(() => mathParts.id, { onDelete: 'cascade', onUpdate: 'cascade' }),
title: text('title').notNull(),
/** PDF pages of the free Napkin.pdf (link with #page=N). */
pageStart: integer('page_start').notNull(),
pageEnd: integer('page_end').notNull(),
/** Problems at the end of the chapter; null when the chapter has none. */
exerciseCount: integer('exercise_count'),
/** Chapter keys this one builds on (approximate, from the book's dependency chart). */
prereqs: text('prereqs')
.array()
.notNull()
.default(sql`'{}'::text[]`),
status: text('status').notNull().default('not_started'),
exercisesDone: integer('exercises_done').notNull().default(0),
notes: text('notes'),
startedAt: timestamptz('started_at'),
completedAt: timestamptz('completed_at'),
sortOrder: integer('sort_order').notNull(),
...timestamps
},
(t) => [
uniqueIndex('chapters_key_key').on(t.key),
index('chapters_part_idx').on(t.partId, t.sortOrder),
check(
'chapters_status_check',
sql`${t.status} IN ('not_started','reading','exercises','done','skipped')`
),
check('chapters_exercises_done_check', sql`${t.exercisesDone} >= 0`)
]
);
export const mathSections = mathSchema.table(
'sections',
{
id: id(),
chapterId: integer('chapter_id')
.notNull()
.references(() => mathChapters.id, { onDelete: 'cascade', onUpdate: 'cascade' }),
/** Section number, e.g. "3.2" (or "D.1"). */
key: text('key').notNull(),
title: text('title').notNull(),
sortOrder: integer('sort_order').notNull(),
/** Optional per-section tick. */
doneAt: timestamptz('done_at')
},
(t) => [uniqueIndex('sections_chapter_key_key').on(t.chapterId, t.key)]
);
export const mathActivity = mathSchema.table(
'activity',
{
id: id(),
chapterId: integer('chapter_id')
.notNull()
.references(() => mathChapters.id, { onDelete: 'cascade', onUpdate: 'cascade' }),
sectionId: integer('section_id').references(() => mathSections.id, {
onDelete: 'set null',
onUpdate: 'cascade'
}),
/** status | exercises | note | section */
kind: text('kind').notNull(),
fromValue: text('from_value'),
toValue: text('to_value'),
/** exercises: done count change. */
delta: integer('delta'),
/** site | bot | sql (from the math.source setting, see math.apply_progress). */
source: text('source').notNull().default('sql'),
createdAt: timestamptz('created_at').notNull().defaultNow()
},
(t) => [
index('activity_created_idx').on(t.createdAt),
index('activity_chapter_idx').on(t.chapterId, t.createdAt),
check('activity_kind_check', sql`${t.kind} IN ('status','exercises','note','section')`)
]
);

View File

@@ -29,3 +29,4 @@ export const aiSchema = pgSchema('ai');
export const moviesSchema = pgSchema('movies');
export const worldtodoSchema = pgSchema('worldtodo');
export const notesSchema = pgSchema('notes');
export const mathSchema = pgSchema('math');

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

View File

@@ -1,5 +1,6 @@
import { describe, expect, it } from 'vitest';
import { isOwner, isOwnerOnlyPath, isOwnerWritePath } from './owner';
import { MATH_PRIVATE } from '#lib/math.js';
const event = (user: Partial<NonNullable<App.Locals['user']>> | null) =>
({ locals: { user, session: null } }) as unknown as { locals: App.Locals };
@@ -37,6 +38,11 @@ describe('owner gate', () => {
expect(isOwnerOnlyPath('/trackers/books/covers/12')).toBe(false);
expect(isOwnerOnlyPath('/trackers/books/12/reader-notes-public')).toBe(false);
});
it('gates the math tracker while MATH_PRIVATE is on; writes are always owner-only', () => {
expect(isOwnerOnlyPath('/trackers/math')).toBe(MATH_PRIVATE);
expect(isOwnerOnlyPath('/trackers/math/__data.json')).toBe(MATH_PRIVATE);
expect(isOwnerWritePath('/trackers/math')).toBe(true);
});
it('only lets the owner write in owner areas', () => {
expect(isOwnerWritePath('/trackers/books/12')).toBe(true);
expect(isOwnerWritePath('/trackers/books')).toBe(true);

View File

@@ -1,4 +1,5 @@
import { error } from '@sveltejs/kit';
import { MATH_PRIVATE } from '#lib/math.js';
// The site has exactly one owner: Stan's account (prod auth.user id tfn6OZDOpiQvMARIH65HQ2XJuaVvmGEe).
// Owner-only data, such as the health tracker, is gated on that account's email, not on
@@ -38,7 +39,9 @@ export const ownerOnlyPrefixes = [
'/api/spotify',
// Worldwide todo list: the list is public, the review queue and its API are mine only.
'/projects/worldwide-todo/queue',
'/projects/worldwide-todo/api/queue'
'/projects/worldwide-todo/api/queue',
// Math tracker (the Napkin): owner-only while MATH_PRIVATE (#lib/math) is on.
...(MATH_PRIVATE ? (['/trackers/math'] as const) : [])
] as const;
/**
@@ -67,7 +70,8 @@ export const ownerWritePrefixes = [
'/trackers/pc',
'/trackers/notes',
'/trackers/ai',
'/trackers/movies'
'/trackers/movies',
'/trackers/math'
] as const;
const normalise = (pathname: string) => pathname.toLowerCase().replace(/\/+$/, '');

View File

@@ -96,6 +96,13 @@ export const trackerIcons: Record<string, TrackerIcon> = {
'M20 3v4M22 5h-4M4 17v2M5 18H3'
]
},
math: {
from: '#818cf8',
to: '#4338ca',
ink: WHITE,
// sigma
paths: ['M18 7V4H6l6 8-6 8h12v-3']
},
notes: {
from: '#fde68a',
to: '#f59e0b',