// Routines: the plan parser, the log's byte discipline, the statistics, and // the edit that writes a day into a note (shared/routine.ts). Pure, like // tests/tracker.test.ts, and for the same reason: a streak off by one looks // exactly like a streak that is right. import assert from "node:assert/strict"; import { describe, it } from "node:test"; import { applyEdit, dayStatus, draftOf, fieldSpec, editRoutinePlan, formatLogLine, logEditFor, mergeEntry, parseLogLine, parseRoutine, parseRoutineLog, routineFenceBody, routineFenceSpans, routineNoteContent, routinesRootFor, routineStats, scanRoutines, tasksFor, upsertLogLine, weekStart, weekdayOfDate, carriedTasks, } from "../shared/routine.ts"; import { courseBands, courseCursor, courseFinish, courseProgress, courseRemaining, courseStepsOn, projectCourse, } from "../shared/course.ts"; import { ROUTINE_PRESETS } from "../shared/routinePresets.ts"; const PLAN = `title: Weekly exercise kind: exercise slots: morning, evening fields: minutes:number, weight:number:kg, mood:scale:5 target: 6/week monday: morning: 60 min brisk walk evening: Full Body A: leg press 3×8–12 tuesday: 20 min easy walk sunday: morning: 60 min walk notes: | Walk first. `; describe("a routine plan", () => { it("reads title, kind, slots, fields, target and the week", () => { const plan = parseRoutine(PLAN); assert.ok(plan); assert.equal(plan.title, "Weekly exercise"); assert.equal(plan.kindKey, "exercise"); assert.equal(plan.icon, "heart"); assert.deepEqual(plan.slots, ["morning", "evening"]); assert.equal(plan.target, 6); assert.deepEqual(plan.fields.map((f) => [f.key, f.type, f.unit, f.max]), [ ["minutes", "number", null, null], ["weight", "number", "kg", null], ["mood", "scale", null, 5], ]); // A value holding a colon keeps it whole: the first colon splits. assert.equal(plan.week.mon[1].text, "Full Body A: leg press 3×8–12"); // A single-line weekday is one unnamed slot. assert.deepEqual(plan.week.tue, [{ slot: "", text: "20 min easy walk" }]); assert.deepEqual(plan.week.wed, []); assert.equal(plan.notes, "Walk first."); }); it("is null with neither a title nor a plan, and reads Arabic weekdays", () => { assert.equal(parseRoutine("kind: exercise\n"), null); const ar = parseRoutine("عنوان: الصلوات\nالجمعة: خطبة\nيوميًا: الفجر، العصر\n"); assert.ok(ar); assert.deepEqual(ar.items, ["الفجر", "العصر"]); assert.equal(ar.week.fri[0].text, "خطبة"); }); it("names a day's tasks: items first, then the weekday's slots", () => { const plan = parseRoutine("title: x\nitems: stretch\nmonday:\n morning: walk\n")!; assert.deepEqual( tasksFor(plan, "2026-09-14").map((t) => t.key), ["stretch", "morning"], ); // An unnamed weekday line is keyed by the weekday itself. const one = parseRoutine("title: x\ntuesday: walk\n")!; assert.deepEqual(tasksFor(one, "2026-09-15").map((t) => t.key), ["tue"]); }); }); describe("a routine log", () => { const fields = parseRoutine(PLAN)!.fields; it("reads a line by segments: done, skipped, declared fields, then the note", () => { const e = parseLogLine("2026-09-13 | done: morning, evening | minutes: 62 | weight: 84.2 | Felt strong: no pain", fields)!; assert.equal(e.date, "2026-09-13"); assert.deepEqual(e.done, ["morning", "evening"]); assert.deepEqual(e.values, { minutes: "62", weight: "84.2" }); // A segment with a colon whose key is not a field is prose, not a value. assert.equal(e.note, "Felt strong: no pain"); }); it("folds Eastern Arabic digits and ignores lines without a date", () => { assert.equal(parseLogLine("notes go here", fields), null); const e = parseLogLine("٢٠٢٦-٠٩-١٣ | minutes: ٤٥", fields)!; assert.equal(e.date, "2026-09-13"); assert.equal(e.values.minutes, "45"); }); it("writes a line in reading order and softens bars in a note", () => { const line = formatLogLine({ date: "2026-09-13", done: ["morning"], skipped: ["evening"], deferred: [], values: { weight: "84", minutes: "60" }, note: "a | b" }, fields); assert.equal(line, "2026-09-13 | done: morning | skipped: evening | minutes: 60 | weight: 84 | a / b"); }); it("upserts one line and leaves every other byte, CRLF included", () => { const body = "2026-09-11 | done: morning\r\n2026-09-13 | minutes: 30\r\n"; const next = upsertLogLine(body, { date: "2026-09-12", done: ["morning"], skipped: [], deferred: [], values: {}, note: null }, fields); assert.equal(next, "2026-09-11 | done: morning\r\n2026-09-12 | done: morning\r\n2026-09-13 | minutes: 30\r\n"); const replaced = upsertLogLine(next, { date: "2026-09-13", done: [], skipped: [], deferred: [], values: { minutes: "45" }, note: null }, fields); assert.equal(replaced, "2026-09-11 | done: morning\r\n2026-09-12 | done: morning\r\n2026-09-13 | minutes: 45\r\n"); const removed = upsertLogLine(replaced, { date: "2026-09-12", done: [], skipped: [], deferred: [], values: {}, note: null }, fields); assert.equal(removed, "2026-09-11 | done: morning\r\n2026-09-13 | minutes: 45\r\n"); }); it("merges a patch: lists replace, values set or clear, done wins over skipped", () => { const base = parseLogLine("2026-09-13 | done: morning | skipped: evening | minutes: 30", fields); const merged = mergeEntry(base, { date: "2026-09-13", done: ["morning", "evening"], values: { minutes: "", weight: "84" } }); assert.deepEqual(merged.done, ["morning", "evening"]); assert.deepEqual(merged.skipped, []); assert.deepEqual(merged.values, { weight: "84" }); }); it("keeps the last line for a duplicated date", () => { const entries = parseRoutineLog("2026-09-13 | minutes: 1\n2026-09-13 | minutes: 2\n", fields); assert.equal(entries.length, 1); assert.equal(entries[0].values.minutes, "2"); }); }); describe("weeks and days", () => { it("knows the weekday of a date and where the week starts, per language", () => { assert.equal(weekdayOfDate("2026-09-13"), "sun"); assert.equal(weekStart("2026-09-13", "en"), "2026-09-07"); assert.equal(weekStart("2026-09-13", "ar"), "2026-09-12"); }); it("judges a day: complete, partial, missed, rest, none", () => { const plan = parseRoutine(PLAN)!; const today = "2026-09-14"; // a Monday const full = { date: today, done: ["morning", "Evening"], skipped: [], deferred: [], values: {}, note: null }; assert.equal(dayStatus(plan, full, today, today), "complete"); assert.equal(dayStatus(plan, { ...full, done: ["morning"] }, today, today), "partial"); assert.equal(dayStatus(plan, null, today, today), "none"); assert.equal(dayStatus(plan, null, "2026-09-07", today), "missed"); assert.equal(dayStatus(plan, null, "2026-09-09", today), "rest"); // a Wednesday: no plan }); }); describe("statistics", () => { it("counts a streak through rest days and stops at a miss", () => { const plan = parseRoutine("title: x\nmonday: a\ntuesday: b\nthursday: c\nfriday: d\n")!; const entries = parseRoutineLog( ["2026-09-07 | done: mon", "2026-09-08 | done: tue", "2026-09-10 | done: thu", "2026-09-11 | done: fri"].join("\n"), [], ); // Saturday 2026-09-12: yesterday Friday complete, Wednesday a rest day. const stats = routineStats(plan, entries, "2026-09-12"); assert.equal(stats.streak, 4); assert.equal(stats.week.done, 4); assert.equal(stats.week.of, 4); assert.equal(stats.heat.length, 12); assert.equal(stats.heat[11][0].date, "2026-09-07"); // Miss Thursday and the streak is Friday alone. const broken = routineStats(plan, entries.filter((e) => e.date !== "2026-09-10"), "2026-09-12"); assert.equal(broken.streak, 1); }); it("does not call the days before the first entry missed", () => { const plan = parseRoutine("title: x\nitems: walk\n")!; const fresh = routineStats(plan, [], "2026-09-13"); assert.ok(fresh.heat.flat().every((c) => c.status === "none" || c.date === "2026-09-13")); const started = routineStats(plan, parseRoutineLog("2026-09-12 | done: walk\n", []), "2026-09-13"); const cells = new Map(started.heat.flat().map((c) => [c.date, c.status])); assert.equal(cells.get("2026-09-11"), "none"); assert.equal(cells.get("2026-09-12"), "complete"); assert.equal(started.streak, 1); }); it("uses the target as the week's denominator when one is set", () => { const plan = parseRoutine("title: x\ntarget: 3/week\nitems: walk\n")!; const stats = routineStats(plan, [], "2026-09-12"); assert.equal(stats.week.of, 3); }); }); describe("a note carrying a routine", () => { const NOTE = `# Fitness\n\n\`\`\`routine\n${PLAN}\`\`\`\n\nSome prose.\n`; it("pairs a plan with the log that follows it", () => { const withLog = `${NOTE}\n\`\`\`routine-log\n2026-09-13 | done: morning\n\`\`\`\n`; const blocks = scanRoutines(withLog); assert.equal(blocks.length, 1); assert.equal(blocks[0].hasLog, true); assert.equal(blocks[0].entries[0].done[0], "morning"); assert.equal(routineFenceSpans(withLog).length, 2); }); it("writes the first day as a new log fence right under the plan", () => { const edit = logEditFor(NOTE, 0, { date: "2026-09-13", done: ["morning"] }); assert.ok(edit); const next = applyEdit(NOTE, edit); assert.equal( next, `# Fitness\n\n\`\`\`routine\n${PLAN}\`\`\`\n\n\`\`\`routine-log\n2026-09-13 | done: morning\n\`\`\`\n\nSome prose.\n`, ); // The second day lands INSIDE the log, in date order, touching nothing else. const again = applyEdit(next, logEditFor(next, 0, { date: "2026-09-12", values: { minutes: "40" } })!); assert.ok(again.includes("```routine-log\n2026-09-12 | minutes: 40\n2026-09-13 | done: morning\n```")); assert.equal(scanRoutines(again)[0].entries.length, 2); }); it("answers null for a plan the note lacks and for a no-op patch", () => { assert.equal(logEditFor(NOTE, 3, { date: "2026-09-13", done: ["x"] }), null); assert.equal(logEditFor(NOTE, 0, { date: "2026-09-13" }), null); }); it("rewrites only the plan's body", () => { const next = editRoutinePlan(NOTE, 0, "title: New\nitems: a\n"); assert.equal(next, "# Fitness\n\n```routine\ntitle: New\nitems: a\n```\n\nSome prose.\n"); }); }); describe("composing a plan", () => { it("round-trips a plan through the form's draft", () => { const plan = parseRoutine(PLAN)!; const body = routineFenceBody(draftOf(plan)); const back = parseRoutine(body)!; assert.equal(back.title, plan.title); assert.deepEqual(back.week, plan.week); assert.deepEqual(back.fields, plan.fields); assert.equal(back.target, 6); assert.equal(back.notes, "Walk first."); }); it("ships presets that parse, in both languages", () => { for (const p of ROUTINE_PRESETS) { for (const lang of ["en", "ar"] as const) { const plan = parseRoutine(routineFenceBody(p.draft(lang))); assert.ok(plan, `${p.id} ${lang}`); assert.equal(plan.kindKey, p.id); } } }); }); describe("a book in the plan (3.13.0)", () => { it("adds the reading task first and round-trips through the draft", () => { const plan = parseRoutine("title: Mornings\nbook: [[The Muqaddima|the book]]\nitems: coffee\n")!; assert.equal(plan.book, "The Muqaddima"); assert.deepEqual(tasksFor(plan, "2026-09-14").map((t) => [t.key, t.book ?? false]), [["read", true], ["coffee", false]]); assert.ok(routineFenceBody(draftOf(plan)).includes("book: The Muqaddima")); }); }); describe("sigils (3.16.0; orbits in 3.15.0) — the new word, the same model", () => { const OWNER = `title: Daily exercise kind: exercise icon: 🚶 slots: morning, evening target: 6/week monday: morning: 60 min brisk walk evening: Full Body A: leg press 3×8–12, chest press 3×8–12, plank 3×45 sec tuesday: morning: 60 min easy walk `; it("reads a sigil fence, a 3.15 orbit fence and a routine fence as the same thing", () => { const sigil = scanRoutines("```sigil\n" + OWNER + "```\n\n```sigil-log\n2026-09-14 | done: morning\n```\n"); const orbit = scanRoutines("```orbit\n" + OWNER + "```\n\n```orbit-log\n2026-09-14 | done: morning\n```\n"); const legacy = scanRoutines("```routine\n" + OWNER + "```\n\n```routine-log\n2026-09-14 | done: morning\n```\n"); assert.equal(sigil.length, 1); assert.equal(orbit.length, 1); assert.equal(legacy.length, 1); assert.deepEqual(sigil[0].plan, legacy[0].plan); assert.deepEqual(orbit[0].plan, legacy[0].plan); assert.deepEqual(sigil[0].entries, legacy[0].entries); assert.deepEqual(orbit[0].entries, legacy[0].entries); }); it("keeps an icon and a banner, and the owner's plan round-trips untouched", () => { const plan = parseRoutine(OWNER)!; assert.equal(plan.emoji, "🚶"); assert.equal(plan.fields.length, 0); // Opened in the form and saved with nothing changed: the same bytes. assert.equal(routineFenceBody(draftOf(plan)), OWNER); const withBanner = parseRoutine("title: X\nicon: ☪\nbanner: [[Media/walk.jpg]]\nitems: a\n")!; assert.equal(withBanner.emoji, "☪"); assert.equal(withBanner.banner, "Media/walk.jpg"); assert.ok(routineFenceBody(draftOf(withBanner)).includes("banner: Media/walk.jpg")); // A paragraph is not an icon. assert.equal(parseRoutine("title: X\nicon: not an icon at all\nitems: a\n")!.emoji, null); }); it("spells a new log fence the way its plan is spelled", () => { const sigilNote = "```sigil\n" + OWNER + "```\n"; const orbitNote = "```orbit\n" + OWNER + "```\n"; const legacyNote = "```routine\n" + OWNER + "```\n"; const patch = { date: "2026-09-14", done: ["morning"] }; assert.ok(applyEdit(sigilNote, logEditFor(sigilNote, 0, patch)!).includes("```sigil-log\n")); assert.ok(applyEdit(orbitNote, logEditFor(orbitNote, 0, patch)!).includes("```orbit-log\n")); assert.ok(applyEdit(legacyNote, logEditFor(legacyNote, 0, patch)!).includes("```routine-log\n")); }); it("knows a count, and lets a declared notes field claim its segment", () => { const plan = parseRoutine("title: Water\nfields: water:count:glasses, notes:text\nitems: drink\n")!; assert.deepEqual(plan.fields.map(fieldSpec), ["water:count:glasses", "notes:text"]); const entry = parseLogLine("2026-09-14 | done: drink | water: 6 | notes: felt fine", plan.fields)!; assert.equal(entry.values.water, "6"); assert.equal(entry.values.notes, "felt fine"); assert.equal(entry.note, null); // Without such a field the same segment is still the day's note. const bare = parseLogLine("2026-09-14 | notes: felt fine", [])!; assert.equal(bare.note, "felt fine"); }); it("files a new sigil under Sigils, or under the Routines folder a vault already keeps — never under Orbits", () => { assert.equal(routinesRootFor("en", []), "Sigils"); assert.equal(routinesRootFor("ar", []), "سجل"); assert.equal(routinesRootFor("en", ["Routines"]), "Routines"); assert.equal(routinesRootFor("en", ["Routines", "Sigils"]), "Sigils"); // Orbits/ is the decks' folder from 3.16: a 3.15 vault that filed its // sigils there gets a Sigils/ folder for the next one. assert.equal(routinesRootFor("en", ["Orbits"]), "Sigils"); assert.equal(routinesRootFor("ar", ["مدارات"]), "سجل"); assert.ok(routineNoteContent(draftOf(parseRoutine(OWNER)!)).startsWith('---\ntitle: "Daily exercise"\n---\n\n```sigil\n')); }); }); // ── A course (3.19.0) ─────────────────────────────────────────────────────── // // The second mode of the same fence: an ordered list of steps, nothing in the // note dated, every date PROJECTED from the cursor forward. The tests below // hold the four things that can quietly go wrong — the note's bytes, a step's // name, where the cursor is, and what a missed day does to the schedule. describe("a course", () => { const COURSE = `title: Japanese kind: study icon: 🗻 mode: course days: mon, tue, wed, thu, fri, sat capacity: 15 min · sat 45 min · sun 0 items: [[Orbits/Japanese/Hiragana]] · [[Orbits/Japanese/Katakana]] steps: | # Kana - Tofugu Learn Hiragana rows あ か さ た (45 min) - rows な は ま や ら わ ん (45 min) # Genki I — lesson 1 - grammar point 1, then Tae Kim's telling of it (15 min) - grammar point 2 (15 min) - grammar point 3 (15 min) notes: | Left hand, on the sheets. `; const plan = () => parseRoutine(COURSE)!; it("reads the mode, the days, the capacity, the items and the steps", () => { const p = plan(); assert.equal(p.mode, "course"); assert.ok(p.course); assert.deepEqual(p.course.days, ["mon", "tue", "wed", "thu", "fri", "sat"]); assert.equal(p.course.capacity, 15); assert.deepEqual(p.course.capacityByDay, { sat: 45, sun: 0 }); // The middle dot joins a list as a comma does. assert.deepEqual(p.items, ["[[Orbits/Japanese/Hiragana]]", "[[Orbits/Japanese/Katakana]]"]); assert.equal(p.course.steps.length, 5); assert.deepEqual( p.course.steps.map((s) => [s.unit, s.text, s.minutes]), [ ["Kana", "Tofugu Learn Hiragana rows あ か さ た", 45], ["Kana", "rows な は ま や ら わ ん", 45], ["Genki I — lesson 1", "grammar point 1, then Tae Kim's telling of it", 15], ["Genki I — lesson 1", "grammar point 2", 15], ["Genki I — lesson 1", "grammar point 3", 15], ], ); // A weekly sigil is untouched by any of it. const weekly = parseRoutine(PLAN)!; assert.equal(weekly.mode, "week"); assert.equal(weekly.course, null); }); it("round-trips through the form's draft byte for byte, comments and blank lines and all", () => { const withComment = COURSE.replace(" # Kana\n", " # Kana\n # two weeks, no more\n\n"); for (const src of [COURSE, withComment]) { const p = parseRoutine(src)!; assert.equal(routineFenceBody(draftOf(p)), src); } }); it("names a step by its tag, else by a hash that survives an insert and a reorder", () => { const keys = plan().course!.steps.map((s) => s.key); assert.equal(new Set(keys).size, keys.length, "no two steps share a name"); assert.ok(keys.every((k) => /^k[0-9a-z]{6}$/.test(k))); // Insert a step at the top and reorder two others: every other key holds. const moved = parseRoutine( COURSE.replace(" - Tofugu Learn Hiragana", " - a new first step (5 min)\n - Tofugu Learn Hiragana").replace( " - grammar point 2 (15 min)\n - grammar point 3 (15 min)", " - grammar point 3 (15 min)\n - grammar point 2 (15 min)", ), )!; for (const key of keys) assert.ok(moved.course!.steps.some((s) => s.key === key), key); // An explicit tag wins, and is what the reader sees in the log. const tagged = parseRoutine(COURSE.replace(" - grammar point 2 (15 min)", " - grammar point 2 (15 min) [g2]"))!; const g2 = tagged.course!.steps.find((s) => s.text === "grammar point 2")!; assert.equal(g2.key, "g2"); assert.equal(g2.tagged, true); assert.equal(g2.minutes, 15); // A wikilink at the end of a step is not a tag. const link = parseRoutine("title: x\nmode: course\nsteps: |\n - study [[Orbits/Hiragana]]\n")!; assert.equal(link.course!.steps[0].tagged, false); assert.equal(link.course!.steps[0].text, "study [[Orbits/Hiragana]]"); // Two steps that say the same words under the same heading are told apart. const twice = parseRoutine("title: x\nmode: course\nsteps: |\n # U\n - review\n - review\n")!; assert.notEqual(twice.course!.steps[0].key, twice.course!.steps[1].key); }); it("stamps a step's key into the note the first time it is ticked, so the words can change after", () => { const note = "# J\n\n```sigil\n" + COURSE + "```\n"; const first = plan().course!.steps[0]; const edit = logEditFor(note, 0, { date: "2026-09-21", done: [first.key] })!; const next = applyEdit(note, edit); assert.ok(next.includes(`- Tofugu Learn Hiragana rows あ か さ た (45 min) [${first.key}]`), "the step signs its line"); assert.ok(next.includes("```sigil-log\n2026-09-21 | done: " + first.key + "\n```"), "and the log records it"); // Every other byte of the plan is where it was. assert.ok(next.includes("capacity: 15 min · sat 45 min · sun 0\n")); assert.ok(next.includes(" # Genki I — lesson 1\n")); // Now rewrite the step's words: the tick stays on it. const edited = next.replace("Tofugu Learn Hiragana rows あ か さ た", "Tofugu — hiragana, the first four rows"); const blocks = scanRoutines(edited); assert.equal(blocks[0].plan.course!.steps[0].key, first.key); assert.equal(courseProgress(blocks[0].plan, blocks[0].entries).done, 1); // A second tick on an already-stamped step touches the log alone. const again = applyEdit(edited, logEditFor(edited, 0, { date: "2026-09-22", done: [blocks[0].plan.course!.steps[1].key] })!); assert.equal(again.split("```sigil\n")[1].split("```")[0].includes("[k"), true); assert.equal(scanRoutines(again)[0].entries.length, 2); }); it("puts the cursor on the first step neither done nor skipped", () => { const p = plan(); const keys = p.course!.steps.map((s) => s.key); assert.equal(courseCursor(p, [])!.key, keys[0]); const log = parseRoutineLog(`2026-09-19 | done: ${keys[0]}\n2026-09-20 | skipped: ${keys[1]}\n`, p.fields); assert.equal(courseCursor(p, log)!.key, keys[2], "a skipped step moves the cursor on"); assert.deepEqual(courseProgress(p, log), { done: 2, of: 5 }); // A step ticked out of turn is simply gone from what is left. const jumped = parseRoutineLog(`2026-09-19 | done: ${keys[3]}\n`, p.fields); assert.deepEqual(courseRemaining(p, jumped).map((s) => s.key), [keys[0], keys[1], keys[2], keys[4]]); assert.equal(courseCursor(p, []) !== null, true); assert.equal(courseCursor(p, parseRoutineLog(keys.map((k, i) => `2026-09-${20 + i} | done: ${k}`).join("\n"), p.fields)), null); }); it("projects the steps over the allowed days, packing each day by its capacity", () => { const p = plan(); const keys = p.course!.steps.map((s) => s.key); // Saturday 2026-09-19: 45 minutes, so one 45-minute step. Sunday is a // rest day (capacity 0). Monday 15 min: one step. … const days = projectCourse(p, [], "2026-09-19"); assert.deepEqual( days.map((d) => [d.iso, d.steps.map((s) => s.key)]), [ ["2026-09-19", [keys[0]]], ["2026-09-21", [keys[1]]], ["2026-09-22", [keys[2]]], ["2026-09-23", [keys[3]]], ["2026-09-24", [keys[4]]], ], ); assert.equal(courseFinish(p, [], "2026-09-19"), "2026-09-24"); // Saturday's 45 minutes hold three fifteen-minute steps at once. const short = parseRoutine("title: x\nmode: course\ncapacity: 45 min\nsteps: |\n - a (15 min)\n - b (15 min)\n - c (15 min)\n - d (15 min)\n")!; assert.deepEqual(projectCourse(short, [], "2026-09-21").map((d) => d.steps.length), [3, 1]); // Without a capacity a day takes exactly one step. const bare = parseRoutine("title: x\nmode: course\nsteps: |\n - a\n - b\n")!; assert.deepEqual(projectCourse(bare, [], "2026-09-21").map((d) => [d.iso, d.steps.length]), [["2026-09-21", 1], ["2026-09-22", 1]]); }); it("shifts everything when a day is missed, and changes nothing in the note to do it", () => { const p = plan(); const keys = p.course!.steps.map((s) => s.key); // Saturday's step was done; nothing since. On Monday the rest simply // starts on Monday — the note is the same note. const log = parseRoutineLog(`2026-09-19 | done: ${keys[0]}\n`, p.fields); const onMon = projectCourse(p, log, "2026-09-21").map((d) => d.iso); assert.deepEqual(onMon, ["2026-09-21", "2026-09-22", "2026-09-23", "2026-09-24"]); // Two days missed: the same four steps, two days later, nothing lost. const onWed = projectCourse(p, log, "2026-09-23"); assert.deepEqual(onWed.map((d) => d.steps[0].key), [keys[1], keys[2], keys[3], keys[4]]); assert.equal(courseFinish(p, log, "2026-09-23"), "2026-09-26"); }); it("judges a day by its capacity's worth of steps, and calls the rest days rest", () => { const p = plan(); const keys = p.course!.steps.map((s) => s.key); const today = "2026-09-24"; // a Thursday // Sunday asks nothing: capacity 0 and not in `days`. The every-day items // still are asked, so the day is not rest — untick them and it is. const noItems = parseRoutine(COURSE.replace(/items:.*\n/, ""))!; assert.equal(dayStatus(noItems, null, "2026-09-20", today), "rest"); // A Monday with the day's fifteen minutes done is complete… const items = p.items; const full = { date: "2026-09-21", done: [keys[2], ...items], skipped: [], deferred: [], values: {}, note: null }; assert.equal(dayStatus(p, full, "2026-09-21", today), "complete"); // …the same Monday with only the items is partial… assert.equal(dayStatus(p, { ...full, done: [...items] }, "2026-09-21", today), "partial"); // …and a Monday with nothing at all, behind us, is missed. assert.equal(dayStatus(p, null, "2026-09-21", today), "missed"); // Saturday wants forty-five minutes: one fifteen-minute step is not a day. const sat = { date: "2026-09-19", done: [keys[2], ...items], skipped: [], deferred: [], values: {}, note: null }; assert.equal(dayStatus(p, sat, "2026-09-19", today), "partial"); assert.equal(dayStatus(p, { ...sat, done: [keys[0], ...items] }, "2026-09-19", today), "complete"); // A course with no capacity is done on one step. const bare = parseRoutine("title: x\nmode: course\nsteps: |\n - a\n - b\n")!; const k = bare.course!.steps[0].key; assert.equal(dayStatus(bare, { date: "2026-09-21", done: [k], skipped: [], deferred: [], values: {}, note: null }, "2026-09-21", today), "complete"); }); it("shows a day the steps it holds and the ones projected onto it, and reads unit bands", () => { const p = plan(); const keys = p.course!.steps.map((s) => s.key); const log = parseRoutineLog(`2026-09-19 | done: ${keys[0]}\n`, p.fields); // The day it was done still shows it, so the tick can be taken back. assert.deepEqual(courseStepsOn(p, log, "2026-09-19", "2026-09-21").map((s) => s.key), [keys[0]]); assert.deepEqual(courseStepsOn(p, log, "2026-09-21", "2026-09-21").map((s) => s.key), [keys[1]]); const bands = courseBands(projectCourse(p, [], "2026-09-19")); assert.deepEqual(bands.map((b) => [b.unit, b.start, b.end, b.steps]), [ ["Kana", "2026-09-19", "2026-09-21", 2], ["Genki I — lesson 1", "2026-09-22", "2026-09-24", 3], ]); }); it("leaves the weekly fixtures exactly as they were", () => { // The owner's own sigil, through the same code path, unchanged. const weekly = parseRoutine(PLAN)!; assert.equal(weekly.mode, "week"); assert.deepEqual(tasksFor(weekly, "2026-09-14").map((t) => t.key), ["morning", "evening"]); assert.equal(routineFenceBody(draftOf(weekly)), PLAN); const note = `\`\`\`routine\n${PLAN}\`\`\`\n`; assert.equal(applyEdit(note, logEditFor(note, 0, { date: "2026-09-14", done: ["morning"] })!), `\`\`\`routine\n${PLAN}\`\`\`\n\n\`\`\`routine-log\n2026-09-14 | done: morning\n\`\`\`\n`); }); }); describe("pushed forward (3.16.3)", () => { const PLAN = `title: Walks slots: morning, evening monday: morning: walk evening: stretch tuesday: morning: walk evening: stretch`; it("reads and writes deferred tasks and drops one once it is done or skipped", () => { const plan = parseRoutine(PLAN)!; const [e] = parseRoutineLog("2026-09-14 | done: morning | deferred: evening", plan.fields); assert.deepEqual(e.deferred, ["evening"]); assert.equal(formatLogLine(e, plan.fields), "2026-09-14 | done: morning | deferred: evening"); const done = mergeEntry(e, { date: e.date, done: [...e.done, "evening"] }); assert.deepEqual(done.deferred, [], "ticking the owed task takes it off the pushed list"); assert.equal(dayStatus(plan, done, "2026-09-14", "2026-09-16"), "complete", "the day it was owed to gets the credit"); const gaveUp = mergeEntry(e, { date: e.date, skipped: ["evening"] }); assert.deepEqual(gaveUp.deferred, []); }); it("carries a pushed task onto the following days until it is answered", () => { const plan = parseRoutine(PLAN)!; const entries = parseRoutineLog("2026-09-14 | done: morning | deferred: evening\n2026-09-15 | done: morning, evening", plan.fields); const carried = carriedTasks(plan, entries, "2026-09-16"); assert.equal(carried.length, 1); assert.equal(carried[0].from, "2026-09-14"); assert.equal(carried[0].task.key, "evening"); assert.equal(carried[0].task.text, "stretch"); assert.equal(carriedTasks(plan, entries, "2026-09-14").length, 0, "not on the day itself"); assert.equal(carriedTasks(plan, entries, "2026-09-30").length, 0, "a week is the horizon"); }); });