diff --git a/frontend/src/app/(shell)/notetaker/page.tsx b/frontend/src/app/(shell)/notetaker/page.tsx index e40058da..e363cc45 100644 --- a/frontend/src/app/(shell)/notetaker/page.tsx +++ b/frontend/src/app/(shell)/notetaker/page.tsx @@ -207,32 +207,95 @@ export default function NotetakerPage() { }); }, [notes, query, courseFilter, courseFor]); - // Debounced autosave for title/body/tags. - const saveTimersRef = React.useRef>(new Map()); + // Debounced autosave for title/body/tags. We keep the latest pending patch + // per note next to its timer so it can be flushed immediately on unmount, + // navigation, or when switching notes — instead of being silently dropped. + type PendingSave = { + handle: number; + patch: Partial>; + }; + const pendingSavesRef = React.useRef>(new Map()); + // Drives the editor footer so it never claims "Saved" while a write is still + // queued or in flight. + const [saveStatus, setSaveStatus] = React.useState<"saved" | "unsaved" | "saving">( + "saved", + ); + + const runSave = React.useCallback( + ( + noteId: string, + patch: Partial>, + keepalive = false, + ) => { + if (!userId) return; + setSaveStatus("saving"); + patchNote(noteId, userId, patch, { keepalive }) + .then(() => { + if (pendingSavesRef.current.size === 0) setSaveStatus("saved"); + }) + .catch((e) => console.error("Autosave failed", e)); + }, + [userId], + ); + + const flushSave = React.useCallback( + (noteId: string, keepalive = false) => { + if (!userId) return; + const pending = pendingSavesRef.current.get(noteId); + if (!pending) return; + window.clearTimeout(pending.handle); + pendingSavesRef.current.delete(noteId); + runSave(noteId, pending.patch, keepalive); + }, + [userId, runSave], + ); + + const flushAllSaves = React.useCallback( + (keepalive = false) => { + for (const noteId of Array.from(pendingSavesRef.current.keys())) { + flushSave(noteId, keepalive); + } + }, + [flushSave], + ); + const queueSave = React.useCallback( (noteId: string, patch: Partial>) => { if (!userId) return; - const timers = saveTimersRef.current; - const existing = timers.get(noteId); - if (existing) window.clearTimeout(existing); + const pending = pendingSavesRef.current; + const existing = pending.get(noteId); + const merged = { ...(existing?.patch ?? {}), ...patch }; + if (existing) window.clearTimeout(existing.handle); + setSaveStatus("unsaved"); const handle = window.setTimeout(() => { - timers.delete(noteId); - patchNote(noteId, userId, patch).catch((e) => - console.error("Autosave failed", e), - ); + pending.delete(noteId); + runSave(noteId, merged); }, 800); - timers.set(noteId, handle); + pending.set(noteId, { handle, patch: merged }); }, - [userId], + [userId, runSave], ); + // Flush pending edits before the active note changes, so switching notes + // never drops a queued save. + const prevActiveIdRef = React.useRef(activeId); + React.useEffect(() => { + const prev = prevActiveIdRef.current; + if (prev && prev !== activeId) flushSave(prev); + prevActiveIdRef.current = activeId; + }, [activeId, flushSave]); + + // Flush on unmount and on tab close / hard navigation. The pagehide flush + // uses keepalive so the request survives the unload; the unmount flush is a + // normal request that completes against the still-live page. React.useEffect(() => { - const timers = saveTimersRef.current; + const onPageHide = () => flushAllSaves(true); + window.addEventListener("pagehide", onPageHide); return () => { - timers.forEach((handle) => window.clearTimeout(handle)); - timers.clear(); + window.removeEventListener("pagehide", onPageHide); + flushAllSaves(); }; - }, []); + }, [flushAllSaves]); const updateActive = (patch: Partial) => { if (!active) return; @@ -330,6 +393,7 @@ export default function NotetakerPage() { const onGenerateQuiz = async () => { if (!active || !userId) return; + flushSave(active.id); setBusy("quiz"); try { const { concept_node_id } = await generateQuizFromNote(active.id, userId); @@ -342,6 +406,7 @@ export default function NotetakerPage() { const onSendToTutor = async () => { if (!active || !userId) return; + flushSave(active.id); setBusy("tutor"); try { const { topic, course_id } = await sendNoteToTutor(active.id, userId); @@ -549,6 +614,7 @@ export default function NotetakerPage() { setFullscreen((f) => !f)} /> @@ -864,11 +930,13 @@ function CourseFilterChip({ function NoteEditor({ note, onChange, + saveStatus, fullscreen, onToggleFullscreen, }: { note: Note; onChange: (patch: Partial) => void; + saveStatus: "saved" | "unsaved" | "saving"; fullscreen: boolean; onToggleFullscreen: () => void; }) { @@ -984,8 +1052,12 @@ function NoteEditor({ }} > - - Saved · {relTime(note.updatedAt)} + + {saveStatus === "saving" + ? "Saving…" + : saveStatus === "unsaved" + ? "Unsaved changes" + : `Saved · ${relTime(note.updatedAt)}`} {note.body.split(/\s+/).filter(Boolean).length} words diff --git a/frontend/src/components/screens/Learn.tsx b/frontend/src/components/screens/Learn.tsx index 71bc017e..8fe87a6c 100644 --- a/frontend/src/components/screens/Learn.tsx +++ b/frontend/src/components/screens/Learn.tsx @@ -95,11 +95,12 @@ function LearnInner() { const initialTopic = searchParams.get("topic") ?? ""; const initialMode = normalizeMode(searchParams.get("mode")); + const initialCourseId = searchParams.get("course") ?? ""; const [mode, setMode] = useState(initialMode); const [topic, setTopic] = useState(initialTopic); const [topicDraft, setTopicDraft] = useState(initialTopic); - const [selectedCourseId, setSelectedCourseId] = useState(""); + const [selectedCourseId, setSelectedCourseId] = useState(initialCourseId); const [sessionId, setSessionId] = useState(null); const [messages, setMessages] = useState([]); diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts index d3402877..1041807b 100644 --- a/frontend/src/lib/api.ts +++ b/frontend/src/lib/api.ts @@ -1125,10 +1125,14 @@ export const patchNote = ( noteId: string, userId: string, patch: Partial>, + // `keepalive` lets a final autosave survive a page unload (tab close / + // navigation) instead of being cancelled mid-flight. + opts?: { keepalive?: boolean }, ) => fetchJSON(`/api/notes/${noteId}`, { method: 'PATCH', body: JSON.stringify({ user_id: userId, ...patch }), + keepalive: opts?.keepalive, }); export const deleteNote = (noteId: string, userId: string) =>