From b5382dcca6afc13d7b15c3ef8bcc4821b8fb2150 Mon Sep 17 00:00:00 2001 From: Luan Nguyen Date: Wed, 11 Nov 2026 23:45:46 +1100 Subject: [PATCH] feat(journal): add habit streak and calendar feedback --- docs/architecture.md | 23 ++-- web/src/styles/journal.css | 155 +++++++++++++++++++++++++-- web/src/ui/JournalHabitFeedback.tsx | 111 ++++++++++++++++++++ web/src/ui/JournalView.tsx | 110 ++++++++++++++------ web/tests/ui/journal.test.tsx | 156 ++++++++++++++++++++++++++++ 5 files changed, 505 insertions(+), 50 deletions(-) create mode 100644 web/src/ui/JournalHabitFeedback.tsx diff --git a/docs/architecture.md b/docs/architecture.md index 53c17c3..b13009a 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -157,15 +157,20 @@ 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. +Habit streak details are derived inside the unlocked Journal view from real civil +completion dates. A checked selected day ends its run there; an unchecked day may +retain the consecutive run ending exactly one day earlier. Creation and archival +bounds apply, and a missing or undone date breaks the run. UTC calendar ordinals +avoid daylight-saving arithmetic; no streak is persisted or shared with Study's +day-start preference. Descriptions identify the actual through-date. Count feedback +retains only outgoing numeric scalars within the unlocked subtree and disappears +synchronously when that private owner closes. + +The calendar shades absolute completed counts: zero, one or two, three, and four +or more. It retains exact historical completed/eligible totals. The last legend +label becomes `4+` only when a displayed actual count exceeds four. Feedback uses +the vault's protected draft, including a retained draft after a failed write; +durable Saved status still requires the existing encrypted commit acknowledgement. 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 diff --git a/web/src/styles/journal.css b/web/src/styles/journal.css index 41ae52d..16dbe0f 100644 --- a/web/src/styles/journal.css +++ b/web/src/styles/journal.css @@ -148,21 +148,162 @@ .journal-habits > button[aria-pressed="true"] { color: var(--t1); } +.journal-view .journal-habits > button:active { + transform: none; +} +.journal-habits > button:focus-visible { + outline: 2px solid var(--t1); + outline-offset: 2px; +} .journal-check { + position: relative; display: inline-grid; place-items: center; - flex: 0 0 14px; - width: 14px; - height: 14px; - border: 1px solid var(--line2); - border-radius: 4px; - font-size: 10px; - line-height: 1; + flex: 0 0 15px; + width: 15px; + height: 15px; + border: 1.5px solid var(--line2); + border-radius: 5px; + transform: scale(1); + transition: + background-color 150ms cubic-bezier(0.4, 0, 0.2, 1), + border-color 150ms cubic-bezier(0.4, 0, 0.2, 1), + transform 150ms cubic-bezier(0.2, 0.8, 0.2, 1); +} +.journal-habits > button:not(:disabled):active .journal-check { + transform: scale(0.92); +} +.journal-check-mark { + position: absolute; + left: 4px; + top: 1px; + width: 4px; + height: 8px; + border: solid var(--main); + border-width: 0 2px 2px 0; + transform: rotate(45deg); + opacity: 0; + transition: opacity 150ms cubic-bezier(0.2, 0.8, 0.2, 1); } .journal-habits > button[aria-pressed="true"] .journal-check { background: var(--t1); + border-color: var(--t1); color: var(--main); } +.journal-habits > button[aria-pressed="true"] .journal-check-mark { + opacity: 1; +} +.journal-habit-streak { + display: inline-grid; + color: var(--t3); + font-size: 11.5px; + white-space: nowrap; + font-variant-numeric: tabular-nums; +} +.journal-habit-count { + grid-area: 1 / 1; + align-self: center; +} +.journal-habit-count[data-entering] { + animation: journal-streak-in 150ms cubic-bezier(0.2, 0.8, 0.2, 1) both; +} +.journal-habit-count[data-leaving] { + animation: journal-streak-out 105ms cubic-bezier(0.4, 0, 0.2, 1) both; +} +@keyframes journal-streak-in { + from { + opacity: 0; + } + to { + opacity: 1; + } +} +@keyframes journal-streak-out { + from { + opacity: 1; + } + to { + opacity: 0; + } +} +.journal-sr-only { + position: absolute; + width: 1px; + height: 1px; + padding: 0; + margin: -1px; + overflow: hidden; + clip-path: inset(50%); + white-space: nowrap; +} +.journal-legend { + display: flex; + align-items: center; + flex-wrap: wrap; + gap: 8px; + padding: 10px 0; +} +.journal-legend-swatches { + display: inline-grid; + grid-template-columns: repeat(4, 14px); + gap: 3px; +} +.journal-legend-swatches i { + width: 14px; + height: 14px; + border-radius: 3px; + background: var(--raise); +} +.journal-legend-swatches i[data-level="1"] { + background: var(--line2); +} +.journal-legend-swatches i[data-level="2"] { + background: var(--t3); +} +.journal-legend-swatches i[data-level="3"] { + background: var(--t1); +} +@media (max-width: 499px) { + .journal-habit-streak { + display: none; + } +} +@media (prefers-reduced-motion: reduce) { + :root:not([data-motion="on"]) .journal-view .journal-check { + transform: none !important; + transition: + background-color 150ms cubic-bezier(0.4, 0, 0.2, 1), + border-color 150ms cubic-bezier(0.4, 0, 0.2, 1) !important; + } + :root:not([data-motion="on"]) .journal-view .journal-check-mark { + transition: opacity 150ms cubic-bezier(0.4, 0, 0.2, 1) !important; + } + :root:not([data-motion="on"]) + .journal-view + .journal-habit-count[data-entering] { + animation: journal-streak-in 150ms cubic-bezier(0.4, 0, 0.2, 1) both !important; + } + :root:not([data-motion="on"]) + .journal-view + .journal-habit-count[data-leaving] { + animation: journal-streak-out 150ms cubic-bezier(0.4, 0, 0.2, 1) both !important; + } +} +:root[data-motion="reduced"] .journal-view .journal-check { + transform: none !important; + transition: + background-color 150ms cubic-bezier(0.4, 0, 0.2, 1), + border-color 150ms cubic-bezier(0.4, 0, 0.2, 1) !important; +} +:root[data-motion="reduced"] .journal-view .journal-check-mark { + transition: opacity 150ms cubic-bezier(0.4, 0, 0.2, 1) !important; +} +:root[data-motion="reduced"] .journal-view .journal-habit-count[data-entering] { + animation: journal-streak-in 150ms cubic-bezier(0.4, 0, 0.2, 1) both !important; +} +:root[data-motion="reduced"] .journal-view .journal-habit-count[data-leaving] { + animation: journal-streak-out 150ms cubic-bezier(0.4, 0, 0.2, 1) both !important; +} .journal-month-panel { border-left: 1px solid var(--line); padding: 14px; diff --git a/web/src/ui/JournalHabitFeedback.tsx b/web/src/ui/JournalHabitFeedback.tsx new file mode 100644 index 0000000..d3ae885 --- /dev/null +++ b/web/src/ui/JournalHabitFeedback.tsx @@ -0,0 +1,111 @@ +import { useEffect, useId, useLayoutEffect, useRef, useState } from "react"; +import type { AnimationEvent } from "react"; +import type { JournalHabitDetail } from "./journalHabitDetails"; + +/** Owned by the unlocked Journal tree. Presence retains numeric scalars only. */ +export function JournalHabitFeedback({ + name, + detail, + throughLabel, + disabled, + onToggle, +}: { + name: string; + detail: JournalHabitDetail; + throughLabel: string | null; + disabled: boolean; + onToggle: () => void; +}) { + const description = useId(); + const count = useRef(detail.days), + generation = useRef(0), + alive = useRef(true); + const [presence, setPresence] = useState<{ + outgoing: number | null; + entering: boolean; + nonce: number; + }>({ outgoing: null, entering: false, nonce: 0 }); + useLayoutEffect(() => { + alive.current = true; + return () => { + alive.current = false; + generation.current++; + }; + }, []); + useLayoutEffect(() => { + if (count.current === detail.days) return; + const outgoing = count.current; + count.current = detail.days; + setPresence({ outgoing, entering: true, nonce: ++generation.current }); + }, [detail.days]); + useEffect(() => { + if (!presence.nonce) return; + const nonce = presence.nonce; + // Normal exit is105ms; effective reduced exit/entry is150ms. This + // presentation-only fallback also covers a policy change mid-transition. + const timer = setTimeout(() => { + if (alive.current && generation.current === nonce) + setPresence((current) => + current.outgoing === null && !current.entering + ? current + : { ...current, outgoing: null, entering: false }, + ); + }, 150 + 34); + return () => clearTimeout(timer); + }, [presence.nonce]); + function finish(event: AnimationEvent, leaving: boolean) { + if ( + event.target !== event.currentTarget || + !alive.current || + generation.current !== presence.nonce || + event.animationName !== + (leaving ? "journal-streak-out" : "journal-streak-in") + ) + return; + setPresence((current) => + leaving + ? { ...current, outgoing: null } + : { ...current, entering: false }, + ); + } + return ( + + ); +} diff --git a/web/src/ui/JournalView.tsx b/web/src/ui/JournalView.tsx index 2c30caa..fb13261 100644 --- a/web/src/ui/JournalView.tsx +++ b/web/src/ui/JournalView.tsx @@ -32,6 +32,8 @@ import { Dialog } from "./primitives"; import { RichEditor, type EditorLinkControl } from "./RichEditor"; import { Menu } from "./Menu"; import { plainText } from "./model"; +import { journalHabitDetails } from "./journalHabitDetails"; +import { JournalHabitFeedback } from "./JournalHabitFeedback"; import "../styles/journal.css"; const EMPTY_DOCUMENT: NoteDocument = { @@ -436,6 +438,18 @@ function JournalWorkspace({ () => (data ? journalMonth(data, month.year, month.month) : []), [data, month.year, month.month], ); + const habitDetails = useMemo( + () => (data ? journalHabitDetails(data, selectedDay) : null), + [data, selectedDay], + ); + const streakThroughLabels = useMemo(() => { + const labels = new Map(); + for (const detail of habitDetails?.values() ?? []) { + if (detail.through && !labels.has(detail.through)) + labels.set(detail.through, dayTitle(detail.through)); + } + return labels; + }, [habitDetails]); if (!data) return null; const today = journalToday(); const entry = data.entries.find((item) => item.date === selectedDay); @@ -832,37 +846,41 @@ function JournalWorkspace({ {activeHabits.length === 0 ? (

No habits yet

) : ( - activeHabits.map((habit) => ( - - )) + ); + }} + /> + ); + }) )} @@ -1063,6 +1081,7 @@ function Calendar({ onMove: (delta: number) => void; }) { const offset = (new Date(year, month - 1, 1).getDay() + 6) % 7; + const overflow = days.some((day) => day.habitsCompleted > 4); return (
@@ -1102,11 +1121,11 @@ function Calendar({ data-level={ day.habitsCompleted === 0 ? 0 - : day.habitTotal && day.habitsCompleted >= day.habitTotal - ? 3 - : day.habitsCompleted / Math.max(1, day.habitTotal) >= 0.5 + : day.habitsCompleted <= 2 + ? 1 + : day.habitsCompleted === 3 ? 2 - : 1 + : 3 } aria-label={`${dayTitle(day.date)} · ${day.habitsCompleted} of ${day.habitTotal} habits${day.written ? " · written" : ""}`} aria-pressed={day.date === selected} @@ -1117,7 +1136,30 @@ function Calendar({ ))}
-

Habits done · 0, some, most, all

+
+ Habits done + + + 0, 1 to 2, 3,{" "} + {overflow ? ( + <> + + 4 or more + + ) : ( + "4" + )} + +

Days written · {days.filter((day) => day.written).length}

diff --git a/web/tests/ui/journal.test.tsx b/web/tests/ui/journal.test.tsx index 81745c4..84f7e89 100644 --- a/web/tests/ui/journal.test.tsx +++ b/web/tests/ui/journal.test.tsx @@ -1,4 +1,5 @@ import { StrictMode } from "react"; +import userEvent from "@testing-library/user-event"; import { act, fireEvent, @@ -17,6 +18,7 @@ import { emptyJournal, journalToday, saveHabit, + setHabitCompletion, validateJournalData, } from "../../src/collections/journal"; import type { NoteDocument } from "../../src/domain/contracts"; @@ -294,6 +296,160 @@ async function journalMenu(item: string) { } describe("protected Journal UI", () => { + function useNovemberClock() { + vi.useFakeTimers({ toFake: ["Date"] }); + vi.setSystemTime(new Date("2026-11-20T12:00:00Z")); + } + function offsetDay(day: string, offset: number) { + const date = new Date(`${day}T12:00:00Z`); + date.setUTCDate(date.getUTCDate() + offset); + return date.toISOString().slice(0, 10); + } + function history( + count: number, + first: string, + done: (habit: number) => string[], + ) { + let data = emptyJournal(); + for (let i = 0; i < count; i++) { + data = saveHabit( + data, + count === 1 ? "Reading" : `Habit ${i + 1}`, + new Date(`${first}T12:00:00Z`), + ); + const habit = data.habits.at(-1)!; + habit.createdOn = first; + for (const day of done(i)) + data = setHabitCompletion(data, day, habit.id, true, new Date()); + } + return data; + } + it("keeps selected-day streaks, keyboard toggles and failed-save drafts truthful", async () => { + useNovemberClock(); + const today = journalToday(), + yesterday = offsetDay(today, -1); + const fixture = testVault(); + await fixture.vault.update(() => + history(1, offsetDay(today, -5), () => + Array.from({ length: 5 }, (_, i) => offsetDay(today, i - 5)), + ), + ); + render(); + const user = userEvent.setup(), + button = screen.getByRole("button", { name: "Reading" }); + const through = new Date(`${yesterday}T12:00:00`).toLocaleDateString( + undefined, + { weekday: "long", day: "numeric", month: "long", year: "numeric" }, + ); + expect(button).toHaveAccessibleDescription( + `5 consecutive days through ${through}.`, + ); + expect(button).toHaveAttribute("aria-pressed", "false"); + await user.click(button); + expect(button).toHaveAttribute("aria-pressed", "true"); + expect(button).toHaveAccessibleDescription(/6 consecutive days/); + await user.keyboard(" "); + expect(button).toHaveAttribute("aria-pressed", "false"); + expect(button).toHaveAccessibleDescription( + `5 consecutive days through ${through}.`, + ); + await user.keyboard("{Enter}"); + expect(button).toHaveAccessibleDescription(/6 consecutive days/); + fixture.failNext(); + await user.click(button); + expect(fixture.vault.status().dirty).toBe(true); + expect(button).toHaveAttribute("aria-pressed", "false"); + expect(button).toHaveAccessibleDescription(/5 consecutive days/); + expect( + screen.getByText(/Your Journal draft is still in this window/), + ).toBeVisible(); + await user.click( + screen.getByRole("button", { name: "Try again" }), + ); + expect(fixture.vault.retry).toHaveBeenCalledTimes(1); + expect(fixture.vault.status().dirty).toBe(false); + expect(button).toHaveAccessibleDescription(/5 consecutive days/); + expect(fixture.vault.update).toHaveBeenCalledTimes(5); + }); + it("shades actual calendar counts and retains the numeric overflow legend", async () => { + useNovemberClock(); + const month = journalToday().slice(0, 7), + fixture = testVault(); + await fixture.vault.update(() => + history(5, `${month}-01`, (i) => + Array.from( + { length: 5 - i }, + (_, offset) => `${month}-${String(i + offset + 1).padStart(2, "0")}`, + ), + ), + ); + const { container } = render(); + const calendar = container.querySelector(".journal-calendar-days")!; + for (const [day, level, count] of [ + [1, 1, 1], + [2, 1, 2], + [3, 2, 3], + [4, 3, 4], + [5, 3, 5], + [6, 0, 0], + ]) { + const button = Array.from(calendar.querySelectorAll("button")).find( + (item) => item.textContent?.trim() === String(day), + )!; + expect(button).toHaveAttribute("data-level", String(level)); + expect(button).toHaveAccessibleName(new RegExp(`${count} of 5 habits`)); + } + const legend = screen.getByRole("group", { name: "Habits done legend" }); + expect(within(legend).getByText("4+")).toBeInTheDocument(); + expect(legend).toHaveTextContent("4 or more"); + expect(legend).not.toHaveTextContent(/some|most|all/); + }); + it("does not infer overflow or darkest color from a large eligible denominator", async () => { + useNovemberClock(); + const today = journalToday(), + fixture = testVault(); + await fixture.vault.update(() => + history(100, `${today.slice(0, 7)}-01`, (i) => (i < 3 ? [today] : [])), + ); + const { container } = render(); + const selected = container.querySelector( + '.journal-calendar-days button[aria-current="date"]', + )!; + expect(selected).toHaveAttribute("data-level", "2"); + expect(selected).toHaveAccessibleName(/3 of 100 habits/); + const legend = screen.getByRole("group", { name: "Habits done legend" }); + expect(legend).toHaveTextContent("0, 1 to 2, 3, 4"); + expect(within(legend).queryByText("4+")).not.toBeInTheDocument(); + }); + it("removes the current and outgoing streak presentation in the same close turn", async () => { + useNovemberClock(); + const today = journalToday(), + fixture = testVault(); + await fixture.vault.update(() => + history(1, offsetDay(today, -1), () => [offsetDay(today, -1)]), + ); + const { container } = render( + + + , + ); + const button = screen.getByRole("button", { name: "Reading" }); + const description = button.getAttribute("aria-describedby")!; + fireEvent.click(button); + expect(container.querySelectorAll(".journal-habit-count")).toHaveLength(2); + let concealed = false; + act(() => { + fixture.vault.close(); + concealed = + !container.querySelector(".journal-habit-count") && + !document.getElementById(description); + }); + expect(concealed).toBe(true); + expect( + screen.queryByRole("button", { name: "Reading" }), + ).not.toBeInTheDocument(); + expect(fixture.vault.update).toHaveBeenCalledTimes(2); + }); it("exports a never-checked habit even when there are no dated entries", async () => { const OriginalURL = URL; const create = vi.fn(() => "blob:habit-only");