// The year in review (shared/yearReview.ts): a fixture vault added up into // `Reviews/.md`, byte for byte, and a second run that rewrites only the // block between the markers. import assert from "node:assert/strict"; import { describe, it } from "node:test"; import type { AgendaNoteSource } from "../shared/dayAgenda.ts"; import { parseRoutine, parseRoutineLog } from "../shared/routine.ts"; import type { ReviewGrade } from "../shared/weekReview.ts"; import { bestStreak, daysOfYear, mergeYearReview, REVIEW_END, REVIEW_START, reviewPath, reviewYears, yearNumbers, yearReviewBlock, type YearReviewInput, type YearReviewWords, } from "../shared/yearReview.ts"; const MONTHS = ["January", "February", "March", "April", "May", "June", "July", "August", "September", "October", "November", "December"]; const plural = (n: number, one: string, many: string): string => `${n} ${n === 1 ? one : many}`; const WORDS: YearReviewWords = { lead: "2025 in review", atAGlance: "At a glance", notesBegun: (n, words) => `${plural(n, "note", "notes")} begun; ${plural(words, "word", "words")} written in them and the daily notes`, dailyDays: (n) => `${plural(n, "day", "days")} with a daily note`, sigilTicks: (n) => plural(n, "sigil tick", "sigil ticks"), cardsReviewed: (n) => `${plural(n, "card", "cards")} reviewed on this device`, pagesRead: (pages, sittings) => `${plural(pages, "page", "pages")} read in ${plural(sittings, "sitting", "sittings")}`, estimatedSittings: (sittings, minutes) => `${plural(sittings, "sitting", "sittings")} estimated, ~${minutes} min`, booksFinished: (n) => `${plural(n, "book", "books")} finished`, months: "Months", monthColumns: ["Month", "Notes", "Daily notes", "Sigil ticks", "Cards", "Pages"], sigils: "Sigils", sigilLine: (ticks, streak) => `${plural(ticks, "tick", "ticks")} · best streak ${plural(streak, "day", "days")}`, books: "Books finished", finishedOn: (date) => `finished ${date}`, mostLinked: "Most linked", links: (n) => plural(n, "link", "links"), none: "Nothing this year.", monthName: (m) => MONTHS[m - 1], num: (n) => String(n), }; function note(path: string, day: string | null, words: number, extra: Partial = {}): AgendaNoteSource & { words: number } { const title = path.slice(path.lastIndexOf("/") + 1).replace(/\.md$/, ""); return { path, title, day, publishedDay: null, published: false, excerpt: "", tags: [], captured: 0, voice: 0, words, ...extra }; } function ts(iso: string): number { const [y, m, d] = iso.split("-").map(Number); return new Date(y, m - 1, d, 12).getTime(); } const PLAN = parseRoutine("title: Morning\nitems: water, stretch")!; const LOG = [ "2025-01-01 | done: water, stretch", "2025-01-02 | done: water, stretch", "2025-01-03 | done: water", "2025-01-04 | done: water, stretch", "2025-01-05 | done: water, stretch", "2025-01-06 | done: water, stretch", "2024-12-31 | done: water, stretch", ].join("\n"); const FIXTURE: YearReviewInput = { year: 2025, today: "2026-09-23", notes: [ note("Essays/On reading.md", "2025-01-02", 1200), note("Essays/Old.md", "2024-06-01", 999), note("Daily/2025-01-01.md", "2025-01-01", 80), note("Daily/2025-03-10.md", "2025-03-10", 40), note("Books/Dune.md", "2025-02-01", 300), note("Hub.md", "2024-01-01", 10), ], daily: new Map([ ["2025-01-01", "Daily/2025-01-01.md"], ["2025-03-10", "Daily/2025-03-10.md"], ]), sigils: [{ path: "Sigils/Morning.md", index: 0, plan: PLAN, entries: parseRoutineLog(LOG, PLAN.fields) }], trackers: [ { path: "Books/Dune.md", title: "Dune", kind: "book", finished: "2025-03-04", rating: { value: 4, max: 5 }, sessions: [{ date: "2025-02-10", from: null, to: null, pages: 40, minutes: 50 }, { date: "2025-03-01", from: null, to: null, pages: 60, minutes: 70 }] }, { path: "Games/Elden.md", title: "Elden Ring", kind: "game", finished: "2025-04-01", rating: null, sessions: [] }, { path: "Books/Old.md", title: "Old", kind: "book", finished: "2024-04-01", rating: null, sessions: [] }, ], grades: [ { path: "Decks/Kana.md", grade: "good", ts: ts("2025-01-02") }, { path: "Decks/Kana.md", grade: "again", ts: ts("2025-01-02") }, { path: "Decks/Kana.md", grade: "easy", ts: ts("2024-12-30") }, ] satisfies ReviewGrade[], edges: [ { source: "Essays/On reading.md", target: "Hub.md" }, { source: "Daily/2025-01-01.md", target: "Hub.md" }, { source: "Daily/2025-03-10.md", target: "Books/Dune.md" }, { source: "Essays/Old.md", target: "Books/Dune.md" }, { source: "Essays/On reading.md", target: "Essays/On reading.md" }, ], }; const EXPECTED = `${REVIEW_START} ## At a glance - 2 notes begun; 1620 words written in them and the daily notes - 2 days with a daily note - 11 sigil ticks - 2 cards reviewed on this device - 100 pages read in 2 sittings - 1 book finished ## Months | Month | Notes | Daily notes | Sigil ticks | Cards | Pages | | --- | ---: | ---: | ---: | ---: | ---: | | January | 1 | 1 | 11 | 2 | 0 | | February | 1 | 0 | 0 | 0 | 40 | | March | 0 | 1 | 0 | 0 | 60 | | April | 0 | 0 | 0 | 0 | 0 | | May | 0 | 0 | 0 | 0 | 0 | | June | 0 | 0 | 0 | 0 | 0 | | July | 0 | 0 | 0 | 0 | 0 | | August | 0 | 0 | 0 | 0 | 0 | | September | 0 | 0 | 0 | 0 | 0 | | October | 0 | 0 | 0 | 0 | 0 | | November | 0 | 0 | 0 | 0 | 0 | | December | 0 | 0 | 0 | 0 | 0 | ## Sigils - [[Sigils/Morning|Morning]] — 11 ticks · best streak 3 days ## Books finished - [[Books/Dune|Dune]] — finished 2025-03-04 · ★ 4/5 ## Most linked - [[Hub]] — 2 links - [[Books/Dune|Dune]] — 1 link ${REVIEW_END}`; describe("the year added up", () => { it("renders the fixture vault byte for byte", () => { assert.equal(yearReviewBlock(yearNumbers(FIXTURE), WORDS), EXPECTED); }); it("is deterministic: the same vault, the same bytes", () => { assert.equal(yearReviewBlock(yearNumbers(FIXTURE), WORDS), yearReviewBlock(yearNumbers({ ...FIXTURE, notes: [...FIXTURE.notes].reverse(), edges: [...FIXTURE.edges].reverse() }), WORDS)); }); it("walks every day of a past year, and stops at today inside the current one", () => { assert.equal(daysOfYear(2025, "2026-09-23").length, 365); assert.equal(daysOfYear(2024, "2026-09-23").length, 366); assert.deepEqual(daysOfYear(2026, "2026-01-03"), ["2026-01-01", "2026-01-02", "2026-01-03"]); }); it("reads a streak with the Sigil card's rule: a partial day breaks it", () => { // 1 and 2 complete, 3 partial, 4–6 complete: the best run is three days. assert.equal(bestStreak(FIXTURE.sigils[0], daysOfYear(2025, "2026-09-23"), "2026-09-23"), 3); }); it("offers the years the vault holds anything in, newest first", () => { assert.deepEqual(reviewYears(FIXTURE.notes, FIXTURE.daily, FIXTURE.sigils, "2026-09-23"), [2025, 2024]); }); it("files the note under Reviews/", () => { assert.equal(reviewPath(2025), "Reviews/2025.md"); }); }); describe("writing the note", () => { const block = yearReviewBlock(yearNumbers(FIXTURE), WORDS); it("opens a new note with its lead line", () => { assert.equal(mergeYearReview(null, block, WORDS.lead), `2025 in review\n\n${block}\n`); }); it("rewrites only the block between the markers on a second run", () => { const first = mergeYearReview(null, block, WORDS.lead); const annotated = `${first.replace("2025 in review", "2025 in review\n\nA year of reading.")}\n## My own notes\n\nKept.\n`; const next = yearReviewBlock(yearNumbers({ ...FIXTURE, notes: [...FIXTURE.notes, note("Essays/Late.md", "2025-12-30", 5)] }), WORDS); const second = mergeYearReview(annotated, next, WORDS.lead); assert.ok(second.startsWith("2025 in review\n\nA year of reading.\n\n")); assert.ok(second.endsWith("\n## My own notes\n\nKept.\n")); assert.ok(second.includes("- 3 notes begun")); assert.equal(second.split(REVIEW_START).length, 2, "one block, never two"); }); it("appends the block when the markers were taken out", () => { assert.equal(mergeYearReview("My year.\n", block, WORDS.lead), `My year.\n\n${block}\n`); }); });