Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions docs/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
82 changes: 82 additions & 0 deletions web/src/ui/journalHabitDetails.ts
Original file line number Diff line number Diff line change
@@ -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<JournalData, "habits" | "completions">,
selectedDay: string,
): ReadonlyMap<string, JournalHabitDetail> {
const selected = civilDay(selectedDay);
const active = new Map<
string,
{ created: number; completedDays: Set<number> }
>();
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<string, JournalHabitDetail>();
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;
}
196 changes: 196 additions & 0 deletions web/tests/ui/journal-habit-details.test.ts
Original file line number Diff line number Diff line change
@@ -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<JournalData, "habits" | "completions">;
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,
});
});
});
Loading