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:
39
src/lib/math.ts
Normal file
39
src/lib/math.ts
Normal 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(', ');
|
||||
}
|
||||
@@ -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',
|
||||
|
||||
@@ -33,3 +33,4 @@ export * from './movies';
|
||||
export * from './worldtodo';
|
||||
export * from './osu-goals';
|
||||
export * from './notes';
|
||||
export * from './math';
|
||||
|
||||
112
src/lib/server/db/schema/math.ts
Normal file
112
src/lib/server/db/schema/math.ts
Normal 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')`)
|
||||
]
|
||||
);
|
||||
@@ -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
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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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(/\/+$/, '');
|
||||
|
||||
@@ -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',
|
||||
|
||||
Reference in New Issue
Block a user