diff --git a/docs/architecture.md b/docs/architecture.md index 7676481..53c17c3 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -157,6 +157,16 @@ No browser restart recovery of these uncommitted forms is promised. JavaScript strings cannot be guaranteed to have been physically erased from process memory. Exports are deliberate readable files with manual-recovery limits. +The preparatory Journal habit-detail selector derives streak scalars from validated +civil completion dates without persisting a streak or retaining source objects. +A checked selected day ends its consecutive run there; an unchecked day may retain +the run ending exactly one day earlier. Creation/archive bounds are inclusive, +and missing or undone dates stop the run. UTC calendar ordinals avoid daylight- +saving arithmetic across supported years 1000..9999. It indexes completion history +once and returns fresh scalar projections. The dependent Journal control slice +owns its unlocked-view lifecycle and visible feedback; this helper alone changes +no application screen, key, envelope or storage format. + Clips stores validated HTTP(S) links and optional actual pasted text. It does not fetch a page as part of local Save link. Duplicate comparison uses a separate canonical URL; the validated original URL is retained. Read/read-later/archive diff --git a/web/src/ui/journalHabitDetails.ts b/web/src/ui/journalHabitDetails.ts new file mode 100644 index 0000000..3cf7abe --- /dev/null +++ b/web/src/ui/journalHabitDetails.ts @@ -0,0 +1,82 @@ +import type { JournalData } from "../collections/journal"; + +export type JournalHabitDetail = Readonly<{ + days: number; + through: string | null; + completed: boolean; +}>; +const DAY_MS = 86400000; + +function civilDay(value: string): number { + if (!/^[1-9]\d{3}-\d{2}-\d{2}$/.test(value)) + throw new RangeError("Choose a valid Journal calendar date."); + const time = Date.UTC( + Number(value.slice(0, 4)), + Number(value.slice(5, 7)) - 1, + Number(value.slice(8, 10)), + ); + if ( + !Number.isFinite(time) || + new Date(time).toISOString().slice(0, 10) !== value + ) + throw new RangeError("Choose a valid Journal calendar date."); + return time / DAY_MS; +} + +/** Derive only view scalars from an already validated, unlocked Journal image. + * No clock, persisted streak, retained source reference or cross-session cache. */ +export function journalHabitDetails( + data: Pick, + selectedDay: string, +): ReadonlyMap { + const selected = civilDay(selectedDay); + const active = new Map< + string, + { created: number; completedDays: Set } + >(); + for (const habit of data.habits) { + if ( + habit.createdOn > selectedDay || + (habit.archivedOn && habit.archivedOn < selectedDay) + ) + continue; + active.set(habit.id, { + created: civilDay(habit.createdOn), + completedDays: new Set(), + }); + } + // One pass over the protected history, rather than one whole-history filter + // per habit. Future and out-of-interval completions cannot extend this run. + for (const completion of data.completions) { + if (!completion.done || completion.date > selectedDay) continue; + const habit = active.get(completion.habitId); + if (!habit) continue; + const day = civilDay(completion.date); + if (day >= habit.created) habit.completedDays.add(day); + } + const details = new Map(); + for (const [id, habit] of active) { + const completed = habit.completedDays.has(selected); + const end = completed ? selected : selected - 1; + let days = 0; + // A missing or explicitly undone day ends the streak immediately. The + // total walk is bounded by the indexed true completion records plus H. + for ( + let cursor = end; + cursor >= habit.created && habit.completedDays.has(cursor); + cursor-- + ) + days++; + details.set( + id, + Object.freeze({ + days, + through: days + ? new Date(end * DAY_MS).toISOString().slice(0, 10) + : null, + completed, + }), + ); + } + return details; +} diff --git a/web/tests/ui/journal-habit-details.test.ts b/web/tests/ui/journal-habit-details.test.ts new file mode 100644 index 0000000..f3a358c --- /dev/null +++ b/web/tests/ui/journal-habit-details.test.ts @@ -0,0 +1,196 @@ +// @vitest-environment node +import { describe, expect, it } from "vitest"; +import type { Habit, JournalData } from "../../src/collections/journal"; +import { journalHabitDetails } from "../../src/ui/journalHabitDetails"; + +const at = "2026-01-01T00:00:00.000Z"; +function habit( + id = "habit", + createdOn = "2026-01-01", + archivedOn: string | null = null, +): Habit { + return { + id, + name: "Private habit", + order: 0, + createdAt: at, + createdOn, + archivedAt: archivedOn ? at : null, + archivedOn, + revision: 1, + }; +} +function completion(date: string, done = true, habitId = "habit") { + return { date, done, habitId, updatedAt: at }; +} +type Source = Pick; +function source(dates: string[], selectedHabit = habit()): Source { + return { + habits: [selectedHabit], + completions: dates.map((day) => completion(day, true, selectedHabit.id)), + }; +} + +describe("Journal habit details", () => { + it("counts a consecutive run ending on the selected completed date", () => { + const data = source(["2026-01-01", "2026-01-02", "2026-01-03"]); + expect(journalHabitDetails(data, "2026-01-03").get("habit")).toEqual({ + days: 3, + through: "2026-01-03", + completed: true, + }); + }); + it("reproduces checked then unchecked streak carry without counting an undone day", () => { + const data = source([ + "2026-01-01", + "2026-01-02", + "2026-01-03", + "2026-01-04", + "2026-01-05", + ]); + data.completions.push(completion("2026-01-06", true)); + expect(journalHabitDetails(data, "2026-01-06").get("habit")?.days).toBe(6); + data.completions[5]!.done = false; + expect(journalHabitDetails(data, "2026-01-06").get("habit")).toEqual({ + days: 5, + through: "2026-01-05", + completed: false, + }); + }); + it("starts the first eligible completion at one and an empty run at zero", () => { + expect(journalHabitDetails(source([]), "2026-01-01").get("habit")).toEqual({ + days: 0, + through: null, + completed: false, + }); + expect( + journalHabitDetails(source(["2026-01-01"]), "2026-01-01").get("habit"), + ).toEqual({ days: 1, through: "2026-01-01", completed: true }); + }); + it("does not search past a missing or explicitly undone day for an older run", () => { + const data = source(["2026-01-01", "2026-01-02", "2026-01-04"]); + expect(journalHabitDetails(data, "2026-01-05").get("habit")?.days).toBe(1); + expect(journalHabitDetails(data, "2026-01-06").get("habit")).toEqual({ + days: 0, + through: null, + completed: false, + }); + data.completions.push(completion("2026-01-03", false)); + expect(journalHabitDetails(data, "2026-01-04").get("habit")?.days).toBe(1); + }); + it("uses selected history rather than future completions or the machine date", () => { + const data = source([ + "2026-01-01", + "2026-01-02", + "2026-01-03", + "2026-01-04", + ]); + expect(journalHabitDetails(data, "2026-01-02").get("habit")).toEqual({ + days: 2, + through: "2026-01-02", + completed: true, + }); + }); + it("respects creation/archive inclusively and does not count out-of-interval records", () => { + const data = source( + ["2026-01-01", "2026-01-02", "2026-01-03", "2026-01-04"], + habit("habit", "2026-01-02", "2026-01-03"), + ); + expect(journalHabitDetails(data, "2026-01-01").size).toBe(0); + expect(journalHabitDetails(data, "2026-01-02").get("habit")?.days).toBe(1); + expect(journalHabitDetails(data, "2026-01-03").get("habit")?.days).toBe(2); + expect(journalHabitDetails(data, "2026-01-04").size).toBe(0); + }); + it("keeps histories by identity across renames, and never crosses habits", () => { + const data = source(["2026-01-01", "2026-01-02"]); + data.habits.push(habit("other")); + data.completions.push(completion("2026-01-03", true, "other")); + data.habits[0]!.name = "Renamed private habit"; + const details = journalHabitDetails(data, "2026-01-03"); + expect(details.get("habit")).toEqual({ + days: 2, + through: "2026-01-02", + completed: false, + }); + expect(details.get("other")).toEqual({ + days: 1, + through: "2026-01-03", + completed: true, + }); + }); + it.each([ + ["2024-02-28", "2024-02-29", "2024-03-01"], + ["2025-12-30", "2025-12-31", "2026-01-01"], + ["2026-03-07", "2026-03-08", "2026-03-09"], + ["2026-10-03", "2026-10-04", "2026-10-05"], + ])( + "uses civil UTC components across leap/year/DST boundaries: %s", + (a, b, c) => { + expect( + journalHabitDetails(source([a, b, c], habit("habit", a)), c).get( + "habit", + )?.days, + ).toBe(3); + }, + ); + it("handles the supported year boundaries without wrapping", () => { + expect( + journalHabitDetails( + source([], habit("habit", "1000-01-01")), + "1000-01-01", + ).get("habit"), + ).toEqual({ days: 0, through: null, completed: false }); + expect( + journalHabitDetails( + source(["9999-12-30", "9999-12-31"], habit("habit", "9999-12-30")), + "9999-12-31", + ).get("habit")?.days, + ).toBe(2); + }); + it.each([ + "2026-02-29", + "2026-04-31", + "0999-12-31", + "10000-01-01", + "2026-1-01", + "not-a-date", + ])("rejects an invalid selected civil date: %s", (day) => { + expect(() => journalHabitDetails(source([]), day)).toThrow(RangeError); + }); + it("returns only fresh scalar projections without mutating the protected input", () => { + const data = source(["2026-01-01"]); + Object.freeze(data.habits[0]); + Object.freeze(data.completions[0]); + Object.freeze(data.habits); + Object.freeze(data.completions); + Object.freeze(data); + const first = journalHabitDetails(data, "2026-01-01"), + detail = first.get("habit")!; + expect(Object.keys(detail).sort()).toEqual([ + "completed", + "days", + "through", + ]); + expect(Object.isFrozen(detail)).toBe(true); + expect(journalHabitDetails(data, "2026-01-01")).not.toBe(first); + }); + it("derives all hundred habits from the maximum legal completion count", () => { + const start = Date.UTC(2020, 0, 1); + const day = (i: number) => + new Date(start + i * 86400000).toISOString().slice(0, 10); + const data: Source = { + habits: Array.from({ length: 100 }, (_, i) => habit(String(i), day(0))), + completions: Array.from({ length: 100000 }, (_, i) => + completion(day(Math.floor(i / 100)), true, String(i % 100)), + ), + }; + const details = journalHabitDetails(data, day(999)); + expect(details.size).toBe(100); + for (const detail of details.values()) + expect(detail).toEqual({ + days: 1000, + through: day(999), + completed: true, + }); + }); +});