diff --git a/web/src/styles/study.css b/web/src/styles/study.css new file mode 100644 index 0000000..08a6818 --- /dev/null +++ b/web/src/styles/study.css @@ -0,0 +1,649 @@ +.study-view { + min-width: 0; + min-height: 0; + position: relative; + display: flex; + flex-direction: column; + flex: 1; + background: var(--main); + color: var(--t1); + overflow: auto; + font-size: 13px; +} +.study-view:focus { + outline: none; +} +.study-heading { + display: flex; + align-items: center; + gap: 12px; + padding: 22px 28px 6px; + flex-wrap: wrap; +} +.study-heading > div:first-child { + display: flex; + align-items: baseline; + gap: 12px; + min-width: 0; + flex-wrap: wrap; +} +.study-heading h1 { + margin: 0; + font-size: 22px; + font-weight: 500; + letter-spacing: -0.015em; + overflow-wrap: anywhere; +} +.study-heading span, +.study-caption, +.study-view small { + color: var(--t3); +} +.study-actions { + display: flex; + align-items: center; + gap: 6px; + flex-wrap: wrap; +} +.study-heading > .study-actions { + margin-left: auto; +} +.study-view button { + font-size: 12.5px; +} +.study-content { + padding: 18px 28px 28px; + min-width: 0; +} +.study-stats { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 12px; + margin-bottom: 16px; +} +.study-stat { + display: flex; + flex-direction: column; + gap: 7px; + padding: 14px; + border: 1px solid var(--line); + border-radius: 12px; +} +.study-stat b { + font-size: 24px; + font-weight: 500; + font-variant-numeric: tabular-nums; + overflow-wrap: anywhere; +} +.study-table-wrap { + max-width: 100%; + overflow-x: auto; +} +.study-table { + width: 100%; + border-collapse: collapse; + table-layout: fixed; + font-size: 12.5px; +} +.study-table th { + font-size: 11px; + font-weight: 400; + color: var(--t3); + text-align: left; + height: 26px; +} +.study-table th:first-child { + width: 34%; +} +.study-table td { + border-top: 1px solid var(--line); + padding: 6px 4px; + height: 44px; + color: var(--t3); + overflow-wrap: anywhere; +} +.study-table td:first-child { + color: var(--t1); +} +.study-table .study-text-button { + padding: 0; + min-height: 30px; + background: transparent; + color: var(--t1); + text-align: left; + justify-content: flex-start; + width: 100%; + border-radius: 4px; + overflow-wrap: anywhere; +} +.study-table .study-text-button:hover { + text-decoration: underline; +} +.study-table td:last-child:has(.menu-anchor) { + width: 36px; +} +.study-source { + background: transparent; + color: var(--t3); + padding: 2px 4px; + text-align: left; +} +.study-source:hover { + color: var(--t1); + text-decoration: underline; +} +.study-card { + display: flex; + flex-direction: column; + gap: 8px; + padding: 16px; + border: 1px solid var(--line); + border-radius: 12px; + margin-top: 12px; +} +.study-card b { + font-weight: 500; +} +.study-card > span { + color: var(--t3); + font-size: 12px; +} +.study-card meter { + width: 100%; + height: 12px; + accent-color: var(--t1); +} +.study-forecast { + display: flex; + align-items: flex-end; + gap: 6px; + min-width: 0; +} +.study-forecast > div { + flex: 1; + min-width: 0; + display: flex; + flex-direction: column; + align-items: center; + justify-content: flex-end; + gap: 4px; + height: 106px; + font-size: 11px; + color: var(--t3); + font-variant-numeric: tabular-nums; +} +.study-forecast i { + display: block; + width: 100%; + max-width: 26px; + border-radius: 5px; + background: var(--t3); +} +.study-forecast > div:first-child i { + background: var(--t1); +} +.study-streak { + display: flex; + align-items: center; + gap: 8px; + margin-top: 14px; + color: var(--t3); +} +.study-streak button { + background: transparent; +} +.study-empty { + display: flex; + flex-direction: column; + align-items: center; + text-align: center; + gap: 12px; + padding: 64px 24px; +} +.study-empty h2 { + margin: 0; + font-size: 18px; + font-weight: 500; +} +.study-empty p { + margin: 0; + max-width: 420px; + color: var(--t3); + line-height: 1.6; +} +.study-banner { + padding: 10px 14px; + margin: 12px 28px 0; + background: var(--raise); + border-radius: 9px; + font-size: 12px; +} +.study-banner button { + margin-left: 8px; +} +.study-skeleton { + padding: 28px; + display: flex; + flex-direction: column; + gap: 14px; +} +.study-skeleton div { + width: 100%; + height: 40px; + background: var(--raise); + border-radius: 9px; +} +.study-skeleton div:first-child { + width: 60%; +} +.study-filters { + display: flex; + gap: 6px; + flex-wrap: wrap; + margin-bottom: 12px; +} +.study-filters button { + border-radius: 999px; + min-height: 28px; + padding: 3px 10px; +} +.study-review-heading { + display: flex; + gap: 12px; + align-items: center; + padding: 16px 24px; + color: var(--t3); + font-size: 12px; +} +.study-review-heading > span:not(.study-review-track) { + white-space: nowrap; + font-variant-numeric: tabular-nums; +} +.study-review-track { + flex: 1; + height: 4px; + background: var(--raise); + overflow: hidden; + border-radius: 2px; +} +.study-review-track i { + height: 100%; + background: var(--t1); + display: block; +} +.study-review-heading button { + width: 28px; + height: 28px; + min-height: 28px; + padding: 0; + background: transparent; +} +.study-review-body { + flex: 1; + display: grid; + min-width: 0; + grid-template-columns: minmax(0, 1fr); + place-items: center; + padding: 12px 24px 50px; +} +.study-review-stack { + width: min(520px, 100%); + min-width: 0; + justify-self: center; + max-width: 100%; + display: flex; + flex-direction: column; + align-items: center; + gap: 14px; +} +.study-flash { + box-sizing: border-box; + width: 100%; + display: flex; + flex-direction: column; + align-items: center; + gap: 14px; + padding: 32px; + border-radius: 16px; + background: var(--raise); + text-align: center; + animation: study-answer-in 150ms cubic-bezier(0.2, 0.8, 0.2, 1); + overflow-wrap: anywhere; +} +.study-question { + color: var(--t1); + font-size: 20px; + font-weight: 500; + white-space: pre-wrap; +} +.study-answer { + border-top: 1px solid var(--line2); + padding-top: 14px; + width: 100%; + font-size: 14px; + color: var(--t2); + white-space: pre-wrap; +} +.study-flash > .primary { + margin-top: 14px; +} +.study-grades { + display: grid; + grid-template-columns: repeat(4, minmax(0, 1fr)); + gap: 8px; + width: 100%; +} +.study-grades button { + display: flex; + flex-direction: column; + align-items: center; + gap: 2px; + padding: 10px; + background: var(--main); + box-shadow: 0 0 0 1px var(--line); + border-radius: 10px; + color: var(--t1); + transition: + background-color 150ms cubic-bezier(0.4, 0, 0.2, 1), + transform 150ms cubic-bezier(0.4, 0, 0.2, 1); +} +.study-grades button:hover { + background: var(--raise); +} +.study-grades button:active:not(:disabled) { + transform: scale(0.97); + background: var(--sel); +} +.study-grades small { + font-size: 11px; +} +.study-grades kbd, +.study-toast kbd { + font-family: inherit; + font-size: 10px; + color: var(--t3); +} +.study-caption { + font-size: 11.5px; + line-height: 1.5; +} +.study-review-stack > .study-caption { + margin: 0; + text-align: center; +} +.study-done { + display: flex; + flex-direction: column; + gap: 14px; + align-items: center; + text-align: center; + max-width: 520px; +} +.study-done h1 { + font-size: 22px; + font-weight: 500; + margin: 0; +} +.study-done p { + margin: 0; + color: var(--t3); +} +.study-toast { + display: flex; + align-items: center; + gap: 12px; + flex-wrap: wrap; + align-self: flex-end; + margin: 0 18px 18px; + padding: 10px 14px; + background: var(--main); + box-shadow: + 0 10px 30px rgba(0, 0, 0, 0.14), + 0 0 0 1px var(--line2); + border-radius: 12px; + font-size: 12.5px; + animation: study-toast-in 200ms cubic-bezier(0.2, 0.8, 0.2, 1); +} +:root[data-theme="dark"] .study-toast, +:root[data-theme="dim"] .study-toast { + box-shadow: + 0 10px 30px rgba(0, 0, 0, 0.6), + 0 0 0 1px var(--line2); +} +.study-form { + display: flex; + flex-direction: column; + gap: 14px; +} +.study-form textarea { + min-height: 96px; + resize: vertical; +} +.study-types { + display: flex; + gap: 6px; + flex-wrap: wrap; +} +.study-form .study-actions { + justify-content: flex-end; +} +.study-card-dialog { + width: min(560px, calc(100% - 32px)); +} +.study-blank-fields { + display: flex; + align-items: flex-end; + gap: 8px; +} +.study-blank-fields label { + flex: 1; + min-width: 0; +} +.study-sr { + position: absolute; + width: 1px; + height: 1px; + padding: 0; + overflow: hidden; + clip-path: inset(50%); + white-space: nowrap; +} +@keyframes study-answer-in { + from { + opacity: 0; + transform: var(--study-enter-transform, scale(0.98)); + } + to { + opacity: 1; + transform: none; + } +} +@keyframes study-toast-in { + from { + opacity: 0; + transform: translateY(12px); + } + to { + opacity: 1; + transform: none; + } +} +@keyframes study-fade { + from { + opacity: 0; + } + to { + opacity: 1; + } +} +@media (max-width: 700px) { + .study-heading { + padding: 20px 20px 6px; + } + .study-heading > .study-actions { + margin-left: 0; + width: 100%; + } + .study-content { + padding: 14px 20px 24px; + } + .study-stats { + gap: 8px; + } + .study-stat { + padding: 10px; + } + .study-stat b { + font-size: 20px; + } + .study-table th, + .study-table td { + font-size: 11px; + } + .study-table th:first-child { + width: 35%; + } + .study-flash { + padding: 28px 20px; + } + .study-review-body { + padding: 12px 16px 32px; + } + .study-grades { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } + .study-forecast { + gap: 4px; + } + .study-banner { + margin-left: 20px; + margin-right: 20px; + } + .study-toast { + margin-left: 16px; + margin-right: 16px; + } +} +@media (prefers-reduced-motion: reduce) { + :root:not([data-motion="on"]) .study-flash, + :root:not([data-motion="on"]) .study-toast { + animation: study-fade 150ms !important; + } + :root:not([data-motion="on"]) .study-grades button:active { + transform: none; + } +} +:root[data-motion="reduced"] .study-flash, +:root[data-motion="reduced"] .study-toast { + animation: study-fade 150ms !important; +} +:root[data-motion="reduced"] .study-grades button:active { + transform: none; +} + +/* Explicit per-phase tokens override the global reduced animation reset. */ +.study-view .study-flash[data-study-motion] { + animation: none !important; +} +.study-view .study-flash[data-study-motion="answer-enter"] { + animation: study-answer-in 150ms cubic-bezier(0.2, 0.8, 0.2, 1) both !important; +} +.study-view .study-flash[data-study-motion="next-enter"] { + animation: study-next-in 200ms cubic-bezier(0.2, 0.8, 0.2, 1) both !important; +} +.study-view .study-flash[data-study-motion="reveal-exit"] { + animation: study-reveal-out 105ms cubic-bezier(0.2, 0.8, 0.2, 1) both !important; +} +.study-view .study-flash[data-study-motion="confirmed-exit"] { + animation: study-card-out 140ms cubic-bezier(0.2, 0.8, 0.2, 1) both !important; +} +.study-view .study-toast[data-exiting] { + animation: study-toast-out 140ms cubic-bezier(0.2, 0.8, 0.2, 1) both !important; +} +:root .study-view.study-content-enter { + animation: study-fade 150ms cubic-bezier(0.2, 0.8, 0.2, 1) both !important; + position: relative; +} +:root .study-view .study-loader-exit { + position: absolute; + inset: 60px 0 0; + background: var(--main); + pointer-events: none; + animation: study-loader-out 150ms cubic-bezier(0.2, 0.8, 0.2, 1) both !important; + z-index: 1; +} +.study-loading-ring { + display: block; + width: 18px; + height: 18px; + border: 2px solid var(--line2); + border-top-color: var(--t2); + border-radius: 50%; + animation: study-ring 900ms linear infinite; +} +.study-done-label { + animation: study-fade 150ms cubic-bezier(0.2, 0.8, 0.2, 1) both; +} +@keyframes study-next-in { + from { + opacity: 0; + transform: var(--study-enter-transform, scale(0.98)); + } + to { + opacity: 1; + transform: none; + } +} +@keyframes study-reveal-out { + from { + opacity: 1; + } + to { + opacity: 0; + } +} +@keyframes study-card-out { + from { + opacity: 1; + } + to { + opacity: 0; + } +} +@keyframes study-toast-out { + from { + opacity: 1; + } + to { + opacity: 0; + } +} +@keyframes study-loader-out { + from { + opacity: 1; + } + to { + opacity: 0; + } +} +@keyframes study-ring { + to { + transform: rotate(360deg); + } +} +@media (prefers-reduced-motion: reduce) { + :root:not([data-motion="on"]) .study-view { + --study-enter-transform: none; + } + :root:not([data-motion="on"]) .study-loading-ring, + :root:not([data-motion="on"]) .study-done-label { + animation: none !important; + } +} +:root[data-motion="reduced"] .study-view { + --study-enter-transform: none; +} +:root[data-motion="reduced"] .study-loading-ring, +:root[data-motion="reduced"] .study-done-label { + animation: none !important; +} diff --git a/web/src/ui/CardSearchPreview.tsx b/web/src/ui/CardSearchPreview.tsx new file mode 100644 index 0000000..36ef008 --- /dev/null +++ b/web/src/ui/CardSearchPreview.tsx @@ -0,0 +1,248 @@ +import { useEffect, useRef, useState } from "react"; +import { Dialog } from "./primitives"; +import { SearchWork, type WorkEvent } from "../search/work"; +import { + buildCardContent, + readCardContentPage, + disposeCardContent, + type CardContentInput, + type CardContentReader, + type CardContentPage, + type CardContentSide, + type CardContentCursor, +} from "../study/cardContent"; + +/** Bounded read-only projection. Identity/authority remains the actual Study owner's responsibility. */ +export function CardSearchPreview({ + input, + deckTitle, + isCurrent, + scopeVersion, + onClose, + onBack, + onOpenSource, +}: { + input: CardContentInput; + deckTitle: string; + isCurrent: () => boolean; + scopeVersion: number; + onClose: () => void; + onBack: () => void; + onOpenSource?: () => void; +}) { + const [side, setSide] = useState("front"), + [pages, setPages] = useState< + Partial> + >({}); + const [busy, setBusy] = useState(false), + [paused, setPaused] = useState(false), + [error, setError] = useState(""); + const reader = useRef(null), + running = useRef(false), + work = useRef | null>(null), + generation = useRef(0), + abort = useRef(null), + alive = useRef(false); + const check = useRef(isCurrent); + check.current = isCurrent; + const valid = isCurrent(); + function assertCurrent() { + if (!alive.current || !check.current()) + throw Error("Card is no longer current."); + } + async function run(ticket: number) { + const operation = work.current; + if (!operation || running.current) return; + running.current = true; + setBusy(true); + setPaused(false); + setError(""); + try { + const result = await operation.round(abort.current?.signal); + if (ticket !== generation.current || !alive.current) return; + if (result.status === "paused") setPaused(true); + else { + setPages((value) => ({ + ...value, + [result.value.side]: result.value.page, + })); + work.current = null; + operation.close(); + } + } catch { + if (ticket === generation.current && alive.current) { + work.current = null; + setError( + "This card couldn’t be displayed. Return to search to open it again.", + ); + } + } finally { + if (ticket === generation.current && alive.current) { + running.current = false; + setBusy(false); + } + } + } + useEffect(() => { + alive.current = true; + running.current = false; + const ticket = ++generation.current; + abort.current = new AbortController(); + function* initial(): Generator< + WorkEvent, + { side: CardContentSide; page: CardContentPage }, + void + > { + const value = yield* buildCardContent(input); + reader.current = value; + const page = yield* readCardContentPage(value, "front"); + return { side: "front", page }; + } + work.current = new SearchWork( + initial(), + undefined, + undefined, + assertCurrent, + ); + void run(ticket); + return () => { + alive.current = false; + running.current = false; + generation.current++; + abort.current?.abort(); + work.current?.close(); + work.current = null; + if (reader.current) disposeCardContent(reader.current); + reader.current = null; + }; + }, [input]); + useEffect(() => { + if (!valid) { + generation.current++; + abort.current?.abort(); + work.current?.close(); + work.current = null; + if (reader.current) disposeCardContent(reader.current); + reader.current = null; + setPages({}); + setBusy(false); + setPaused(false); + } + }, [valid, scopeVersion]); + function read(side: CardContentSide, cursor?: CardContentCursor) { + if ( + running.current || + busy || + paused || + !reader.current || + !check.current() + ) + return; + const value = reader.current, + ticket = ++generation.current; + abort.current?.abort(); + abort.current = new AbortController(); + function* next(): Generator< + WorkEvent, + { side: CardContentSide; page: CardContentPage }, + void + > { + return { side, page: yield* readCardContentPage(value, side, cursor) }; + } + work.current = new SearchWork(next(), undefined, undefined, assertCurrent); + void run(ticket); + } + const page = pages[side]; + return ( + + {!valid ? ( +

+ This card changed or is no longer available. Return to search to open + it again. +

+ ) : ( + <> +

+ Deck: {deckTitle} ·{" "} + {input.type === "basic" + ? "Basic" + : input.type === "reversed" + ? input.key === "reversed:reverse" + ? "Reversed · reverse" + : "Reversed · forward" + : "Cloze"}{" "} + · Read-only +

+

{side === "front" ? "Question" : "Answer"}

+ {page && ( + <> +
{page.text || "(Empty)"}
+
+ + {page.total === 0 ? "0" : `${page.start + 1}–${page.end}`} of{" "} + {page.total} characters + + + +
+ + )} + {busy &&

Loading card content…

} + {paused && ( +
+

This card needs more time to display.

+ +
+ )} + {error &&

{error}

} + + {onOpenSource && } + + )} +
+ + +
+
+ ); +} diff --git a/web/src/ui/StudyView.tsx b/web/src/ui/StudyView.tsx new file mode 100644 index 0000000..e7c4e53 --- /dev/null +++ b/web/src/ui/StudyView.tsx @@ -0,0 +1,2278 @@ +import { useEffect, useLayoutEffect, useMemo, useRef, useState } from "react"; +import { flushSync } from "react-dom"; +import { onLock, lockVersion } from "../account/session"; +import { + assertCollectionsAccess, + onCollectionsClose, + onCollectionsEpochChange, +} from "../collections/store"; +import type { + StudySourceOpenRequest, + StudyEntryOpenRequest, +} from "./studySourceNavigation"; +import type { + CollectionsState, + CollectionsStore, + Grade, + StudyEntry, + StudyState, +} from "../collections/contracts"; +import { CollectionsRevisionConflictError } from "../collections/contracts"; +import { + createDeck, + saveEntry, + deleteEntry, + gradeCard, + undoReview, + reviewQueue, + cardText, + progress, +} from "../study/model"; +import { nextSchedule, studyDay } from "../study/scheduler"; +import { parseStudyCsv, importStudyCsv, exportStudyCsv } from "../study/csv"; +import { registerCloseGuard, trackOperation } from "../desktop/closeGuard"; +import { Dialog } from "./primitives"; +import { Menu } from "./Menu"; +import "../styles/study.css"; +import { CardSearchPreview } from "./CardSearchPreview"; +import { useStudyToastLifetime } from "./useStudyToastLifetime"; +import { + useStudyMotion, + useStudyToastExit, + useStudyColdLoading, +} from "./useStudyMotion"; +import type { StudyMotionLocator } from "./useStudyMotion"; +import type { CardContentInput } from "../study/cardContent"; +import type { + CardSearchOpenRequest, + SearchReaderResult, +} from "./searchNavigation"; + +type Session = { + id: string; + deckId?: string; + cards: string[]; + position: number; + history: { eventId: string; position: number }[]; +}; +type ReviewIntent = { + epoch: string; + operationId: string; + grade: Grade; + at: Date; + cardId: string; + sessionId: string; + position: number; + checked: boolean; +}; +type Form = + | { kind: "deck" } + | { kind: "card"; entry?: StudyEntry } + | { kind: "delete"; entry: StudyEntry } + | { kind: "import" } + | { kind: "export" }; +const grades: { grade: Grade; label: string }[] = [ + { grade: "again", label: "Again" }, + { grade: "hard", label: "Hard" }, + { grade: "good", label: "Good" }, + { grade: "easy", label: "Easy" }, +]; +const typeName = (entry: StudyEntry) => + entry.type === "cloze" + ? "Fill in the blank" + : entry.type === "reversed" + ? "Reversed" + : "Basic"; +const countText = (count: number, noun: string) => + `${count} ${noun}${count === 1 ? "" : "s"}`; +function dateText(value: string) { + return new Date(value).toLocaleDateString(undefined, { + month: "short", + day: "numeric", + }); +} +function intervalText(due: string, at: Date) { + const minutes = Math.max( + 1, + Math.round((Date.parse(due) - at.getTime()) / 60000), + ); + return minutes < 60 + ? `${minutes} min` + : minutes < 1440 + ? `${Math.round(minutes / 60)} h` + : `${Math.round(minutes / 1440)} d`; +} + +export function StudyView({ + store, + onOpenNote, + onOpenSource, + openEntry, + openCard, + onSearchReaderResult, + onReturnToSearch, + accountId, + covered = false, + onReopen, +}: { + onReopen?: () => void; + covered?: boolean; + store: CollectionsStore; + onOpenNote?: (noteId: string) => void; + onOpenSource?: (request: StudySourceOpenRequest) => void; + openEntry?: StudyEntryOpenRequest | null; + openCard?: CardSearchOpenRequest | null; + onSearchReaderResult?: SearchReaderResult; + onReturnToSearch?: () => void; + accountId?: string; +}) { + const [cardPreview, setCardPreview] = useState<{ + request: CardSearchOpenRequest; + input: CardContentInput; + deckTitle: string; + entryRevision: number; + cardRevision: number; + collectionsRevision: number; + hasSource: boolean; + } | null>(null); + const cardAdmission = useRef(0), + cardReaderResult = useRef(onSearchReaderResult); + cardReaderResult.current = onSearchReaderResult; + const [snapshot, setSnapshot] = useState(null); + const current = useRef(null); + const [unavailable, setUnavailable] = useState(false); + const revoked = useRef(false), + ownerGeneration = useRef(0), + ownerLockVersion = useRef(lockVersion()), + readOwner = useRef(-1); + const [readError, setReadError] = useState(""); + const [error, setError] = useState(""); + const [conflict, setConflict] = useState(false); + const [busy, setBusy] = useState(false); + const working = useRef(false); + const dirty = useRef(false); + const [guardNotice, setGuardNotice] = useState(""); + const [notice, setNotice] = useState(""); + const [deckId, setDeckId] = useState(null); + const [page, setPage] = useState<"decks" | "progress">("decks"); + const [filter, setFilter] = useState<"all" | "due" | "new" | "learned">( + "all", + ); + const [form, setForm] = useState
(null); + const [session, setSession] = useState(null); + const [answer, setAnswer] = useState(false); + const [answerAt, setAnswerAt] = useState(() => new Date()); + const pendingReview = useRef(null); + const [toast, setToast] = useState<{ text: string; eventId: string } | null>( + null, + ); + const effectiveCovered = covered || cardPreview !== null || form !== null; + const motion = useStudyMotion(effectiveCovered, validLocator); + const coldLoading = useStudyColdLoading( + snapshot !== null, + !!readError, + ownerGeneration.current, + ); + const eligibleToastId = + snapshot && + session && + toast && + toast.eventId === session.history.at(-1)?.eventId + ? toast.eventId + : null; + const toastExit = useStudyToastExit(eligibleToastId, (eventId) => + setToast((current) => (current?.eventId === eventId ? null : current)), + ); + const toastInteractions = useStudyToastLifetime( + snapshot && + session && + toast && + toast.eventId === session.history.at(-1)?.eventId + ? toast.eventId + : null, + toastExit.expire, + effectiveCovered, + ); + const [now, setNow] = useState(() => new Date()); + const [offline, setOffline] = useState(() => !navigator.onLine); + const sequence = useRef(0); + const mounted = useRef(false); + const surface = useRef(null); + const motionFocus = useRef(null); + function locator( + value: CollectionsState, + cardId: string, + sessionId: string, + side: StudyMotionLocator["side"], + ): StudyMotionLocator | null { + const card = value.study.cards.find((item) => item.id === cardId), + entry = + card && value.study.entries.find((item) => item.id === card.entryId); + return card && entry + ? { + epoch: value.epoch, + sessionId, + cardId, + entryId: entry.id, + cardRevision: card.revision, + entryRevision: entry.revision, + side, + } + : null; + } + function validLocator(item: StudyMotionLocator) { + const value = current.current, + card = value?.study.cards.find((card) => card.id === item.cardId), + entry = value?.study.entries.find((entry) => entry.id === item.entryId); + return ( + value?.epoch === item.epoch && + session?.id === item.sessionId && + card?.entryId === item.entryId && + card.revision === item.cardRevision && + entry?.revision === item.entryRevision + ); + } + function clearPrivate(closed: boolean, synchronous = true) { + ownerGeneration.current++; + sequence.current++; + cardAdmission.current++; + revoked.current = closed; + current.current = null; + pendingReview.current = null; + dirty.current = false; + working.current = false; + motionFocus.current = null; + const clear = () => { + motion.invalidate(); + setSnapshot(null); + setSession(null); + setAnswer(false); + setToast(null); + setForm(null); + setCardPreview(null); + setDeckId(null); + setPage("decks"); + setFilter("all"); + setError(""); + setReadError(""); + setNotice(""); + setGuardNotice(""); + setConflict(false); + setBusy(false); + setUnavailable(closed); + }; + if (synchronous) flushSync(clear); + else clear(); + } + function ownerCurrent(ticket: number) { + if ( + revoked.current || + ticket !== ownerGeneration.current || + lockVersion() !== ownerLockVersion.current || + !mounted.current + ) + return false; + try { + assertCollectionsAccess(store); + return true; + } catch { + return false; + } + } + useLayoutEffect(() => { + if (motion.locator && !validLocator(motion.locator)) motion.invalidate(); + }, [snapshot, session?.id, motion.nonce]); + useLayoutEffect(() => { + if (effectiveCovered || motion.exiting || !session) return; + const focused = document.activeElement; + if ( + motionFocus.current && + (focused === motionFocus.current || + focused === document.body || + focused === surface.current) + ) { + surface.current + ?.querySelector( + answer + ? ".study-flash:not([inert])" + : ".study-flash:not([inert]) .primary", + ) + ?.focus({ preventScroll: true }); + } + motionFocus.current = null; + }, [motion.phase, session?.position, answer, effectiveCovered]); + useEffect(() => { + if (!snapshot) return; + const time = Date.now(); + const next = snapshot.study.cards.reduce((due, card) => { + const candidate = Date.parse(card.schedule.due); + return candidate > time ? Math.min(due, candidate) : due; + }, time + 60_000); + const timer = setTimeout( + () => setNow(new Date()), + Math.max(25, next - time + 5), + ); + return () => clearTimeout(timer); + }, [snapshot?.study, now]); + + function accept(value: CollectionsState) { + if (revoked.current) return; + if (current.current && current.current.epoch !== value.epoch) { + motion.invalidate(); + setCardPreview(null); + cardAdmission.current++; + dirty.current = false; + pendingReview.current = null; + setForm(null); + setSession(null); + setAnswer(false); + setToast(null); + setDeckId(null); + setPage("decks"); + setFilter("all"); + setGuardNotice(""); + setNotice(""); + setError(""); + setConflict(false); + } else if (current.current && value.revision < current.current.revision) + return; + current.current = value; + setSnapshot(value); + setNow(new Date()); + confirmReview(value); + } + function confirmReview(value: CollectionsState) { + const intent = pendingReview.current; + if (!intent || intent.epoch !== value.epoch) return false; + const event = value.study.events.find( + (item) => item.id === intent.operationId, + ); + if ( + !event || + event.undoneAt || + !value.study.cards.some((card) => card.id === intent.cardId) + ) + return false; + if ( + event.cardId !== intent.cardId || + event.sessionId !== intent.sessionId || + event.grade !== intent.grade || + event.at !== intent.at.toISOString() + ) + return false; + pendingReview.current = null; + const outgoing = locator(value, intent.cardId, intent.sessionId, "answer"); + if (outgoing) { + motionFocus.current = document.activeElement; + motion.begin("confirmed-exit", outgoing, event.id); + } + setSession((previous) => + previous?.id === intent.sessionId + ? { + ...previous, + position: intent.position + 1, + history: [ + ...previous.history, + { eventId: intent.operationId, position: intent.position }, + ], + } + : previous, + ); + setAnswer(false); + setError(""); + setConflict(false); + setGuardNotice(""); + setToast({ + eventId: intent.operationId, + text: `Graded ${grades.find((item) => item.grade === intent.grade)!.label} · next review in ${intervalText(event.after.schedule.due, intent.at)}`, + }); + return true; + } + async function load(resetConflict = false) { + const request = ++sequence.current; + const ownerTicket = ownerGeneration.current; + if (!ownerCurrent(ownerTicket)) return; + readOwner.current = ownerTicket; + if (!current.current) setReadError(""); + try { + const value = await store.read(); + if (!ownerCurrent(ownerTicket) || request !== sequence.current) return; + accept(value); + setReadError(""); + if (resetConflict) { + if (pendingReview.current?.epoch === value.epoch) + pendingReview.current.checked = true; + setConflict(false); + setError(""); + } + } catch { + if (ownerCurrent(ownerTicket) && request === sequence.current) + setReadError("Couldn’t load your decks. Try again."); + } + } + useLayoutEffect(() => { + mounted.current = true; + ownerLockVersion.current = lockVersion(); + clearPrivate(false, false); + let stopClose: (() => void) | undefined, + stopEpoch: (() => void) | undefined, + stopLock: (() => void) | undefined, + unsubscribe: (() => void) | undefined; + try { + assertCollectionsAccess(store); + stopClose = onCollectionsClose(store, () => clearPrivate(true)); + stopEpoch = onCollectionsEpochChange(store, () => { + clearPrivate(false); + const ticket = ownerGeneration.current; + queueMicrotask(() => { + if (ownerCurrent(ticket) && readOwner.current !== ticket) void load(); + }); + }); + stopLock = onLock(() => clearPrivate(true)); + unsubscribe = store.subscribe(() => void load()); + void load(); + } catch { + clearPrivate(true, false); + } + return () => { + mounted.current = false; + sequence.current++; + motion.invalidate(); + unsubscribe?.(); + stopClose?.(); + stopEpoch?.(); + stopLock?.(); + ownerGeneration.current++; + revoked.current = true; + current.current = null; + pendingReview.current = null; + dirty.current = false; + working.current = false; + }; + }, [store, accountId]); + useEffect(() => { + if (!openEntry) return; + let active = true; + const request = ++sequence.current; + const ownerTicket = ownerGeneration.current; + void trackOperation(() => store.read()) + .then((value) => { + if ( + !active || + !ownerCurrent(ownerTicket) || + request !== sequence.current + ) + return; + accept(value); + const entry = value.study.entries.find( + (entry) => entry.id === openEntry.entryId, + ); + if (!entry) { + setError("This card is no longer available."); + return; + } + setDeckId(entry.deckId); + setPage("decks"); + setForm({ kind: "card", entry }); + }) + .catch(() => { + if (active && ownerCurrent(ownerTicket) && request === sequence.current) + setError( + "Couldn’t open this card. Your saved cards were not changed.", + ); + }); + return () => { + active = false; + }; + }, [store, openEntry?.key]); + useLayoutEffect(() => { + if (!openCard) setCardPreview(null); + }, [openCard?.requestId]); + useEffect(() => { + const ticket = ++cardAdmission.current; + if (!openCard) return; + const request = openCard; + if (dirty.current || working.current || pendingReview.current) { + cardReaderResult.current?.(request.requestId, "blocked"); + return; + } + void trackOperation(() => store.read()) + .then((value) => { + if (!mounted.current || ticket !== cardAdmission.current) return; + if (dirty.current || working.current || pendingReview.current) { + cardReaderResult.current?.(request.requestId, "blocked"); + return; + } + const target = request.target, + card = value.study.cards.find( + (card) => + card.id === target.cardId && + card.entryId === target.entryId && + card.key === target.key, + ), + entry = value.study.entries.find( + (entry) => entry.id === target.entryId, + ); + if ( + !accountId || + accountId !== target.accountId || + value.epoch !== target.collectionsEpoch || + !card || + !entry + ) { + cardReaderResult.current?.(request.requestId, "unavailable"); + return; + } + let input: CardContentInput; + if (entry.type === "basic" && card.key === "basic:forward") + input = { + type: "basic", + key: card.key, + front: entry.front, + back: entry.back, + }; + else if ( + entry.type === "reversed" && + (card.key === "reversed:forward" || card.key === "reversed:reverse") + ) + input = { + type: "reversed", + key: card.key, + front: entry.front, + back: entry.back, + }; + else if ( + entry.type === "cloze" && + card.key.startsWith("cloze:") && + entry.blanks.some((blank) => `cloze:${blank.id}` === card.key) + ) + input = { + type: "cloze", + key: card.key as `cloze:${string}`, + text: entry.text, + blanks: entry.blanks, + }; + else { + cardReaderResult.current?.(request.requestId, "unavailable"); + return; + } + accept(value); + setForm(null); + setCardPreview({ + request, + input, + deckTitle: + value.study.decks.find((deck) => deck.id === entry.deckId)?.title ?? + "Untitled deck", + entryRevision: entry.revision, + cardRevision: card.revision, + collectionsRevision: value.revision, + hasSource: !!entry.source, + }); + cardReaderResult.current?.(request.requestId, "opened"); + }) + .catch(() => { + if (mounted.current && ticket === cardAdmission.current) + cardReaderResult.current?.(request.requestId, "unavailable"); + }); + return () => { + cardAdmission.current++; + }; + }, [store, accountId, openCard?.requestId]); + useEffect(() => { + const update = () => { + setOffline(!navigator.onLine); + setNow(new Date()); + }; + window.addEventListener("online", update); + window.addEventListener("offline", update); + window.addEventListener("focus", update); + return () => { + window.removeEventListener("online", update); + window.removeEventListener("offline", update); + window.removeEventListener("focus", update); + }; + }, []); + useLayoutEffect( + () => + registerCloseGuard(() => { + if (!dirty.current && !working.current && !pendingReview.current) + return true; + setGuardNotice( + pendingReview.current + ? "This review hasn’t been confirmed. Reload to check it before leaving." + : "Your Study changes are only in this window. Save or cancel before leaving.", + ); + return false; + }), + [], + ); + useEffect(() => { + const guard = (event: BeforeUnloadEvent) => { + if (dirty.current || working.current || pendingReview.current) + event.preventDefault(); + }; + window.addEventListener("beforeunload", guard); + return () => window.removeEventListener("beforeunload", guard); + }, []); + useEffect(() => { + if (session && !form && !effectiveCovered) + surface.current?.focus({ preventScroll: true }); + }, [session?.id]); + + async function commit( + change: (state: StudyState) => StudyState, + failure: string, + ): Promise { + const expected = current.current; + const ownerTicket = ownerGeneration.current; + if (!expected || working.current || conflict || !ownerCurrent(ownerTicket)) + return null; + working.current = true; + setBusy(true); + setError(""); + try { + const value = await trackOperation(() => + store.update(expected, (state) => ({ + ...state, + study: change(state.study), + })), + ); + if ( + !ownerCurrent(ownerTicket) || + current.current?.epoch !== expected.epoch + ) + return null; + sequence.current++; + accept(value); + setGuardNotice(""); + return value; + } catch (cause) { + if ( + !ownerCurrent(ownerTicket) || + current.current?.epoch !== expected.epoch + ) + return null; + const changed = + cause instanceof CollectionsRevisionConflictError || + (typeof cause === "object" && + cause !== null && + "code" in cause && + cause.code === "collections_revision_conflict"); + setConflict(changed); + const entryChanged = + cause instanceof Error && cause.message.startsWith("entry_conflict:"); + setError( + changed + ? "Study changed in another window. Your draft is still here. Reload before trying again." + : entryChanged + ? "This card changed in another window. Your draft is still here. Copy your changes, then reopen the card." + : failure, + ); + return null; + } finally { + if (ownerCurrent(ownerTicket)) { + working.current = false; + setBusy(false); + } + } + } + function openForm(next: Form) { + setError(""); + setGuardNotice(""); + dirty.current = false; + setForm(next); + } + function closeForm() { + if (working.current) return; + dirty.current = false; + setGuardNotice(""); + setError(""); + setForm(null); + } + function markDirty() { + dirty.current = true; + } + async function addDeck(title: string) { + const ownerTicket = ownerGeneration.current; + const at = new Date(); + const value = await commit( + (state) => createDeck(state, title, at), + "Couldn’t create this deck. Your name is still here. Try again.", + ); + if (value && ownerCurrent(ownerTicket)) { + const created = value.study.decks.find( + (deck) => + !snapshot?.study.decks.some((previous) => previous.id === deck.id), + ); + closeForm(); + if (created) setDeckId(created.id); + } + } + async function persistEntry(entry: StudyEntry) { + const ownerTicket = ownerGeneration.current; + const at = new Date(); + const value = await commit( + (state) => saveEntry(state, entry, at), + "Couldn’t save this card. Your draft is still here. Try again.", + ); + if (value && ownerCurrent(ownerTicket)) closeForm(); + } + function startReview(more = false) { + const state = current.current?.study; + if (!state || working.current) return; + const at = new Date(); + const selected = deckId + ? state.cards.filter((card) => + state.entries.some( + (entry) => entry.id === card.entryId && entry.deckId === deckId, + ), + ) + : state.cards; + const queue = more + ? [...selected].sort( + (a, b) => + a.schedule.due.localeCompare(b.schedule.due) || + a.id.localeCompare(b.id), + ) + : reviewQueue(state, at, deckId ?? undefined); + setSession({ + id: crypto.randomUUID(), + deckId: deckId ?? undefined, + cards: queue.map((card) => card.id), + position: 0, + history: [], + }); + motion.enter(); + setAnswer(false); + setAnswerAt(at); + setError(""); + setToast(null); + setNow(at); + } + async function grade(grade: Grade) { + if (!session || !answer || working.current || motion.phase !== "stable") + return; + const cardId = session.cards[session.position]; + if (!cardId) return; + const existing = pendingReview.current; + if (existing && (existing.grade !== grade || !existing.checked)) return; + const intent = existing ?? { + epoch: current.current!.epoch, + operationId: crypto.randomUUID(), + grade, + at: answerAt, + cardId, + sessionId: session.id, + position: session.position, + checked: true, + }; + pendingReview.current = intent; + const value = await commit( + (state) => + gradeCard( + state, + intent.cardId, + intent.grade, + intent.sessionId, + intent.operationId, + intent.at, + ), + "Couldn’t save this review. Your answer is still here. Try again.", + ); + if (value) confirmReview(value); + if (pendingReview.current === intent) { + intent.checked = false; + setError( + "Couldn’t confirm this review. Reload to check whether it was saved.", + ); + } else if (mounted.current) setError(""); + } + async function undo() { + const last = session?.history.at(-1); + if (!last || !session || working.current || pendingReview.current) return; + motion.invalidate(); + const at = new Date(); + const ownerTicket = ownerGeneration.current; + const value = await commit( + (state) => undoReview(state, last.eventId, at), + "Couldn’t undo this review. Your saved grade was not changed. Try again.", + ); + if (!value || !ownerCurrent(ownerTicket)) return; + setSession({ + ...session, + position: last.position, + history: session.history.slice(0, -1), + }); + setAnswer(true); + setAnswerAt(new Date()); + setToast(null); + motion.enter(); + } + function keyboard(event: React.KeyboardEvent) { + if ( + !session || + form || + busy || + event.defaultPrevented || + event.repeat || + event.nativeEvent.isComposing + ) + return; + const target = event.target as HTMLElement; + if ( + target.closest( + 'input,textarea,select,button,a,[role="button"],dialog,[contenteditable="true"],[role="menu"],[role="listbox"]', + ) + ) + return; + if ( + (event.ctrlKey || event.metaKey) && + !event.shiftKey && + event.key.toLowerCase() === "z" + ) { + event.preventDefault(); + void undo(); + } else if ( + !event.ctrlKey && + !event.metaKey && + !event.altKey && + !event.shiftKey && + event.key === " " && + !motion.exiting && + !answer && + session.position < session.cards.length + ) { + event.preventDefault(); + reveal(); + } else if ( + !event.ctrlKey && + !event.metaKey && + !event.altKey && + !event.shiftKey && + answer && + motion.phase === "stable" && + /^[1-4]$/.test(event.key) + ) { + event.preventDefault(); + void grade(grades[Number(event.key) - 1]!.grade); + } + } + function reveal() { + if (answer || motion.exiting || !session || !current.current) return; + const outgoing = locator( + current.current, + session.cards[session.position]!, + session.id, + "question", + ); + if (!outgoing) return; + motionFocus.current = document.activeElement; + motion.begin("reveal-exit", outgoing); + setAnswerAt(new Date()); + setAnswer(true); + } + function toggleStreak() { + const next = !current.current?.study.preferences.showStreak; + void commit( + (state) => ({ + ...state, + preferences: { ...state.preferences, showStreak: next }, + }), + "Couldn’t save this preference. Try again.", + ); + } + + const cachedProgress = useMemo( + () => (snapshot ? progress(snapshot.study, now) : null), + [snapshot?.study, now], + ); + const cachedDue = useMemo( + () => + snapshot ? reviewQueue(snapshot.study, now, deckId ?? undefined) : [], + [snapshot?.study, now, deckId], + ); + if (unavailable) + return ( +
+

Study is no longer available.

+ {onReopen && } +
+ ); + if (!snapshot) + return ( +
+
+

Study

+
+ {readError ? ( +
+

Couldn’t load your decks.

+

Your saved cards have not been changed.

+ +
+ ) : coldLoading.loader ? ( +
+
+ ); + const state = snapshot.study; + const showStreak = state.preferences.showStreak; + const metrics = cachedProgress!; + const deck = state.decks.find((item) => item.id === deckId); + const entries = deckId + ? state.entries.filter((entry) => entry.deckId === deckId) + : state.entries; + const cards = state.cards.filter((card) => + entries.some((entry) => entry.id === card.entryId), + ); + const due = cachedDue; + const activeCard = session + ? state.cards.find((card) => card.id === session.cards[session.position]) + : undefined; + const activeEntry = activeCard + ? state.entries.find((entry) => entry.id === activeCard.entryId) + : undefined; + const reviewText = + activeCard && activeEntry ? cardText(activeEntry, activeCard.key) : null; + const outgoing = + motion.exiting && motion.locator && validLocator(motion.locator) + ? motion.locator + : null; + const shownCard = outgoing + ? state.cards.find((item) => item.id === outgoing.cardId) + : activeCard; + const shownEntry = outgoing + ? state.entries.find((item) => item.id === outgoing.entryId) + : activeEntry; + const shownAnswer = outgoing ? outgoing.side === "answer" : answer; + const shownText = + outgoing && shownCard && shownEntry + ? cardText(shownEntry, shownCard.key) + : reviewText; + const complete = session !== null && session.position >= session.cards.length; + const lastEvent = session?.history.at(-1); + const remainingDue = state.cards.filter( + (card) => + Date.parse(card.schedule.due) <= now.getTime() && + (!session?.deckId || + state.entries.some( + (entry) => + entry.id === card.entryId && entry.deckId === session.deckId, + )), + ).length; + const nextDue = cards + .filter((card) => Date.parse(card.schedule.due) > now.getTime()) + .sort((a, b) => a.schedule.due.localeCompare(b.schedule.due))[0]; + const doneForToday = + remainingDue === 0 && + (!nextDue || + studyDay( + new Date(nextDue.schedule.due), + state.preferences.timezone, + state.preferences.dayStartHour, + ) > + studyDay( + now, + state.preferences.timezone, + state.preferences.dayStartHour, + )); + + return ( +
{ + if (!cardPreview) keyboard(event); + }} + > + {coldLoading.entering && ( +
+ ); +} + +function Stat({ label, value }: { label: string; value: string | number }) { + return ( +
+ {label} + {value} +
+ ); +} +function Forecast({ + days, + title, +}: { + days: { day: string; count: number }[]; + title: string; +}) { + const max = Math.max(1, ...days.map((day) => day.count)); + return ( +
+ {title} +
+ {days.map((day, index) => ( +
+ {day.count} + + {index === 0 ? "Today" : Number(day.day.slice(-2))} +
+ ))} +
+
+ ); +} +function DeckForm({ + busy, + onDirty, + onSave, + onCancel, +}: { + busy: boolean; + onDirty: () => void; + onSave: (title: string) => void; + onCancel: () => void; +}) { + const [title, setTitle] = useState(""); + return ( + { + event.preventDefault(); + if (title.trim()) onSave(title.trim()); + }} + > + +
+ + +
+ + ); +} +function CardForm({ + entry, + deckId, + decks, + busy, + onDirty, + onSave, + onCancel, +}: { + entry?: StudyEntry; + deckId: string; + decks: StudyState["decks"]; + busy: boolean; + onDirty: () => void; + onSave: (entry: StudyEntry) => void; + onCancel: () => void; +}) { + const [type, setType] = useState(entry?.type ?? "basic"); + const [front, setFront] = useState( + entry && entry.type !== "cloze" ? entry.front : "", + ); + const [back, setBack] = useState( + entry && entry.type !== "cloze" ? entry.back : "", + ); + const [text, setText] = useState(entry?.type === "cloze" ? entry.text : ""); + const [blanks, setBlanks] = useState( + entry?.type === "cloze" ? entry.blanks : [{ id: "blank1", answer: "" }], + ); + const [selectedDeck, setSelectedDeck] = useState(entry?.deckId ?? deckId); + const [tags, setTags] = useState(entry?.tags.join(", ") ?? ""); + const [validation, setValidation] = useState(""); + const newId = useRef(crypto.randomUUID()); + function submit() { + if (type === "cloze") { + if ( + !text.trim() || + !blanks.length || + blanks.some( + (blank) => + !/^[A-Za-z0-9_-]+$/.test(blank.id) || + !blank.answer.trim() || + text.split(`{{${blank.id}}}`).length !== 2, + ) || + new Set(blanks.map((blank) => blank.id)).size !== blanks.length || + [...text.matchAll(/\{\{([^{}]+)\}\}/g)].some( + (match) => !blanks.some((blank) => blank.id === match[1]), + ) + ) { + setValidation( + "Give each blank a unique ID and answer, and use its {{ID}} once in the text.", + ); + return; + } + } else if (!front.trim() || !back.trim()) { + setValidation("Enter a front and a back for this card."); + return; + } + if (!decks.some((deck) => deck.id === selectedDeck)) { + setValidation("Choose an available deck."); + return; + } + setValidation(""); + const base = { + id: entry?.id ?? newId.current, + deckId: selectedDeck, + tags: [ + ...new Set( + tags + .split(",") + .map((tag) => tag.trim()) + .filter(Boolean), + ), + ], + source: entry?.source ?? null, + revision: entry ? entry.revision + 1 : 1, + }; + onSave( + type === "cloze" + ? { ...base, type, text, blanks } + : { ...base, type, front, back }, + ); + } + return ( +
{ + event.preventDefault(); + submit(); + }} + > +
+ {(["basic", "reversed", "cloze"] as const).map((value) => ( + + ))} +
+ {type === "cloze" ? ( + <> +