From e8bfe05cd0f880704fa79a2fbd6acaae3e483de0 Mon Sep 17 00:00:00 2001 From: Luan Nguyen Date: Wed, 11 Nov 2026 21:36:45 +1100 Subject: [PATCH] feat(journal): add protected views and credential revocation --- docs/architecture.md | 4 + web/src/styles/journal.css | 427 +++++++ web/src/ui/JournalView.tsx | 1243 +++++++++++++++++++++ web/tests/ui/journal-owner-close.test.tsx | 177 +++ web/tests/ui/journal.test.tsx | 623 +++++++++++ 5 files changed, 2474 insertions(+) create mode 100644 web/src/styles/journal.css create mode 100644 web/src/ui/JournalView.tsx create mode 100644 web/tests/ui/journal-owner-close.test.tsx create mode 100644 web/tests/ui/journal.test.tsx diff --git a/docs/architecture.md b/docs/architecture.md index c14491f..68f6af4 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -84,3 +84,7 @@ Journal uses a separately protected envelope and private-form recovery. Account, ## Delivery boundary: native-link-transport The local main window alone may acknowledge bounded note intents or request validated HTTP(S) source opening. Commands independently verify the current injected caller URL. External URLs receive a fixed OS open verb with no argument expansion; arbitrary navigation and filesystem commands are unavailable. + +## Delivery boundary: journal-interface + +Journal removes unlocked content and populated credential forms synchronously on terminal owner revocation. Replaced or unmounted subscriptions cannot read dead owners or republish late crypto results. Stored writing remains encrypted and can reopen through a fresh eligible owner. diff --git a/web/src/styles/journal.css b/web/src/styles/journal.css new file mode 100644 index 0000000..41ae52d --- /dev/null +++ b/web/src/styles/journal.css @@ -0,0 +1,427 @@ +.journal-view { + min-width: 0; + min-height: 0; + flex: 1; + display: flex; + flex-direction: column; + overflow: auto; + background: var(--main); + color: var(--t1); + font-size: 13px; + container-type: inline-size; +} +.journal-heading { + display: flex; + align-items: center; + gap: 12px; + padding: 12px 22px 10px; + flex-wrap: wrap; + border-bottom: 1px solid var(--line); +} +.journal-heading > div:first-child { + display: flex; + align-items: baseline; + gap: 8px; + flex-wrap: wrap; + min-width: 0; +} +.journal-heading h1 { + margin: 0; + font-size: 14.5px; + font-weight: 500; +} +.journal-heading > div:first-child > span { + color: var(--t3); + font-size: 12px; +} +.journal-actions { + display: flex; + align-items: center; + gap: 8px; + flex-wrap: wrap; +} +.journal-heading > .journal-actions { + margin-left: auto; +} +.journal-view button { + font-size: 12.5px; +} +.journal-heading [role="status"], +.journal-private { + font-size: 12px; + color: var(--t3); +} +.journal-layout { + display: grid; + grid-template-columns: minmax(0, 1fr) 300px; + flex: 1; + min-height: 0; +} +.journal-entry { + width: 100%; + max-width: var(--notes-editor-width, 640px); + box-sizing: border-box; + padding: 28px 40px; + min-width: 0; + align-self: start; + color: var(--t2); + line-height: 1.7; + font-size: var(--notes-editor-size, 14px); +} +.journal-entry h2 { + font-size: 24px; + line-height: 1.3; + color: var(--t1); + font-weight: 500; + margin: 8px 0 18px; +} +.journal-caption { + color: var(--t3); + font-size: 11.5px; + line-height: 1.5; +} +.journal-prompt { + display: flex; + flex-wrap: wrap; + align-items: flex-start; + gap: 8px; + background: var(--raise); + padding: 12px; + border-radius: 9px; + line-height: 1.5; +} +.journal-prompt > span:not(.journal-caption) { + flex: 1; + min-width: min(200px, 100%); + color: var(--t1); +} +.journal-prompt button { + margin-left: auto; +} +.journal-writing { + margin: 18px 0 12px; + min-width: 0; +} +.journal-writing .tiptap { + min-height: 180px; + color: var(--t2); +} +.journal-writing .format { + flex-wrap: wrap; +} +.journal-habits-heading { + display: flex; + align-items: center; + gap: 8px; + margin-top: 14px; + flex-wrap: wrap; +} +.journal-habits-heading h3 { + margin: 0; + color: var(--t3); + font-size: 12px; + font-weight: 400; +} +.journal-habits-heading > button { + margin-left: auto; + background: transparent; +} +.journal-habits { + display: flex; + flex-wrap: wrap; + gap: 8px; + margin-top: 8px; +} +.journal-habits > button { + min-height: 36px; + height: auto; + padding: 6px 13px; + gap: 9px; + border-radius: 10px; + background: var(--raise); + color: var(--t2); + line-height: 1.4; + text-align: left; + overflow-wrap: anywhere; + max-width: 100%; +} +.journal-habits > button[aria-pressed="true"] { + color: var(--t1); +} +.journal-check { + 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; +} +.journal-habits > button[aria-pressed="true"] .journal-check { + background: var(--t1); + color: var(--main); +} +.journal-month-panel { + border-left: 1px solid var(--line); + padding: 14px; + box-sizing: border-box; +} +.journal-calendar { + min-width: 0; + width: 240px; + max-width: 100%; +} +.journal-calendar header { + display: flex; + align-items: center; + gap: 4px; + margin-bottom: 14px; +} +.journal-calendar h2 { + flex: 1; + font-size: 13px; + font-weight: 500; + margin: 0; +} +.journal-calendar header button { + width: 28px; + height: 28px; + min-height: 28px; + padding: 0; + background: transparent; +} +.journal-weekdays, +.journal-calendar-days { + display: grid; + grid-template-columns: repeat(7, 30px); + gap: 5px; +} +.journal-weekdays { + margin-bottom: 5px; + font-size: 10px; + color: var(--t3); + text-align: center; +} +.journal-calendar-days button { + width: 30px; + height: 30px; + min-height: 30px; + padding: 0; + border-radius: 7px; + display: grid; + place-items: center; + background: var(--raise); + font-size: 11px; + font-variant-numeric: tabular-nums; + color: var(--t3); +} +.journal-calendar-days button[data-level="1"] { + background: var(--line2); + color: var(--t1); +} +.journal-calendar-days button[data-level="2"] { + background: var(--t3); + color: var(--btnt); +} +.journal-calendar-days button[data-level="3"] { + background: var(--t1); + color: var(--btnt); +} +.journal-calendar-days button:disabled { + opacity: 1; + background: transparent; + box-shadow: inset 0 0 0 1px var(--line); + color: var(--t3); +} +.journal-calendar-days button[aria-current="date"] { + box-shadow: + 0 0 0 2px var(--main), + 0 0 0 3.5px var(--t1); +} +.journal-calendar-days button[aria-pressed="true"] { + outline: 1px solid var(--t1); + outline-offset: -2px; +} +.journal-calendar-days button:focus-visible { + outline: 2px solid var(--t1); + outline-offset: 2px; +} +.journal-history { + display: grid; + grid-template-columns: 240px minmax(0, 1fr); + gap: 40px; + padding: 28px; + min-width: 0; +} +.journal-past { + min-width: 0; +} +.journal-past h2 { + margin: 0 0 8px; + font-size: 13px; + font-weight: 500; +} +.journal-history-row { + display: flex; + flex-direction: column; + align-items: flex-start; + gap: 5px; + text-align: left; + width: 100%; + border-radius: 8px; + background: transparent; + padding: 12px 10px; + border-bottom: 1px solid var(--line); +} +.journal-history-row b { + color: var(--t1); + font-weight: 500; +} +.journal-history-row span, +.journal-history-row small { + color: var(--t3); + overflow-wrap: anywhere; +} +.journal-banner { + padding: 10px 14px; + margin: 12px 22px; + background: var(--raise); + border-radius: 9px; + font-size: 12px; +} +.journal-banner button { + margin-left: 8px; +} +.journal-dialog .journal-banner { + margin: 8px 0 16px; +} +.journal-lock { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 14px; + text-align: center; + padding: 40px 24px; + min-height: 340px; + flex: 1; +} +.journal-lock h1 { + font-size: 20px; + font-weight: 500; + margin: 0; +} +.journal-lock > p { + max-width: 360px; + color: var(--t3); + font-size: 13px; + margin: 0; + line-height: 1.6; +} +.journal-lock-icon { + display: inline-grid; + place-items: center; + width: 44px; + height: 44px; + border-radius: 14px; + background: var(--raise); + color: var(--t3); +} +.journal-credentials, +.journal-form { + display: flex; + flex-direction: column; + gap: 14px; +} +.journal-credentials { + width: 360px; + max-width: 100%; + text-align: left; +} +.journal-credentials .journal-actions, +.journal-form .journal-actions { + justify-content: flex-end; +} +.journal-form p { + margin: 0; + font-size: 13px; + line-height: 1.6; +} +.journal-credentials [role="alert"] { + font-size: 12px; + margin: 0; +} +.journal-settings { + display: flex; + flex-direction: column; + gap: 14px; +} +.journal-settings > div { + display: flex; + align-items: center; + gap: 8px; + flex-wrap: wrap; + font-size: 13px; +} +.journal-settings b { + font-weight: 500; +} +.journal-settings > div > span { + margin-right: auto; + min-width: 0; + overflow-wrap: anywhere; +} +.journal-settings > div > b { + margin-right: auto; +} +.journal-settings h3 { + font-size: 13px; + font-weight: 500; + margin: 8px 0 0; +} +.journal-toggle { + display: flex; + flex-direction: row; + align-items: center; + gap: 8px; + font-size: 13px; +} +.journal-toggle input { + width: 16px; + height: 16px; +} +@container (max-width: 800px) { + .journal-layout { + grid-template-columns: minmax(0, 1fr); + } + .journal-month-panel { + display: none; + } + .journal-heading { + padding: 12px 20px 10px; + } + .journal-heading > .journal-actions { + margin-left: 0; + } + .journal-entry { + padding: 24px; + } + .journal-history { + grid-template-columns: minmax(0, 1fr); + gap: 24px; + padding: 24px; + } +} +@media (max-width: 480px) { + .journal-private { + width: 100%; + } + .journal-lock { + padding: 32px 20px; + } + .journal-entry { + padding: 24px 20px; + } +} diff --git a/web/src/ui/JournalView.tsx b/web/src/ui/JournalView.tsx new file mode 100644 index 0000000..2c30caa --- /dev/null +++ b/web/src/ui/JournalView.tsx @@ -0,0 +1,1243 @@ +import { + useEffect, + useLayoutEffect, + useMemo, + useRef, + useState, + useSyncExternalStore, +} from "react"; +import type { MutableRefObject } from "react"; +import { flushSync } from "react-dom"; +import { strToU8, zipSync } from "fflate"; +import type { + JournalVault, + JournalVaultStatus, +} from "../collections/journalVault"; +import type { Habit, JournalData } from "../collections/journal"; +import { + archiveHabit, + journalMonth, + journalToday, + setHabitCompletion, + writeJournalEntry, +} from "../collections/journal"; +import { exportJournalFiles } from "../collections/journalTransfer"; +import type { + HabitFormDraft, + HabitFormInput, +} from "../collections/journalViewDraft"; +import type { NoteDocument } from "../domain/contracts"; +import { registerCloseGuard, trackOperation } from "../desktop/closeGuard"; +import { Dialog } from "./primitives"; +import { RichEditor, type EditorLinkControl } from "./RichEditor"; +import { Menu } from "./Menu"; +import { plainText } from "./model"; +import "../styles/journal.css"; + +const EMPTY_DOCUMENT: NoteDocument = { + type: "doc", + content: [{ type: "paragraph", content: [] }], +}; +const PROMPTS = [ + "What is one thing you want to remember from this week?", + "What felt important today?", + "What would you like to remember?", + "What helped you today?", +]; +const message = (value: string) => value.replace(/^[a-z_]+: /, ""); +function dayDate(day: string) { + return new Date(`${day}T12:00:00`); +} +function dayTitle(day: string) { + return dayDate(day).toLocaleDateString(undefined, { + weekday: "long", + day: "numeric", + month: "long", + year: "numeric", + }); +} +function monthTitle(year: number, month: number) { + return new Date(year, month - 1, 1).toLocaleDateString(undefined, { + month: "long", + year: "numeric", + }); +} +type JournalForm = + | { kind: "habit" } + | { kind: "archive"; habit: Habit } + | { kind: "settings" } + | { kind: "export" }; +type JournalNavigation = { + selectedDay: string; + month: { year: number; month: number }; + history: boolean; +}; +function initialNavigation(): JournalNavigation { + const selectedDay = journalToday(), + date = dayDate(selectedDay); + return { + selectedDay, + month: { year: date.getFullYear(), month: date.getMonth() + 1 }, + history: false, + }; +} +function readHabitDraft(vault: JournalVault) { + try { + return vault.status().phase === "unlocked" ? vault.readHabitDraft() : null; + } catch { + return null; + } +} + +function useVaultStatus(vault: JournalVault) { + const active = useRef(null); + useLayoutEffect(() => { + active.current = vault; + return () => { + if (active.current === vault) active.current = null; + }; + }, [vault]); + const external = useMemo(() => { + let cached = vault.status(); + return { + get: () => cached, + subscribe: (notify: () => void) => { + cached = vault.status(); + if (cached.phase === "closed" || cached.phase === "revoked") + return () => {}; + let listening = true; + const stop = vault.subscribe(() => { + if (!listening || active.current !== vault) return; + const next = vault.status(); + const hiding = + (cached.phase === "unlocked" && next.phase !== "unlocked") || + (cached.phase !== next.phase && + (next.phase === "closed" || next.phase === "revoked")); + cached = next; + if (hiding) flushSync(notify); + else notify(); + }); + cached = vault.status(); + return () => { + listening = false; + stop(); + }; + }, + }; + }, [vault]); + return useSyncExternalStore(external.subscribe, external.get); +} + +/** Metadata is the only state retained while Journal is locked. */ +export function JournalView({ + vault, + onReopen, +}: { + vault: JournalVault; + onReopen?: () => void; +}) { + const status = useVaultStatus(vault); + const formDirty = useRef(false); + const [authPending, setAuthPending] = useState(false); + const [navigation, setNavigation] = useState(initialNavigation); + const owner = useMemo(() => crypto.randomUUID(), [vault]); + useLayoutEffect(() => { + setNavigation(initialNavigation()); + setAuthPending(false); + }, [vault]); + const [closeNotice, setCloseNotice] = useState(""); + useLayoutEffect( + () => + registerCloseGuard(() => { + if (!formDirty.current && vault.canClose()) return true; + setCloseNotice( + "Your Journal changes are still in this window. Save or cancel before leaving.", + ); + return false; + }), + [vault], + ); + useEffect(() => { + const guard = (event: BeforeUnloadEvent) => { + if (formDirty.current || !vault.canClose()) event.preventDefault(); + }; + window.addEventListener("beforeunload", guard); + return () => window.removeEventListener("beforeunload", guard); + }, [vault]); + useLayoutEffect(() => { + if (status.phase !== "unlocked") { + formDirty.current = false; + setCloseNotice(""); + } + if (status.phase === "revoked" || status.phase === "closed") + setNavigation(initialNavigation()); + }, [status.phase]); + return ( +
+ {closeNotice && ( +

+ {closeNotice} +

+ )} + {status.phase === "unlocked" && !authPending ? ( + { + if (vault.status().phase === "unlocked") + setNavigation((previous) => ({ ...previous, ...patch })); + }} + /> + ) : status.phase === "closed" || status.phase === "revoked" ? ( +
+

Journal access changed

+

Journal is hidden. Reopen it to continue.

+ {onReopen && } +
+ ) : ( + + )} +
+ ); +} + +function Credentials({ + vault, + status, + formDirty, + pending, + onPending, +}: { + vault: JournalVault; + status: JournalVaultStatus; + formDirty: MutableRefObject; + pending: boolean; + onPending: (pending: boolean) => void; +}) { + const [passphrase, setPassphrase] = useState(""); + const [confirmation, setConfirmation] = useState(""); + const [validation, setValidation] = useState(""); + const active = useRef(false); + const mounted = useRef(true); + useLayoutEffect(() => { + mounted.current = true; + return () => { + mounted.current = false; + formDirty.current = false; + }; + }, []); + const creating = !status.hasJournal; + const busy = + pending || status.phase === "unlocking" || status.phase === "sealing"; + function clear() { + setPassphrase(""); + setConfirmation(""); + setValidation(""); + formDirty.current = false; + } + async function submit() { + if (active.current || busy) return; + if (passphrase.length < 12 || passphrase.length > 1024) { + setValidation("Use a passphrase of 12 to 1,024 characters."); + return; + } + if (creating && passphrase !== confirmation) { + setValidation("The passphrases don’t match. Enter them again."); + setPassphrase(""); + setConfirmation(""); + formDirty.current = false; + return; + } + const secret = passphrase; + active.current = true; + onPending(true); + clear(); + try { + await trackOperation(() => + creating ? vault.create(secret) : vault.unlock(secret), + ); + } catch { + if (mounted.current && !vault.status().error) + setValidation( + creating + ? "Journal setup did not finish. Try again." + : "Couldn’t unlock Journal. Check your passphrase and try again.", + ); + } finally { + active.current = false; + if (mounted.current) onPending(false); + } + } + return ( +
+ +

{creating ? "Set a Journal passphrase" : "Journal is locked"}

+

+ {creating + ? "If you forget it, Orbyn can’t unlock your Journal." + : status.pendingRecovery + ? "Your Journal draft is hidden in this window. Unlock it to save or recover it." + : "It locks after 1 minute away. Your habits are locked too."} +

+ {status.phase === "sealing" ? ( +

Hiding and securing your draft…

+ ) : ( +
{ + event.preventDefault(); + void submit(); + }} + > + + {creating && ( + + )} + {(validation || status.error) && ( +

+ {validation || message(status.error!)} +

+ )} +
+ + +
+
+ )} +
+ ); +} + +function JournalWorkspace({ + vault, + status, + formDirty, + navigation, + onNavigate, +}: { + vault: JournalVault; + status: JournalVaultStatus; + formDirty: MutableRefObject; + navigation: JournalNavigation; + onNavigate: (patch: Partial) => void; +}) { + const { selectedDay, month, history } = navigation; + const setSelectedDay = (value: string) => onNavigate({ selectedDay: value }); + const setMonth = (value: JournalNavigation["month"]) => + onNavigate({ month: value }); + const setHistory = (value: boolean) => onNavigate({ history: value }); + const [form, setForm] = useState(() => + readHabitDraft(vault) ? { kind: "habit" } : null, + ); + const [cancelHabitOpen, setCancelHabitOpen] = useState(false); + const [discardOpen, setDiscardOpen] = useState(false); + const [busy, setBusy] = useState(false); + const lockRequested = useRef(false); + const [savingShown, setSavingShown] = useState(false); + const [actionError, setActionError] = useState(""); + const mounted = useRef(true); + useLayoutEffect(() => { + mounted.current = true; + return () => { + mounted.current = false; + formDirty.current = false; + }; + }, []); + useEffect(() => { + if (!status.saving) { + setSavingShown(false); + return; + } + const timer = setTimeout(() => setSavingShown(true), 1000); + return () => clearTimeout(timer); + }, [status.saving]); + const data = useMemo(() => { + try { + return vault.status().phase === "unlocked" ? vault.read() : null; + } catch { + return null; + } + }, [vault, status]); + const habitDraft = useMemo(() => readHabitDraft(vault), [vault, status]); + const linkDraft = useMemo(() => { + try { + return vault.status().phase === "unlocked" ? vault.readLinkDraft() : null; + } catch { + return null; + } + }, [vault, status]); + const days = useMemo( + () => (data ? journalMonth(data, month.year, month.month) : []), + [data, month.year, month.month], + ); + if (!data) return null; + const today = journalToday(); + const entry = data.entries.find((item) => item.date === selectedDay); + const linkControl: EditorLinkControl = { + draft: linkDraft?.date === selectedDay ? linkDraft : null, + valid: + !linkDraft || + (linkDraft.date === selectedDay && + linkDraft.entryId === (entry?.id ?? null) && + linkDraft.entryRevision === (entry?.revision ?? 0)), + open: (draft) => vault.setLinkDraft({ ...draft, date: selectedDay }), + change: (url) => { + const current = vault.readLinkDraft(); + if (!current || current.date !== selectedDay) + throw new Error("This link draft is no longer available."); + const { date, source, from, to, selection, initialUrl } = current; + vault.setLinkDraft({ + date, + source, + from, + to, + selection, + initialUrl, + url, + }); + }, + cancel: () => vault.discardLinkDraft(), + beginApply: () => { + const token = vault.beginLinkApply(); + return (document, changed) => + vault.completeLinkDraft(token, { document, changed }); + }, + lock: () => { + void lock(); + }, + }; + const prompt = entry?.prompt ?? PROMPTS[0]!; + const activeHabits = data.habits + .filter( + (habit) => + habit.createdOn <= selectedDay && + (!habit.archivedOn || habit.archivedOn >= selectedDay), + ) + .sort((a, b) => a.order - b.order); + const completed = new Set( + data.completions + .filter((item) => item.date === selectedDay && item.done) + .map((item) => item.habitId), + ); + const written = days.filter((day) => day.written); + const canEdit = !status.conflict; + function openForm(next: JournalForm | { kind: "habit"; habit: Habit }) { + try { + if (next.kind === "habit") { + const habit = "habit" in next ? next.habit : undefined; + vault.setHabitDraft({ + mode: habit ? "edit" : "create", + id: habit?.id ?? null, + name: habit?.name ?? "", + }); + formDirty.current = vault.status().habitDraftDirty; + setForm({ kind: "habit" }); + } else { + formDirty.current = false; + setForm(next); + } + setActionError(""); + } catch (cause) { + setActionError( + cause instanceof Error + ? message(cause.message) + : "Couldn’t open this habit. Try again.", + ); + } + } + function closeForm() { + if (busy) return; + if (form?.kind === "habit") { + if (readHabitDraft(vault)?.dirty) { + setCancelHabitOpen(true); + return; + } + vault.discardHabitDraft(); + } + formDirty.current = false; + setForm(null); + setActionError(""); + } + async function operation(action: () => Promise): Promise { + setActionError(""); + try { + await trackOperation(action); + return mounted.current && vault.status().phase === "unlocked"; + } catch (cause) { + if ( + mounted.current && + vault.status().phase === "unlocked" && + !vault.status().error + ) + setActionError( + cause instanceof Error + ? message(cause.message) + : "Couldn’t save Journal. Your draft is still here. Try again.", + ); + return false; + } + } + function write(document: NoteDocument) { + const at = new Date(); + // update accepts the entire current edit synchronously before its first await. + void operation(() => + vault.update((current) => { + const existing = current.entries.find( + (item) => item.date === selectedDay, + ); + return writeJournalEntry( + current, + selectedDay, + document, + existing?.prompt ?? + (current.preferences.promptEnabled ? PROMPTS[0]! : null), + at, + ); + }), + ); + } + function newPrompt() { + const at = new Date(); + void operation(() => + vault.update((current) => { + const existing = current.entries.find( + (item) => item.date === selectedDay, + ); + const index = PROMPTS.indexOf(existing?.prompt ?? PROMPTS[0]!); + return writeJournalEntry( + current, + selectedDay, + existing?.document ?? EMPTY_DOCUMENT, + PROMPTS[(index + 1) % PROMPTS.length]!, + at, + ); + }), + ); + } + function changeHabitName(name: string) { + const current = readHabitDraft(vault); + if (!current) return; + try { + const input: HabitFormInput = { + mode: current.mode, + id: current.id, + name, + }; + vault.setHabitDraft(input); + formDirty.current = vault.status().habitDraftDirty; + setActionError(""); + } catch (cause) { + setActionError( + cause instanceof Error + ? message(cause.message) + : "Couldn’t keep this habit draft. Try again.", + ); + } + } + async function saveHabitForm() { + if (busy || status.conflict) return; + setBusy(true); + const saved = await operation(() => { + const pending = vault.saveHabitDraft(); + formDirty.current = vault.status().habitDraftDirty; + return pending; + }); + if (mounted.current) { + setBusy(false); + if (saved) { + formDirty.current = false; + setForm(null); + } + } + } + async function lock() { + if (lockRequested.current || vault.status().phase !== "unlocked") return; + lockRequested.current = true; + setBusy(true); + try { + await operation(() => vault.lock()); + } finally { + lockRequested.current = false; + if (mounted.current) setBusy(false); + } + } + function selectDay(day: string) { + if (formDirty.current || busy) return; + setSelectedDay(day); + setHistory(false); + } + function moveMonth(delta: number) { + const date = new Date(month.year, month.month - 1 + delta, 1); + if (date.getFullYear() >= 1000 && date.getFullYear() <= 9999) + setMonth({ year: date.getFullYear(), month: date.getMonth() + 1 }); + } + const error = actionError || (status.error ? message(status.error) : ""); + const statusLabel = status.conflict + ? "Needs you" + : status.error + ? "Couldn’t save" + : status.saving && savingShown + ? "Saving…" + : status.dirty || + status.habitDraftDirty || + status.linkDraftDirty || + status.saving + ? "Unsaved changes" + : "Saved"; + const calendar = ( + + ); + const recovery = ( + <> + {error && ( +
+ {error} + {status.conflict ? ( + + ) : ( + + )} +
+ )} + + ); + return ( + <> +
+
+

Journal

+ + {history + ? monthTitle(month.year, month.month) + : dayTitle(selectedDay)} + +
+
+ + Private · passphrase protected + + {statusLabel} + {status.dirty && + !status.saving && + !status.conflict && + !status.error && ( + + )} + + + openForm({ kind: "settings" }), + }, + { label: "Lock Journal", onSelect: () => void lock() }, + { + label: "Export Journal", + onSelect: () => openForm({ kind: "export" }), + }, + ...(status.dirty || status.conflict + ? [ + { + label: "Discard draft and reload", + danger: true, + onSelect: () => setDiscardOpen(true), + }, + ] + : []), + ]} + /> +
+
+ {!form && recovery} + {history ? ( +
+
{calendar}
+
+

Past entries

+

+ {written.length} {written.length === 1 ? "day" : "days"} written · + Private +

+ {written.length === 0 ? ( +

No entries in this month

+ ) : ( + [...written].reverse().map((day) => ( + + )) + )} +
+
+ ) : ( +
+
+ {dayTitle(selectedDay)} +

+ {selectedDay === today + ? "Today" + : dayDate(selectedDay).toLocaleDateString(undefined, { + month: "long", + day: "numeric", + })} +

+ {data.preferences.promptEnabled && ( +
+ Prompt + {prompt} + +
+ )} +
+ +
+
+

+ Habits ·{" "} + {activeHabits.filter((habit) => completed.has(habit.id)).length}{" "} + of {activeHabits.length} done +

+ +
+
+ {activeHabits.length === 0 ? ( +

No habits yet

+ ) : ( + activeHabits.map((habit) => ( + + )) + )} +
+
+ +
+ )} + {form && ( + + {recovery} + {form.kind === "habit" && habitDraft && ( + void saveHabitForm()} + onCancel={closeForm} + /> + )} + {form.kind === "archive" && ( + <> +

+ This hides the habit from future days. Past completions stay in + your Journal. +

+ + + + )} + {form.kind === "settings" && ( +
+
+ Lock Journal with + A separate passphrase +
+
+ Lock after + 1 minute away +
+ +

Habits

+ {data.habits + .filter((habit) => !habit.archivedAt) + .map((habit) => ( +
+ {habit.name} + + +
+ ))} + + +
+ )} + {form.kind === "export" && ( + + )} +
+ )} + {cancelHabitOpen && ( + setCancelHabitOpen(false)} + > +

Your unsubmitted habit changes will be discarded.

+ + +
+ )} + {discardOpen && ( + { + if (!busy) setDiscardOpen(false); + }} + > +

+ This discards your unsaved Journal, habit and link changes in this + window and returns to the saved Journal. Copy any text you need + before continuing. +

+ + +
+ )} + + ); +} + +function Calendar({ + days, + year, + month, + selected, + today, + onSelect, + onMove, +}: { + days: ReturnType; + year: number; + month: number; + selected: string; + today: string; + onSelect: (day: string) => void; + onMove: (delta: number) => void; +}) { + const offset = (new Date(year, month - 1, 1).getDay() + 6) % 7; + return ( +
+
+

{monthTitle(year, month)}

+ + +
+ +
+ {Array.from({ length: offset }, (_, index) => ( + + ))} + {days.map((day) => ( + + ))} +
+

Habits done · 0, some, most, all

+

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

+
+ ); +} +function HabitForm({ + draft, + disabled, + working, + onChange, + onSave, + onCancel, +}: { + draft: HabitFormDraft; + disabled: boolean; + working: boolean; + onChange: (name: string) => void; + onSave: () => void; + onCancel: () => void; +}) { + return ( +
{ + event.preventDefault(); + if (draft.name.trim()) onSave(); + }} + > + +
+ + +
+
+ ); +} +function JournalExport({ + vault, + onCancel, +}: { + vault: JournalVault; + onCancel: () => void; +}) { + const [url, setUrl] = useState(null); + const download = useRef(null); + const [error, setError] = useState(""); + useLayoutEffect( + () => () => { + if (download.current) URL.revokeObjectURL(download.current); + download.current = null; + }, + [], + ); + function prepare() { + if (download.current) return; + try { + const output = exportJournalFiles(vault.read()); + if (!output.length) { + setError( + "There are no Journal entries or habit records to export yet.", + ); + return; + } + const files: Record = {}; + for (const file of output) files[file.filename] = strToU8(file.content); + if (vault.status().phase !== "unlocked") return; + const generated = URL.createObjectURL( + new Blob([zipSync(files) as BlobPart], { type: "application/zip" }), + ); + download.current = generated; + setUrl(generated); + } catch { + setError("Couldn’t export Journal. Your entries were not changed."); + } + } + return ( +
+

+ Exported entries and habits are readable copies. Anyone with the files + can read them. +

+

The file is not encrypted. Keep it somewhere safe.

+

+ Includes a complete JSON backup and readable Markdown. Journal import + and automatic restore are not available yet. +

+ {error && ( +

+ {error} +

+ )} +
+ + {url ? ( + + Download Journal + + ) : ( + + )} +
+
+ ); +} diff --git a/web/tests/ui/journal-owner-close.test.tsx b/web/tests/ui/journal-owner-close.test.tsx new file mode 100644 index 0000000..fd1bff9 --- /dev/null +++ b/web/tests/ui/journal-owner-close.test.tsx @@ -0,0 +1,177 @@ +import { act, fireEvent, render, screen } from "@testing-library/react"; +import { expect, it, vi } from "vitest"; +import type { + JournalVault, + JournalVaultStatus, +} from "../../src/collections/journalVault"; +import { JournalView } from "../../src/ui/JournalView"; + +// UI lifecycle adapter only. No keys, encryption or real owner authority is mocked as qualified. +function lifecycle(hasJournal = false) { + let metadata: JournalVaultStatus = { + phase: "locked", + hasJournal, + dirty: false, + habitDraftDirty: false, + linkDraftDirty: false, + saving: false, + pendingRecovery: false, + conflict: false, + error: null, + }; + const listeners = new Set<() => void>(), + notifiers: (() => void)[] = []; + let release!: () => void; + const pending = new Promise((resolve) => { + release = resolve; + }); + function transition( + phase: JournalVaultStatus["phase"], + inspect?: () => void, + ) { + metadata = { ...metadata, phase }; + for (const notify of [...listeners]) { + notify(); + // This executes inside the owner's notifier turn, before React act exits. + inspect?.(); + } + } + const vault: JournalVault = { + status: vi.fn(() => ({ ...metadata })), + read: vi.fn(() => { + throw Error("This boundary adapter never unlocks plaintext"); + }), + readHabitDraft: vi.fn(() => null), + setHabitDraft: vi.fn(), + saveHabitDraft: vi.fn(async () => {}), + discardHabitDraft: vi.fn(), + readLinkDraft: vi.fn(() => null), + setLinkDraft: vi.fn(), + discardLinkDraft: vi.fn(), + beginLinkApply: vi.fn(() => "unused"), + completeLinkDraft: vi.fn(), + create: vi.fn(() => { + transition("unlocking"); + return pending; + }), + unlock: vi.fn(() => { + transition("unlocking"); + return pending; + }), + update: vi.fn(async () => {}), + retry: vi.fn(async () => {}), + lock: vi.fn(async () => {}), + discardAndReload: vi.fn(async () => {}), + setAway: vi.fn(), + canClose: vi.fn(() => true), + subscribe: vi.fn((notify) => { + listeners.add(notify); + notifiers.push(notify); + return () => { + listeners.delete(notify); + }; + }), + close: vi.fn(() => transition("closed")), + }; + return { vault, transition, notifiers, release }; +} +const secret = "Synthetic lifecycle passphrase"; +function populate(hasJournal: boolean) { + fireEvent.change(screen.getByLabelText("Passphrase", { exact: true }), { + target: { value: secret }, + }); + if (!hasJournal) + fireEvent.change( + screen.getByLabelText("Confirm passphrase", { exact: true }), + { target: { value: secret } }, + ); +} +const passwords = () => [ + ...document.querySelectorAll('input[type="password"]'), +]; + +for (const terminal of ["closed", "revoked"] as const) { + for (const hasJournal of [false, true]) { + it(`removes populated ${hasJournal ? "locked" : "enrollment"} credentials within the ${terminal} notifier turn`, () => { + const owner = lifecycle(hasJournal); + render(); + populate(hasJournal); + expect(passwords().every((input) => input.value === secret)).toBe(true); + let samples = 0; + act(() => + owner.transition(terminal, () => { + samples++; + expect(passwords()).toHaveLength(0); + expect( + screen.getByRole("heading", { name: "Journal access changed" }), + ).toBeVisible(); + }), + ); + expect(samples).toBe(1); + expect(owner.vault.read).not.toHaveBeenCalled(); + }); + + it(`removes ${hasJournal ? "unlock" : "create"}-in-progress fields synchronously on ${terminal} and ignores late completion`, async () => { + const owner = lifecycle(hasJournal); + render(); + populate(hasJournal); + fireEvent.click( + screen.getByRole("button", { + name: hasJournal ? "Unlock Journal" : "Lock Journal", + }), + ); + expect(owner.vault.status().phase).toBe("unlocking"); + expect(passwords()).toHaveLength(hasJournal ? 1 : 2); + try { + act(() => + owner.transition(terminal, () => { + expect(passwords()).toHaveLength(0); + expect( + screen.getByRole("heading", { name: "Journal access changed" }), + ).toBeVisible(); + }), + ); + } finally { + await act(async () => owner.release()); + } + expect(passwords()).toHaveLength(0); + expect( + screen.getByRole("heading", { name: "Journal access changed" }), + ).toBeVisible(); + expect(owner.vault.read).not.toHaveBeenCalled(); + }); + } +} + +it("ignores a captured old-owner notifier after replacement without reading its closed status", () => { + const previous = lifecycle(), + next = lifecycle(true); + const view = render(); + populate(false); + const late = previous.notifiers[0]!; + view.rerender(); + populate(true); + vi.mocked(previous.vault.status).mockImplementation(() => { + throw Error("Old owner must not be read"); + }); + expect(() => act(late)).not.toThrow(); + expect(screen.getByLabelText("Passphrase", { exact: true })).toHaveValue( + secret, + ); + expect( + screen.queryByRole("heading", { name: "Journal access changed" }), + ).not.toBeInTheDocument(); +}); + +it("ignores a captured notifier after unmount without calling the dead owner", () => { + const owner = lifecycle(); + const view = render(); + populate(false); + const late = owner.notifiers[0]!; + view.unmount(); + vi.mocked(owner.vault.status).mockImplementation(() => { + throw Error("Unmounted owner must not be read"); + }); + expect(() => act(late)).not.toThrow(); + expect(passwords()).toHaveLength(0); +}); diff --git a/web/tests/ui/journal.test.tsx b/web/tests/ui/journal.test.tsx new file mode 100644 index 0000000..81745c4 --- /dev/null +++ b/web/tests/ui/journal.test.tsx @@ -0,0 +1,623 @@ +import { StrictMode } from "react"; +import { + act, + fireEvent, + render, + screen, + waitFor, + within, +} from "@testing-library/react"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import type { + JournalVault, + JournalVaultStatus, +} from "../../src/collections/journalVault"; +import type { JournalData } from "../../src/collections/journal"; +import { + emptyJournal, + journalToday, + saveHabit, + validateJournalData, +} from "../../src/collections/journal"; +import type { NoteDocument } from "../../src/domain/contracts"; +import { plainText } from "../../src/ui/model"; +import { JournalView } from "../../src/ui/JournalView"; +import { checkClose } from "../../src/desktop/closeGuard"; +import type { HabitFormDraft } from "../../src/collections/journalViewDraft"; +import { + validateHabitFormDraft, + validateHabitFormInput, +} from "../../src/collections/journalViewDraft"; + +vi.mock("../../src/ui/RichEditor", () => ({ + RichEditor: ({ + document, + onChange, + readOnly, + ariaLabel, + }: { + document: NoteDocument; + onChange: (document: NoteDocument) => void; + readOnly: boolean; + ariaLabel: string; + }) => ( +