diff --git a/web/src/styles/study-settings.css b/web/src/styles/study-settings.css new file mode 100644 index 0000000..d38f24b --- /dev/null +++ b/web/src/styles/study-settings.css @@ -0,0 +1,122 @@ +.study-settings { + min-width: 0; + font-size: 13px; + line-height: 1.45; +} +.study-settings form { + display: flex; + flex-direction: column; + gap: 12px; +} +.study-settings-help { + margin: 0; + color: var(--t3); + font-size: 12px; +} +.study-settings-row { + display: flex; + flex-direction: row; + align-items: center; + justify-content: space-between; + gap: 16px; + min-height: 52px; + padding: 8px 0; + border-top: 1px solid var(--line); +} +.study-settings-row > label, +.study-settings-row > div, +.study-settings-row > span:first-child { + min-width: 0; + flex: 1; +} +.study-settings-row b { + display: block; + font-size: 13px; + font-weight: 500; + color: var(--t1); +} +.study-settings-row small { + display: block; + color: var(--t3); + font-size: 12px; + overflow-wrap: anywhere; +} +.study-settings-row small.error { + color: var(--red); +} +.study-settings-row > input[type="text"], +.study-settings-row > select { + width: 100px; + height: 32px; + flex: none; + padding: 0 10px; + border: 1px solid var(--line2); + border-radius: 8px; + background: var(--main); + color: var(--t1); + font-size: 12.5px; +} +.study-settings-row > input[type="checkbox"] { + appearance: none; + width: 30px; + height: 18px; + flex: none; + padding: 0; + margin: 0; + border: 0; + border-radius: 999px; + background: radial-gradient(circle, var(--main) 7px, transparent 7px) 2px + 2px / 14px 14px no-repeat var(--line2); + cursor: pointer; +} +.study-settings-row > input[type="checkbox"]:checked { + background-position: 14px 2px; + background-color: var(--t1); +} +.study-settings-footer { + display: flex; + align-items: center; + flex-wrap: wrap; + gap: 6px; + padding-top: 12px; +} +.study-settings-footer > span { + flex: 1; + color: var(--t3); + font-size: 12px; +} +.study-settings-notice { + margin: 0; + color: var(--t2); +} +.study-settings-planned { + margin-top: 20px; +} +.study-planned-label { + flex: none; + color: var(--t3); + font-size: 11px; +} +.study-settings-confirm-actions { + display: flex; + flex-wrap: wrap; + gap: 6px; +} +.settings-study-pane .settings-content { + padding-top: 8px; +} +.settings-study-pane .settings-heading h3 { + font-size: 22px; + letter-spacing: -0.015em; +} +@media (max-width: 600px) { + .study-settings-row { + flex-wrap: wrap; + } + .study-settings-row > label { + flex-basis: calc(100% - 116px); + } + .study-settings-footer > span { + flex-basis: 100%; + } +} diff --git a/web/src/ui/SettingsView.tsx b/web/src/ui/SettingsView.tsx index cb927bd..5998d52 100644 --- a/web/src/ui/SettingsView.tsx +++ b/web/src/ui/SettingsView.tsx @@ -1,6 +1,12 @@ -import { useState } from "react"; -import { Download, Settings } from "lucide-react"; +import { useRef, useState } from "react"; +import { Download, Layers, Settings, Type } from "lucide-react"; +import { StudySettingsPanel } from "./StudySettingsPanel"; +import type { + StudySettingsOwner, + SettingsLeaveGuard, +} from "./studySettingsTypes"; import type { Theme, usePreferences } from "./primitives"; +import { useEditorPreferences } from "./editorPreferences"; export function SettingsView({ email, @@ -9,7 +15,15 @@ export function SettingsView({ onExport, onErase, onDone, + onSetup, + setupUnavailable = false, + studyOwner = { status: "unavailable" }, + onStudyGuardReady, }: { + studyOwner?: StudySettingsOwner; + onStudyGuardReady?: (guard: SettingsLeaveGuard | null) => void; + onSetup?: () => void; + setupUnavailable?: boolean; email: string; preferences: ReturnType; onSignOut: () => void; @@ -17,29 +31,69 @@ export function SettingsView({ onErase: () => void; onDone: () => void; }) { - const [section, setSection] = useState<"general" | "data">("general"); + const [section, setSection] = useState< + "general" | "editor" | "study" | "data" + >("general"); + const editor = useEditorPreferences(); + const studyGuard = useRef(null); + function leave(action: () => void) { + if (studyGuard.current) studyGuard.current.requestLeave(action); + else action(); + } + function registerGuard(guard: SettingsLeaveGuard | null) { + studyGuard.current = guard; + onStudyGuardReady?.(guard); + } return (
-
+
-

{section === "general" ? "General" : "Data and export"}

- +

+ {section === "general" + ? "General" + : section === "editor" + ? "Editor" + : section === "study" + ? "Study" + : "Data and export"} +

+
{section === "general" ? ( @@ -51,6 +105,21 @@ export function SettingsView({
+ {onSetup && ( +
+
+ Set up Notes + + {setupUnavailable + ? "Finish the current note-link request first." + : "Optional import and local storage setup."} + +
+ +
+ )}
Appearance @@ -96,6 +165,97 @@ export function SettingsView({
+ ) : section === "editor" ? ( + <> +
+ Font + Geist +
+
+ Text size +
+ {(["small", "medium", "large"] as const).map((size) => ( + + ))} +
+
+
+ Line width +
+ {(["narrow", "wide"] as const).map((width) => ( + + ))} +
+
+ + +
+
+ Show “Linked from” beside notes + + Planned · Opening backlinks by default isn’t available yet. + Use Links and backlinks in a note. + +
+ +
+
+ Open task and event chips in + Orbyn Planner · Planned +
+ + ) : section === "study" ? ( + ) : ( <>
diff --git a/web/src/ui/StudySettingsPanel.tsx b/web/src/ui/StudySettingsPanel.tsx new file mode 100644 index 0000000..31294b5 --- /dev/null +++ b/web/src/ui/StudySettingsPanel.tsx @@ -0,0 +1,900 @@ +import { useEffect, useId, useLayoutEffect, useRef, useState } from "react"; +import { flushSync } from "react-dom"; +import type { + CollectionsRevision, + CollectionsState, + StudyPreferences, +} from "../collections/contracts"; +import { CollectionsRevisionConflictError } from "../collections/contracts"; +import { + assertCollectionsAccess, + onCollectionsClose, + onCollectionsEpochChange, +} from "../collections/store"; +import { lockVersion, onLock, requireSession } from "../account/session"; +import { + checkClose, + registerCloseGuard, + trackOperation, +} from "../desktop/closeGuard"; +import { Dialog } from "./primitives"; +import type { + SettingsLeaveGuard, + StudySettingsOwner, +} from "./studySettingsTypes"; +import "../styles/study-settings.css"; +type Values = Pick< + StudyPreferences, + | "newPerDay" + | "reviewsPerDay" + | "dayStartHour" + | "showNextReview" + | "showStreak" +>; +type Draft = Omit & { + newPerDay: string; + reviewsPerDay: string; +}; +type Key = keyof Values; +type Baseline = { + token: CollectionsRevision; + values: Values; + timezone: string; +}; +type Attempt = { + token: CollectionsRevision; + values: Values; + keys: readonly Key[]; + draftVersion: number; +}; +type Phase = + | "loading" + | "load-error" + | "ready" + | "saving" + | "checking" + | "failed" + | "conflict" + | "closed"; +const keys: readonly Key[] = [ + "newPerDay", + "reviewsPerDay", + "dayStartHour", + "showNextReview", + "showStreak", +]; +const values = (p: StudyPreferences): Values => ({ + newPerDay: p.newPerDay, + reviewsPerDay: p.reviewsPerDay, + dayStartHour: p.dayStartHour, + showNextReview: p.showNextReview, + showStreak: p.showStreak, +}); +const draftOf = (v: Values): Draft => ({ + ...v, + newPerDay: String(v.newPerDay), + reviewsPerDay: String(v.reviewsPerDay), +}); +function dirtyKeys(draft: Draft, base: Baseline): Key[] { + const saved = draftOf(base.values); + return keys.filter((key) => draft[key] !== saved[key]); +} +function validate(draft: Draft) { + const errors: Partial> = {}; + for (const key of ["newPerDay", "reviewsPerDay"] as const) { + const raw = draft[key].trim(), + max = key === "newPerDay" ? 10000 : 20000; + if (!raw) errors[key] = "Enter a daily limit."; + else if ( + !/^\d+$/.test(raw) || + !Number.isSafeInteger(Number(raw)) || + Number(raw) > max + ) + errors[key] = + `Use digits only, from 0 to ${max.toLocaleString("en-US")}.`; + } + return { + errors, + values: { + ...draft, + newPerDay: Number(draft.newPerDay.trim()), + reviewsPerDay: Number(draft.reviewsPerDay.trim()), + } as Values, + }; +} +function patch(attempt: Attempt): Partial { + const out: Partial = {}; + if (attempt.keys.includes("newPerDay")) + out.newPerDay = attempt.values.newPerDay; + if (attempt.keys.includes("reviewsPerDay")) + out.reviewsPerDay = attempt.values.reviewsPerDay; + if (attempt.keys.includes("dayStartHour")) + out.dayStartHour = attempt.values.dayStartHour; + if (attempt.keys.includes("showNextReview")) + out.showNextReview = attempt.values.showNextReview; + if (attempt.keys.includes("showStreak")) + out.showStreak = attempt.values.showStreak; + return out; +} +export function StudySettingsPanel({ + owner, + onGuardReady, +}: { + owner: StudySettingsOwner; + onGuardReady?: (guard: SettingsLeaveGuard | null) => void; +}) { + if (owner.status === "ready") + return ; + return ( +
+

+ {owner.status === "loading" + ? "Loading Study settings…" + : owner.status === "error" + ? "Study settings couldn’t load. Try again." + : "Study settings aren’t available."} +

+ {owner.status === "error" && ( + + )} +
+ ); +} +function LoadedStudySettings({ + owner, + onGuardReady, +}: { + owner: Extract; + onGuardReady?: (guard: SettingsLeaveGuard | null) => void; +}) { + const { store, identity } = owner, + id = useId(); + const [baseline, setBaseline] = useState(null), + [draft, setDraft] = useState(null), + [phase, setPhase] = useState("loading"), + [notice, setNotice] = useState(""), + [errors, setErrors] = useState>>({}), + [busyState, setBusyState] = useState(false), + [confirm, setConfirm] = useState<"discard" | "leave" | null>(null); + const baseRef = useRef(null), + draftRef = useRef(null), + dirtyRef = useRef(false), + working = useRef(false), + attemptRef = useRef(null), + generation = useRef(0), + readSequence = useRef(0), + draftVersion = useRef(0), + alive = useRef(false), + openedVersion = useRef(0), + refreshRequested = useRef(false), + continuation = useRef<(() => void) | null>(null), + leaveTicket = useRef(0), + registration = useRef(onGuardReady), + form = useRef(null); + registration.current = onGuardReady; + function current(ticket: number) { + if ( + !alive.current || + ticket !== generation.current || + lockVersion() !== openedVersion.current + ) + throw Error("Study settings scope changed."); + requireSession(identity.accountId, identity.deviceId); + assertCollectionsAccess(store); + } + function revoke() { + generation.current++; + readSequence.current++; + draftVersion.current++; + leaveTicket.current++; + baseRef.current = null; + draftRef.current = null; + dirtyRef.current = false; + attemptRef.current = null; + working.current = false; + setBusyState(false); + continuation.current = null; + refreshRequested.current = false; + if (alive.current) + flushSync(() => { + setBaseline(null); + setDraft(null); + setErrors({}); + setNotice(""); + setConfirm(null); + setPhase("closed"); + }); + } + function apply(value: CollectionsState, keep: boolean) { + const oldDraft = draftRef.current, + oldBase = baseRef.current, + dirty = oldDraft && oldBase ? dirtyKeys(oldDraft, oldBase) : []; + const base: Baseline = { + token: { epoch: value.epoch, revision: value.revision }, + values: values(value.study.preferences), + timezone: value.study.preferences.timezone, + }; + const next = draftOf(base.values); + if (keep && oldDraft) { + for (const key of dirty) { + if (key === "newPerDay" || key === "reviewsPerDay") + next[key] = oldDraft[key]; + else if (key === "dayStartHour") + next.dayStartHour = oldDraft.dayStartHour; + else if (key === "showNextReview") + next.showNextReview = oldDraft.showNextReview; + else next.showStreak = oldDraft.showStreak; + } + } + baseRef.current = base; + draftRef.current = next; + dirtyRef.current = dirtyKeys(next, base).length > 0; + setBaseline(base); + setDraft(next); + readSequence.current++; + } + function focusError(error: Partial>) { + const key = keys.find((key) => error[key]); + const scope = generation.current, + version = draftVersion.current, + leave = leaveTicket.current; + if (key) + requestAnimationFrame(() => { + if ( + !alive.current || + scope !== generation.current || + version !== draftVersion.current || + leave !== leaveTicket.current + ) + return; + try { + current(scope); + } catch { + return; + } + form.current?.querySelector(`[name="${key}"]`)?.focus(); + }); + } + function continueIfAllowed() { + const next = continuation.current; + if (!next) return false; + continuation.current = null; + const request = leaveTicket.current; + try { + current(generation.current); + if (request !== leaveTicket.current || checkClose() !== "allowed") { + setNotice("Finish pending work before leaving Settings."); + return false; + } + next(); + return true; + } catch { + return false; + } + } + async function read( + mode: "initial" | "background" | "reload" | "discard" | "check", + ) { + if (working.current) { + if (mode === "background") refreshRequested.current = true; + return; + } + const ticket = generation.current, + sequence = ++readSequence.current, + expected = baseRef.current?.token.epoch; + working.current = true; + setBusyState(true); + if (mode !== "background") { + setPhase(mode === "initial" ? "loading" : "checking"); + if (mode !== "initial") + setNotice( + mode === "check" + ? "Checking saved settings…" + : "Loading saved settings…", + ); + if (mode !== "discard") { + continuation.current = null; + leaveTicket.current++; + } + } + try { + current(ticket); + const value = await trackOperation(() => store.read()); + current(ticket); + if (sequence !== readSequence.current) return; + if (expected && value.epoch !== expected) { + revoke(); + return; + } + if (mode === "background" && dirtyRef.current) { + if (value.revision !== baseRef.current?.token.revision) { + setPhase("conflict"); + setNotice( + "Saved data changed. Reload the latest settings to keep your edits.", + ); + } + return; + } + if (mode === "check") { + const attempt = attemptRef.current; + const matched = + !!attempt && + attempt.keys.every( + (key) => value.study.preferences[key] === attempt.values[key], + ); + const newer = + !!attempt && draftVersion.current !== attempt.draftVersion; + apply(value, !matched || newer); + attemptRef.current = null; + setPhase(matched && !newer ? "ready" : "failed"); + setNotice( + matched && !newer + ? "Saved on this device." + : "Your edits are still here. Review them, then try again.", + ); + } else { + apply(value, mode === "reload"); + attemptRef.current = null; + setErrors({}); + setPhase("ready"); + if (mode === "discard") setNotice("Changes discarded."); + else if (mode === "reload") + setNotice("Latest settings loaded. Your edits are still here."); + else if (mode === "initial") setNotice(""); + } + } catch { + if (alive.current && ticket === generation.current) { + try { + current(ticket); + } catch { + revoke(); + return; + } + setPhase( + mode === "initial" + ? "load-error" + : mode === "check" + ? "failed" + : "conflict", + ); + setNotice( + mode === "initial" + ? "Study settings couldn’t load. Try again." + : "Couldn’t load saved settings. Your edits are still here.", + ); + } + } finally { + if (alive.current && ticket === generation.current) { + working.current = false; + setBusyState(false); + const left = mode === "discard" && continueIfAllowed(); + if ( + !left && + baseRef.current && + !attemptRef.current && + refreshRequested.current + ) { + refreshRequested.current = false; + void read("background"); + } + } + } + } + async function save() { + if ( + working.current || + !baseRef.current || + !draftRef.current || + attemptRef.current + ) + return; + const checked = validate(draftRef.current); + setErrors(checked.errors); + if (Object.keys(checked.errors).length) { + focusError(checked.errors); + return; + } + const dirty = dirtyKeys(draftRef.current, baseRef.current); + if (!dirty.length) { + continueIfAllowed(); + return; + } + const attempt: Attempt = { + token: { ...baseRef.current.token }, + values: Object.freeze({ ...checked.values }), + keys: Object.freeze([...dirty]), + draftVersion: draftVersion.current, + }, + ticket = generation.current; + attemptRef.current = attempt; + working.current = true; + setBusyState(true); + readSequence.current++; + setPhase("saving"); + setNotice(""); + let committed = false; + try { + current(ticket); + const change = Object.freeze(patch(attempt)); + const result = await trackOperation(() => + store.update(attempt.token, (value) => ({ + ...value, + study: { + ...value.study, + preferences: { ...value.study.preferences, ...change }, + }, + })), + ); + current(ticket); + if (result.epoch !== attempt.token.epoch) { + revoke(); + return; + } + if (draftVersion.current !== attempt.draftVersion) return; + apply(result, false); + attemptRef.current = null; + setPhase("ready"); + setNotice("Saved on this device."); + committed = true; + } catch (error) { + if (!alive.current || ticket !== generation.current) return; + try { + current(ticket); + } catch { + revoke(); + return; + } + if (error instanceof CollectionsRevisionConflictError) { + attemptRef.current = null; + setPhase("conflict"); + setNotice( + "Saved data changed. Reload the latest settings to keep your edits.", + ); + } else { + setPhase("checking"); + setNotice("Couldn’t confirm the save. Checking saved settings…"); + try { + const value = await trackOperation(() => store.read()); + current(ticket); + if (value.epoch !== attempt.token.epoch) { + revoke(); + return; + } + const matched = attempt.keys.every( + (key) => value.study.preferences[key] === attempt.values[key], + ); + if (draftVersion.current !== attempt.draftVersion) return; + apply(value, !matched); + attemptRef.current = null; + setPhase(matched ? "ready" : "failed"); + setNotice( + matched + ? "Saved on this device." + : "Couldn’t save your changes. Your edits are still here.", + ); + committed = matched; + } catch { + if (alive.current && ticket === generation.current) { + setPhase("failed"); + setNotice("Couldn’t confirm the save. Your edits are still here."); + } + } + } + } finally { + if (alive.current && ticket === generation.current) { + working.current = false; + setBusyState(false); + if (!committed) { + continuation.current = null; + leaveTicket.current++; + } + const left = committed && continueIfAllowed(); + if (!left && !attemptRef.current && refreshRequested.current) { + refreshRequested.current = false; + void read("background"); + } + } + } + } + function canClose() { + if (working.current || dirtyRef.current || attemptRef.current) { + continuation.current = null; + leaveTicket.current++; + setNotice( + working.current + ? "Study settings are still working. Wait before closing." + : "Save or cancel your Study changes before closing.", + ); + return false; + } + return true; + } + const guard = useRef({ + canClose: () => true, + requestLeave: () => {}, + }); + guard.current.canClose = canClose; + guard.current.requestLeave = (next) => { + if (working.current) { + continuation.current = null; + leaveTicket.current++; + setNotice("Study settings are still working. Wait before leaving."); + return; + } + if (!dirtyRef.current && !attemptRef.current) { + next(); + return; + } + leaveTicket.current++; + continuation.current = next; + setConfirm("leave"); + }; + useLayoutEffect(() => { + alive.current = true; + setBaseline(null); + setDraft(null); + setErrors({}); + setNotice(""); + setConfirm(null); + setPhase("loading"); + openedVersion.current = lockVersion(); + const ticket = ++generation.current; + let stopEpoch: (() => void) | undefined, + stopClose: (() => void) | undefined, + stopLock: (() => void) | undefined, + stopSubscribe: (() => void) | undefined; + const stopGuard = registerCloseGuard(() => guard.current.canClose()); + registration.current?.(guard.current); + try { + current(ticket); + stopEpoch = onCollectionsEpochChange(store, revoke); + stopClose = onCollectionsClose(store, revoke); + stopLock = onLock(revoke); + stopSubscribe = store.subscribe(() => { + if (working.current || attemptRef.current) + refreshRequested.current = true; + else void read("background"); + }); + void read("initial"); + } catch { + revoke(); + } + const unload = (event: BeforeUnloadEvent) => { + if (working.current || dirtyRef.current || attemptRef.current) + event.preventDefault(); + }; + window.addEventListener("beforeunload", unload); + return () => { + alive.current = false; + generation.current++; + readSequence.current++; + draftRef.current = null; + baseRef.current = null; + attemptRef.current = null; + dirtyRef.current = false; + working.current = false; + continuation.current = null; + leaveTicket.current++; + stopEpoch?.(); + stopClose?.(); + stopLock?.(); + stopSubscribe?.(); + stopGuard(); + registration.current?.(null); + window.removeEventListener("beforeunload", unload); + }; + }, [store, identity.accountId, identity.deviceId, identity.email]); + function edit(key: K, value: Draft[K]) { + if (working.current || !draftRef.current || !baseRef.current) return; + continuation.current = null; + leaveTicket.current++; + const next = { ...draftRef.current, [key]: value }; + draftRef.current = next; + draftVersion.current++; + dirtyRef.current = dirtyKeys(next, baseRef.current).length > 0; + setDraft(next); + setNotice(""); + } + function keepEditing() { + continuation.current = null; + leaveTicket.current++; + setConfirm(null); + } + const busy = + busyState || + phase === "saving" || + phase === "checking" || + phase === "loading", + dirty = draft && baseline ? dirtyKeys(draft, baseline) : []; + if (phase === "closed") + return ( +
+

+ Study settings are no longer available. Reopen them before making + changes. +

+ +
+ ); + if (!draft || !baseline) + return ( +
+

+ {phase === "load-error" + ? "Study settings couldn’t load. Try again." + : "Loading Study settings…"} +

+ {phase === "load-error" && ( + + )} +
+ ); + return ( +
+
{ + event.preventDefault(); + if (phase !== "conflict") void save(); + }} + noValidate + > +

+ Limits apply when you start a session. Cards already queued stay + available. +

+

+ Repeat reviews of cards studied today and Study more can exceed these + limits. +

+ {(["newPerDay", "reviewsPerDay"] as const).map((key) => ( +
+ + edit(key, event.target.value)} + onBlur={() => { + const current = draftRef.current; + if (current) + setErrors((value) => ({ + ...value, + [key]: validate(current).errors[key], + })); + }} + /> +
+ ))} +
+ + +
+ {(["showNextReview", "showStreak"] as const).map((key) => ( + + ))} + {notice && ( +

+ {notice} +

+ )} + {phase === "conflict" && ( + + )} + {!!attemptRef.current && phase === "failed" && ( + + )} +
+ + {!busy && dirty.length ? "Unsaved changes" : ""} + + + +
+
+
+ {[ + ["Daily reminder", "Reminders aren’t available in this beta."], + [ + "Add a daily Study task to Planner", + "Planner integration isn’t available.", + ], + [ + "Suggest cards when I finish a note", + "Card suggestions aren’t available.", + ], + ["Let my agent make cards", "Agent setup isn’t available."], + ].map(([label, help]) => ( +
+
+ {label} + Planned · {help} +
+ Planned +
+ ))} +
+ {confirm && ( + +

+ {confirm === "leave" + ? "Save or discard your Study changes before leaving." + : "We’ll load the settings currently saved on this device."} +

+
+ + + {confirm === "leave" && ( + + )} +
+
+ )} +
+ ); +} diff --git a/web/src/ui/studySettingsTypes.ts b/web/src/ui/studySettingsTypes.ts new file mode 100644 index 0000000..caf537d --- /dev/null +++ b/web/src/ui/studySettingsTypes.ts @@ -0,0 +1,16 @@ +import type { AccountSession } from "../domain/contracts"; +import type { CollectionsStore } from "../collections/contracts"; +export type StudySettingsOwner = + | { status: "loading" } + | { status: "error"; retry: () => void } + | { status: "unavailable" } + | { + status: "ready"; + store: CollectionsStore; + identity: Pick; + retry: () => void; + }; +export interface SettingsLeaveGuard { + canClose(): boolean; + requestLeave(continueLeave: () => void): void; +} diff --git a/web/tests/ui/editor-preferences.test.tsx b/web/tests/ui/editor-preferences.test.tsx new file mode 100644 index 0000000..1a0171b --- /dev/null +++ b/web/tests/ui/editor-preferences.test.tsx @@ -0,0 +1,206 @@ +import { act, fireEvent, render, screen, within } from "@testing-library/react"; +import { beforeEach, expect, it, vi } from "vitest"; +import { SettingsView } from "../../src/ui/SettingsView"; +import { RichEditor } from "../../src/ui/RichEditor"; +import { usePreferences } from "../../src/ui/primitives"; +import { TextSelection } from "@tiptap/pm/state"; +import type { Editor } from "@tiptap/react"; +import type { NoteDocument } from "../../src/domain/contracts"; +const live = vi.hoisted(() => ({ editor: null as Editor | null })); +vi.mock("@tiptap/react", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + useEditor: ( + options: import("@tiptap/react").UseEditorOptions, + dependencies?: import("react").DependencyList, + ) => { + const editor = actual.useEditor(options, dependencies); + live.editor = editor; + return editor; + }, + }; +}); +function Fixture({ + readOnly = false, + document = { type: "doc", content: [{ type: "paragraph", content: [] }] }, + onChange = () => {}, +}: { + readOnly?: boolean; + document?: NoteDocument; + onChange?: (value: NoteDocument) => void; +}) { + const preferences = usePreferences(); + return ( + <> + {}} + onExport={() => {}} + onErase={() => {}} + onDone={() => {}} + /> + + + ); +} +beforeEach(() => { + localStorage.clear(); + window.dispatchEvent(new StorageEvent("storage", { key: null })); + vi.stubGlobal("matchMedia", () => ({ + matches: false, + addEventListener: vi.fn(), + removeEventListener: vi.fn(), + })); +}); +it.each(["markdown", "readonly"])( + "rejects queued IME formatting after %s changes", + async (mode) => { + const document: NoteDocument = { + type: "doc", + content: [ + { type: "paragraph", content: [{ type: "text", text: "## " }] }, + ], + }; + const change = vi.fn(); + const mounted = render(); + const box = await screen.findByRole("textbox", { name: "Journal entry" }); + const view = live.editor!.view; + vi.useFakeTimers({ toFake: ["setTimeout", "clearTimeout"] }); + try { + act(() => + view.dispatch( + view.state.tr.setSelection(TextSelection.atEnd(view.state.doc)), + ), + ); + fireEvent.compositionEnd(box); + if (mode === "markdown") { + fireEvent.click(screen.getByRole("button", { name: /^Editor$/ })); + fireEvent.click( + screen.getByRole("checkbox", { name: /Turn Markdown/ }), + ); + } else + mounted.rerender( + , + ); + act(() => vi.runOnlyPendingTimers()); + expect(box.querySelector("h2")).toBeNull(); + expect(box.textContent).toBe("## "); + expect(change).not.toHaveBeenCalled(); + } finally { + mounted.unmount(); + vi.useRealTimers(); + } + }, +); +it("applies spelling/size/width across an already mounted editor and persists across remount", async () => { + const mounted = render(); + fireEvent.click(screen.getByRole("button", { name: /^Editor$/ })); + fireEvent.click( + within(screen.getByRole("group", { name: "Text size" })).getByRole( + "button", + { name: "Large" }, + ), + ); + fireEvent.click( + within(screen.getByRole("group", { name: "Line width" })).getByRole( + "button", + { name: "Wide" }, + ), + ); + fireEvent.click(screen.getByRole("checkbox", { name: "Check spelling" })); + expect( + await screen.findByRole("textbox", { name: "Journal entry" }), + ).toHaveAttribute("spellcheck", "false"); + expect( + document.documentElement.style.getPropertyValue("--notes-editor-size"), + ).toBe("16px"); + expect( + document.documentElement.style.getPropertyValue("--notes-editor-width"), + ).toBe("800px"); + mounted.unmount(); + render(); + fireEvent.click(screen.getByRole("button", { name: /^Editor$/ })); + expect( + within(screen.getByRole("group", { name: "Text size" })).getByRole( + "button", + { name: "Large" }, + ), + ).toHaveAttribute("aria-pressed", "true"); + expect( + screen.getByRole("checkbox", { name: "Check spelling" }), + ).not.toBeChecked(); +}); +it("uses safe defaults for corrupt persisted preferences and updates on cross-tab storage changes", async () => { + localStorage.setItem("notes.editor.v1", '{"textSize":"999px"}'); + render(); + expect( + await screen.findByRole("textbox", { name: "Journal entry" }), + ).toHaveAttribute("spellcheck", "true"); + await act(async () => { + localStorage.setItem( + "notes.editor.v1", + JSON.stringify({ + textSize: "small", + lineWidth: "wide", + markdown: false, + spellcheck: false, + }), + ); + window.dispatchEvent( + new StorageEvent("storage", { key: "notes.editor.v1" }), + ); + }); + expect( + screen.getByRole("textbox", { name: "Journal entry" }), + ).toHaveAttribute("spellcheck", "false"); + expect( + document.documentElement.style.getPropertyValue("--notes-editor-size"), + ).toBe("12px"); +}); +it("keeps a conflicted Journal readable/selectable while disabling editing and its formatting surface", async () => { + const mounted = render(); + expect( + await screen.findByRole("textbox", { name: "Journal entry" }), + ).toHaveAttribute("contenteditable", "true"); + mounted.rerender(); + expect( + screen.getByRole("textbox", { name: "Journal entry" }), + ).toHaveAttribute("contenteditable", "false"); + expect( + screen.getByRole("textbox", { name: "Journal entry" }), + ).toHaveAttribute("aria-readonly", "true"); + expect( + screen.getByRole("toolbar", { name: "Formatting", hidden: true }), + ).toHaveAttribute("inert"); +}); +it("allows controlled saved-document replacement in read-only mode without calling autosave", async () => { + const change = vi.fn(); + const mounted = render(); + await screen.findByRole("textbox", { name: "Journal entry" }); + mounted.rerender( + , + ); + expect( + screen.getByRole("textbox", { name: "Journal entry" }), + ).toHaveTextContent("Reloaded synthetic saved entry"); + expect(change).not.toHaveBeenCalled(); +}); diff --git a/web/tests/ui/study-settings.test.tsx b/web/tests/ui/study-settings.test.tsx new file mode 100644 index 0000000..1e1a37a --- /dev/null +++ b/web/tests/ui/study-settings.test.tsx @@ -0,0 +1,507 @@ +import { StrictMode } from "react"; +import { + act, + fireEvent, + render, + screen, + waitFor, + within, +} from "@testing-library/react"; +import { afterEach, beforeEach, expect, it, vi } from "vitest"; +import { StudySettingsPanel } from "../../src/ui/StudySettingsPanel"; +import { SettingsView } from "../../src/ui/SettingsView"; +import type { SettingsLeaveGuard } from "../../src/ui/studySettingsTypes"; +import type { + CollectionsState, + CollectionsStore, +} from "../../src/collections/contracts"; +import { CollectionsRevisionConflictError } from "../../src/collections/contracts"; +import { emptyStudy } from "../../src/collections/validation"; +import { checkClose, registerCloseGuard } from "../../src/desktop/closeGuard"; +const lifetime = vi.hoisted(() => ({ + version: 0, + locks: new Set<() => void>(), + owners: new WeakMap< + object, + { + closed: boolean; + close: Set<() => void>; + epoch: Set<(epoch: string) => void>; + } + >(), +})); +vi.mock("../../src/account/session", () => ({ + lockVersion: () => lifetime.version, + onLock: (fn: () => void) => { + lifetime.locks.add(fn); + return () => lifetime.locks.delete(fn); + }, + requireSession: () => {}, +})); +vi.mock("../../src/collections/store", () => ({ + assertCollectionsAccess: (store: object) => { + if (lifetime.owners.get(store)?.closed) throw Error("closed"); + }, + onCollectionsClose: (store: object, fn: () => void) => { + const owner = lifetime.owners.get(store)!; + owner.close.add(fn); + return () => owner.close.delete(fn); + }, + onCollectionsEpochChange: (store: object, fn: (epoch: string) => void) => { + const owner = lifetime.owners.get(store)!; + owner.epoch.add(fn); + return () => owner.epoch.delete(fn); + }, +})); +beforeEach(() => { + lifetime.version = 0; + lifetime.locks.clear(); + HTMLDialogElement.prototype.showModal = function () { + this.setAttribute("open", ""); + }; + HTMLDialogElement.prototype.close = function () { + this.removeAttribute("open"); + }; +}); +afterEach(() => vi.unstubAllGlobals()); +// Explicit UI-boundary adapter only. Genuine encrypted writer tests belong to the separate data owner. +function fixture() { + let value: CollectionsState = { + version: 3, + epoch: crypto.randomUUID(), + revision: 0, + study: emptyStudy("UTC"), + journal: null, + clips: { items: [], receipts: [] }, + }; + const subscribers = new Set<() => void>(), + owner = { + closed: false, + close: new Set<() => void>(), + epoch: new Set<(epoch: string) => void>(), + }; + let lose = false; + const store: CollectionsStore = { + read: vi.fn(async () => structuredClone(value)), + update: vi.fn(async (expected, change) => { + if ( + expected.epoch !== value.epoch || + expected.revision !== value.revision + ) + throw new CollectionsRevisionConflictError(structuredClone(value)); + value = { + ...change(structuredClone(value)), + revision: value.revision + 1, + }; + subscribers.forEach((fn) => fn()); + if (lose) { + lose = false; + throw Error("Response unknown"); + } + return structuredClone(value); + }), + subscribe: (fn) => { + subscribers.add(fn); + return () => { + subscribers.delete(fn); + }; + }, + persistence: () => ({ status: "ready" }), + close: () => { + owner.closed = true; + owner.close.forEach((fn) => fn()); + subscribers.clear(); + }, + }; + lifetime.owners.set(store, owner); + let guard: SettingsLeaveGuard | null = null; + const props = { + owner: { + status: "ready" as const, + store, + identity: { + accountId: "account", + deviceId: "device", + email: "synthetic-settings@example.test", + }, + retry: vi.fn(), + }, + onGuardReady: (next: SettingsLeaveGuard | null) => { + guard = next; + }, + }; + const view = render(); + return { + store, + props, + view, + value: () => structuredClone(value), + guard: () => guard, + lose: () => { + lose = true; + }, + external: (patch: Partial) => { + value = { + ...value, + revision: value.revision + 1, + study: { + ...value.study, + preferences: { ...value.study.preferences, ...patch }, + }, + }; + subscribers.forEach((fn) => fn()); + }, + erase: () => { + value = { ...value, epoch: crypto.randomUUID(), revision: 0 }; + owner.epoch.forEach((fn) => fn(value.epoch)); + subscribers.forEach((fn) => fn()); + }, + }; +} +const field = (name = "New cards per day") => + screen.getByRole("textbox", { name }); +async function loaded() { + await waitFor(() => expect(field()).toHaveValue("10")); + await waitFor(() => expect(field()).toBeEnabled()); +} +function held() { + let resolve!: (value: T) => void; + const promise = new Promise((yes) => { + resolve = yes; + }); + return { promise, resolve }; +} +it("loads actual values, supports full bounds and writes only five intended prefs", async () => { + const f = fixture(); + await loaded(); + const before = f.value(); + fireEvent.change(field(), { target: { value: "0" } }); + fireEvent.change(field("Reviews per day"), { target: { value: "20000" } }); + fireEvent.change(screen.getByRole("combobox", { name: "Day starts at" }), { + target: { value: "23" }, + }); + fireEvent.click(screen.getByRole("switch", { name: "Show streak" })); + fireEvent.click( + screen.getByRole("switch", { name: "Show next review on answer buttons" }), + ); + expect(checkClose()).toBe("draft"); + fireEvent.click(screen.getByRole("button", { name: "Save changes" })); + await screen.findByText("Saved on this device."); + await waitFor(() => expect(field()).toBeEnabled()); + expect(f.value().study.preferences).toMatchObject({ + newPerDay: 0, + reviewsPerDay: 20000, + dayStartHour: 23, + showStreak: false, + showNextReview: false, + algorithm: before.study.preferences.algorithm, + timezone: "UTC", + }); + expect(f.value().study.cards).toEqual(before.study.cards); + expect(f.value().study.events).toEqual(before.study.events); + expect(f.value().clips).toEqual(before.clips); + expect(checkClose()).toBe("allowed"); +}); +it("retains empty/fraction/overflow raw strings with errors and never clamps or writes them", async () => { + const f = fixture(); + await loaded(); + for (const raw of ["", "1.5", "10001", "999999999999999999999"]) { + fireEvent.change(field(), { target: { value: raw } }); + fireEvent.blur(field()); + fireEvent.click(screen.getByRole("button", { name: "Save changes" })); + expect(field()).toHaveValue(raw); + expect(field()).toHaveAttribute("aria-invalid", "true"); + expect(f.store.update).not.toHaveBeenCalled(); + } + fireEvent.change(field(), { target: { value: "10000" } }); + fireEvent.click(screen.getByRole("button", { name: "Save changes" })); + await screen.findByText("Saved on this device."); + expect(f.value().study.preferences.newPerDay).toBe(10000); +}); +it("keeps partial input through a failure and retries explicitly against read-confirmed state", async () => { + const f = fixture(); + await loaded(); + fireEvent.change(field(), { target: { value: "12" } }); + vi.mocked(f.store.update).mockRejectedValueOnce(Error("Quota")); + fireEvent.click(screen.getByRole("button", { name: "Save changes" })); + await screen.findByText( + "Couldn’t save your changes. Your edits are still here.", + ); + expect(field()).toHaveValue("12"); + expect(f.value().study.preferences.newPerDay).toBe(10); + expect(f.store.update).toHaveBeenCalledTimes(1); + fireEvent.click(screen.getByRole("button", { name: "Try again" })); + await screen.findByText("Saved on this device."); + expect(f.store.update).toHaveBeenCalledTimes(2); + expect(f.value().study.preferences.newPerDay).toBe(12); +}); +it("reloads a conflict with dirty keys kept and untouched booleans/current shared state preserved", async () => { + const f = fixture(); + await loaded(); + fireEvent.change(field(), { target: { value: "3" } }); + act(() => f.external({ showStreak: false, reviewsPerDay: 77 })); + await screen.findByText( + "Saved data changed. Reload the latest settings to keep your edits.", + ); + expect(field()).toHaveValue("3"); + fireEvent.click( + screen.getByRole("button", { name: "Reload latest (keep edits)" }), + ); + await screen.findByText("Latest settings loaded. Your edits are still here."); + expect(field()).toHaveValue("3"); + expect(field("Reviews per day")).toHaveValue("77"); + expect(screen.getByRole("switch", { name: "Show streak" })).not.toBeChecked(); + fireEvent.click(screen.getByRole("button", { name: "Save changes" })); + await screen.findByText("Saved on this device."); + expect(f.value().study.preferences).toMatchObject({ + newPerDay: 3, + reviewsPerDay: 77, + showStreak: false, + }); +}); +it("discloses actual saved hour and switch values after conflict reload without writing", async () => { + const f = fixture(); + await loaded(); + const day = screen.getByRole("combobox", { name: "Day starts at" }); + fireEvent.change(day, { target: { value: "2" } }); + fireEvent.click(screen.getByRole("switch", { name: "Show streak" })); + fireEvent.click( + screen.getByRole("switch", { name: "Show next review on answer buttons" }), + ); + expect(screen.getByText("Saved value: 04:00")).toBeVisible(); + act(() => + f.external({ dayStartHour: 3, showStreak: true, showNextReview: true }), + ); + await screen.findByRole("button", { name: "Reload latest (keep edits)" }); + fireEvent.click( + screen.getByRole("button", { name: "Reload latest (keep edits)" }), + ); + await screen.findByText("Latest settings loaded. Your edits are still here."); + expect(day).toHaveValue("2"); + expect(screen.getByText("Saved value: 03:00")).toBeVisible(); + expect(screen.queryByText("Saved value: 04:00")).not.toBeInTheDocument(); + expect(screen.getAllByText("Saved value: On")).toHaveLength(2); + expect(screen.getByRole("switch", { name: "Show streak" })).not.toBeChecked(); + expect( + screen.getByRole("switch", { name: "Show next review on answer buttons" }), + ).not.toBeChecked(); + expect(f.store.update).not.toHaveBeenCalled(); + expect(f.value().study.preferences.dayStartHour).toBe(3); +}); +it("confirms an unknown committed result by read without another write", async () => { + const f = fixture(); + await loaded(); + fireEvent.change(field(), { target: { value: "11" } }); + f.lose(); + fireEvent.click(screen.getByRole("button", { name: "Save changes" })); + await screen.findByText("Saved on this device."); + expect(f.store.update).toHaveBeenCalledTimes(1); + expect(f.value().study.preferences.newPerDay).toBe(11); + await waitFor(() => expect(field()).toBeEnabled()); + expect(checkClose()).toBe("allowed"); +}); +it("does not mark newer edits saved when checking an older unknown attempt", async () => { + const f = fixture(); + await loaded(); + fireEvent.change(field(), { target: { value: "11" } }); + f.lose(); + vi.mocked(f.store.read).mockRejectedValueOnce(Error("Read unavailable")); + fireEvent.click(screen.getByRole("button", { name: "Save changes" })); + await screen.findByText( + "Couldn’t confirm the save. Your edits are still here.", + ); + await waitFor(() => expect(field()).toBeEnabled()); + fireEvent.change(field(), { target: { value: "13" } }); + fireEvent.click(screen.getByRole("button", { name: "Check saved settings" })); + await screen.findByText( + "Your edits are still here. Review them, then try again.", + ); + expect(field()).toHaveValue("13"); + expect(f.value().study.preferences.newPerDay).toBe(11); + expect(screen.queryByText("Saved on this device.")).not.toBeInTheDocument(); + expect(f.store.update).toHaveBeenCalledTimes(1); + fireEvent.click(screen.getByRole("button", { name: "Try again" })); + await screen.findByText("Saved on this device."); + expect(f.value().study.preferences.newPerDay).toBe(13); +}); +it("guards same-event close, explicit local leave and confirmed discard with real reload", async () => { + const f = fixture(); + await loaded(); + fireEvent.change(field(), { target: { value: "9" } }); + expect(checkClose()).toBe("draft"); + const next = vi.fn(); + act(() => f.guard()!.requestLeave(next)); + const confirm = screen.getByRole("dialog", { name: "Unsaved Study changes" }); + fireEvent.click( + within(confirm).getByRole("button", { name: "Keep editing" }), + ); + expect(next).not.toHaveBeenCalled(); + act(() => f.guard()!.requestLeave(next)); + fireEvent.click(screen.getByRole("button", { name: "Discard and continue" })); + await waitFor(() => expect(next).toHaveBeenCalledOnce()); + expect(field()).toHaveValue("10"); + expect(f.store.update).not.toHaveBeenCalled(); +}); +it("Save and continue runs only after the actual write/own guard settles", async () => { + const f = fixture(); + await loaded(); + fireEvent.change(field(), { target: { value: "8" } }); + const next = vi.fn(); + act(() => f.guard()!.requestLeave(next)); + fireEvent.click(screen.getByRole("button", { name: "Save and continue" })); + await waitFor(() => expect(next).toHaveBeenCalledOnce()); + expect(f.value().study.preferences.newPerDay).toBe(8); +}); +it("clears direct close/erase and suppresses held save completion", async () => { + const f = fixture(); + await loaded(); + fireEvent.change(field(), { target: { value: "9" } }); + const pending = held(); + vi.mocked(f.store.update).mockReturnValueOnce(pending.promise); + fireEvent.click(screen.getByRole("button", { name: "Save changes" })); + expect(field()).toBeDisabled(); + act(() => f.store.close()); + expect( + screen.queryByRole("textbox", { name: "New cards per day" }), + ).not.toBeInTheDocument(); + await act(async () => pending.resolve(f.value())); + expect(screen.queryByText("Saved on this device.")).not.toBeInTheDocument(); + expect( + screen.getByText(/Study settings are no longer available/), + ).toBeVisible(); + f.view.unmount(); + const g = fixture(); + await loaded(); + fireEvent.change(field(), { target: { value: "7" } }); + act(g.erase); + expect( + screen.queryByRole("textbox", { name: "New cards per day" }), + ).not.toBeInTheDocument(); +}); +it("does not leak an older owner draft into replacement/StrictMode reload", async () => { + const f = fixture(); + await loaded(); + fireEvent.change(field(), { target: { value: "123" } }); + f.view.unmount(); + const g = fixture(); + g.view.unmount(); + const v = render( + + + , + ); + await loaded(); + expect(field()).toHaveValue("10"); + expect(screen.queryByText("123")).not.toBeInTheDocument(); + v.unmount(); +}); +it("drains a second notification after an already captured background read", async () => { + const f = fixture(); + await loaded(); + const pending = held(); + vi.mocked(f.store.read).mockReturnValueOnce(pending.promise); + act(() => f.external({ newPerDay: 11 })); + const captured = f.value(); + act(() => f.external({ newPerDay: 17 })); + await act(async () => pending.resolve(captured)); + await waitFor(() => expect(field()).toHaveValue("17")); + expect(f.store.read).toHaveBeenCalledTimes(3); + expect(f.store.update).not.toHaveBeenCalled(); +}); +it("does not focus a replacement owner's form from an older invalid-save frame", async () => { + const f = fixture(); + await loaded(); + const frames: FrameRequestCallback[] = []; + vi.stubGlobal("requestAnimationFrame", (callback: FrameRequestCallback) => { + frames.push(callback); + return frames.length; + }); + fireEvent.change(field(), { target: { value: "" } }); + fireEvent.click(screen.getByRole("button", { name: "Save changes" })); + const older = [...frames]; + expect(older.length).toBeGreaterThan(0); + const g = fixture(); + g.view.unmount(); + f.view.rerender(); + await loaded(); + const toggle = screen.getByRole("switch", { name: "Show streak" }); + toggle.focus(); + act(() => older.forEach((callback) => callback(1))); + expect(toggle).toHaveFocus(); + expect(field()).toHaveValue("10"); +}); +it("routes subsection and Done through the registered guard without duplicate continuation", async () => { + const f = fixture(); + f.view.unmount(); + const done = vi.fn(); + render( + , + ); + fireEvent.click(screen.getByRole("button", { name: "Study" })); + await loaded(); + fireEvent.change(field(), { target: { value: "3" } }); + fireEvent.click(screen.getByRole("button", { name: "Editor" })); + fireEvent.click(screen.getByRole("button", { name: "Keep editing" })); + expect(screen.getByRole("heading", { name: "Study" })).toBeVisible(); + fireEvent.click(screen.getByRole("button", { name: "Editor" })); + fireEvent.click(screen.getByRole("button", { name: "Discard and continue" })); + await screen.findByRole("heading", { name: "Editor" }); + expect(f.store.update).not.toHaveBeenCalled(); + fireEvent.click(screen.getByRole("button", { name: "Study" })); + await loaded(); + fireEvent.change(field(), { target: { value: "7" } }); + fireEvent.click(screen.getByRole("button", { name: "Done" })); + fireEvent.click(screen.getByRole("button", { name: "Save and continue" })); + await waitFor(() => expect(done).toHaveBeenCalledOnce()); + expect(f.value().study.preferences.newPerDay).toBe(7); +}); +it("rechecks other global guards after save and never resumes a blocked continuation", async () => { + const f = fixture(); + await loaded(); + fireEvent.change(field(), { target: { value: "5" } }); + const next = vi.fn(), + stop = registerCloseGuard(() => false); + try { + act(() => f.guard()!.requestLeave(next)); + fireEvent.click(screen.getByRole("button", { name: "Save and continue" })); + await screen.findByText("Finish pending work before leaving Settings."); + expect(f.value().study.preferences.newPerDay).toBe(5); + expect(next).not.toHaveBeenCalled(); + stop(); + await act(async () => {}); + expect(next).not.toHaveBeenCalled(); + } finally { + stop(); + } +}); +it("guards beforeunload immediately and removes the private form on account lock", async () => { + fixture(); + await loaded(); + fireEvent.change(field(), { target: { value: "4" } }); + const event = new Event("beforeunload", { cancelable: true }); + window.dispatchEvent(event); + expect(event.defaultPrevented).toBe(true); + act(() => { + lifetime.version++; + lifetime.locks.forEach((listener) => listener()); + }); + expect( + screen.queryByRole("textbox", { name: "New cards per day" }), + ).not.toBeInTheDocument(); + expect( + screen.getByText(/Study settings are no longer available/), + ).toBeVisible(); +});