import { createEditor } from './editor.js'; import { markdownToHtml } from './markdown.js'; /* * The notes app. * * One school day is one note, one lesson is one `##` heading, and the server * builds the headings from WebUntis — so opening the app during a free period * gives you the day already laid out rather than an empty box. That shape is * also what makes each lesson separately searchable afterwards, which is the * whole reason the notes are worth writing here rather than in Notes.app. * * Three rules this file exists to honour: * * - **Never lose what was typed.** Every keystroke goes to localStorage, and a * draft that is newer than the server's copy survives a dead connection, a * locked phone and a closed tab. A note taken in a lesson cannot be retaken. * - **Never silently overwrite.** Saves carry the modification time the editor * loaded; the server refuses one that would clobber a version this editor * never saw, and the banner then makes it the person's decision. * - **Say what state it is in.** "Gespeichert 14:02", "Nicht gespeichert", * "Offline — lokal gesichert". A silent editor over a flaky connection is * indistinguishable from one that is losing your work. * * What the person sees is formatted text with a toolbar; what is written to * disk is Markdown. `editor.js` is the whole of that translation — everything * here deals in Markdown strings and never touches the document. */ const AUTOSAVE_MS = 2500; const DRAFT_PREFIX = 'schulcloud-mcp/draft/'; const ui = { login: document.getElementById('login'), loginForm: document.getElementById('login-form'), password: document.getElementById('password'), loginError: document.getElementById('login-error'), app: document.getElementById('app'), tabNotes: document.getElementById('tab-notes'), tabSettings: document.getElementById('tab-settings'), viewNotes: document.getElementById('view-notes'), viewSettings: document.getElementById('view-settings'), searchForm: document.getElementById('search-form'), searchInput: document.getElementById('search-input'), railStatus: document.getElementById('rail-status'), railList: document.getElementById('rail-list'), today: document.getElementById('today'), back: document.getElementById('back'), daybar: document.querySelector('.daybar'), actions: document.querySelector('.actions'), notePreview: document.getElementById('note-preview'), notePreviewTitle: document.getElementById('note-preview-title'), notePreviewPath: document.getElementById('note-preview-path'), notePreviewBody: document.getElementById('note-preview-body'), prev: document.getElementById('prev'), next: document.getElementById('next'), dayTitle: document.getElementById('day-title'), dayDate: document.getElementById('day-date'), dayStatus: document.getElementById('day-status'), conflict: document.getElementById('day-conflict'), toolbar: document.getElementById('toolbar'), editor: document.getElementById('editor'), source: document.getElementById('source'), editorHint: document.getElementById('editor-hint'), save: document.getElementById('save'), fill: document.getElementById('fill'), lessonsHint: document.getElementById('lessons-hint'), tokenState: document.getElementById('token-state'), tokenForm: document.getElementById('token-form'), jwt: document.getElementById('jwt'), tokenResult: document.getElementById('token-result'), serverState: document.getElementById('server-state'), logout: document.getElementById('logout'), }; /** Everything about the day currently open. */ const day = { date: today(), path: '', /** The server's modification time for the loaded note, or null if there is none. */ modifiedAt: null, /** The text as the server last confirmed it, to tell "dirty" from "saved". */ saved: '', /** Headings the timetable has and the note does not. */ missing: '', dirty: false, conflicted: false, timer: 0, }; /** * The formatted editor over the two elements that hold a note. * * It owns the document and the toolbar; this file only ever asks it for * Markdown and hands it Markdown back. */ const editor = createEditor({ rich: ui.editor, source: ui.source, toolbar: ui.toolbar, onInput: markDirty, onModeChange: (mode) => { // Switching by hand is not a warning, so the automatic one goes away. hint(mode === 'source' ? 'Markdown-Ansicht. „MD" führt zurück.' : ''); }, }); function hint(message) { ui.editorHint.textContent = message; ui.editorHint.hidden = !message; } // --- plumbing ------------------------------------------------------------ async function api(path, options) { const response = await fetch(path, { credentials: 'same-origin', ...options, headers: { accept: 'application/json', ...(options && options.headers) }, }); if (response.status === 401) { showLogin(); throw new Error('unauthorized'); } let body = null; try { body = await response.json(); } catch (error) { body = null; } if (!response.ok) { const failure = new Error((body && (body.message || body.error)) || 'HTTP ' + response.status); failure.status = response.status; failure.body = body; throw failure; } return body; } function json(method, path, payload) { return api(path, { method, headers: { 'content-type': 'application/json' }, body: JSON.stringify(payload) }); } function today() { // The device's own date. The server keeps school dates in Europe/Berlin, but // the phone in the lesson is in that timezone by definition. const now = new Date(); return [now.getFullYear(), pad(now.getMonth() + 1), pad(now.getDate())].join('-'); } function pad(value) { return String(value).padStart(2, '0'); } function shiftDate(date, days) { // Noon, so a daylight-saving change cannot push the result onto the // neighbouring day. const at = new Date(date + 'T12:00:00'); at.setDate(at.getDate() + days); return [at.getFullYear(), pad(at.getMonth() + 1), pad(at.getDate())].join('-'); } function clock() { const now = new Date(); return pad(now.getHours()) + ':' + pad(now.getMinutes()); } // --- drafts: the safety net --------------------------------------------- function draftKey(date) { return DRAFT_PREFIX + date; } function saveDraft(text) { try { const value = text === undefined ? editor.getMarkdown() : text; localStorage.setItem(draftKey(day.date), JSON.stringify({ text: value, at: Date.now() })); } catch (error) { // A full or disabled localStorage must not break typing; the server copy // is still the real one. } } function readDraft(date) { try { const raw = localStorage.getItem(draftKey(date)); return raw ? JSON.parse(raw) : null; } catch (error) { return null; } } function clearDraft(date) { try { localStorage.removeItem(draftKey(date)); } catch (error) { // Nothing to do: a stale draft is only ever offered, never forced. } } // --- the day ------------------------------------------------------------- function setStatus(message, kind) { ui.dayStatus.textContent = message; ui.dayStatus.className = 'status' + (kind ? ' ' + kind : ''); } /** A school day, in the editor, with the list showing which one. */ async function openDay(date) { ui.notePreview.hidden = true; showEditor(true); await loadDay(date); } /** Where a day's note lives, which is also its id in the list. */ function dayPathFor(date) { return date.slice(0, 4) + '/' + date + '.md'; } async function loadDay(date) { // Anything unsaved goes to the draft before the view moves, or switching // days would be a way to lose a lesson. if (day.dirty) saveDraft(); window.clearTimeout(day.timer); day.date = date; day.conflicted = false; ui.conflict.hidden = true; ui.dayDate.value = date; editor.setEnabled(false); hint(''); setStatus('Wird geladen …'); let info; try { info = await api('/api/notes/day?date=' + encodeURIComponent(date)); } catch (error) { if (error.message === 'unauthorized') return; ui.dayTitle.textContent = date; // A reply with a status is the server saying no — most often that it keeps // no notes at all — and reporting that as "offline" would send someone // looking at their signal instead of at NOTES_DIR. if (error.status) { editor.setMarkdown(''); editor.setEnabled(false); setStatus(error.message, 'error'); ui.lessonsHint.textContent = ''; return; } // No status: the request never arrived. Fall back to whatever this device // has, rather than an empty editor that looks like a day with no notes. const draft = readDraft(date); editor.setEnabled(true); editor.setMarkdown(draft ? draft.text : ''); day.saved = ''; day.modifiedAt = null; day.dirty = Boolean(draft); setStatus( draft ? 'Offline — lokale Fassung, nicht gespeichert.' : 'Offline — keine Verbindung zum Server.', 'warn', ); return; } day.path = info.path; day.modifiedAt = info.modifiedAt; day.missing = info.missing || ''; ui.dayTitle.textContent = info.title; const server = info.exists ? info.text : info.skeleton; const draft = readDraft(date); // A draft only wins when it differs from what the server holds; otherwise it // is just the last save echoed back and offering it would be noise. const useDraft = draft && draft.text !== server && draft.text.trim() !== ''; editor.setEnabled(true); // The server's text first, and what the editor makes of it is the baseline. // Opening a note the editor would tidy — a table typed unevenly, `*` for // italics — must not count as an edit, or simply looking at a day would // rewrite the file. const loaded = editor.setMarkdown(server); day.saved = info.exists ? editor.getMarkdown() : ''; if (useDraft) editor.setMarkdown(draft.text); day.dirty = editor.getMarkdown() !== day.saved; // One note in a hundred: something the formatted view cannot hold without // changing it. It opens as Markdown rather than being quietly reduced. hint( !loaded.faithful ? 'Diese Notiz enthält Formatierung, die die formatierte Ansicht nicht unverändert halten kann — deshalb Markdown.' : editor.mode === 'source' ? 'Markdown-Ansicht. „MD" führt zurück.' : '', ); if (useDraft) { setStatus('Lokale, noch nicht gespeicherte Fassung wiederhergestellt.', 'warn'); } else if (info.exists) { setStatus('Gespeichert.'); } else if (info.skeleton) { setStatus('Neuer Tag — Stunden aus WebUntis eingetragen.'); } else { setStatus('Neuer Tag.'); } describeLessons(info); ui.fill.hidden = !day.missing; markOpenRow(); } function describeLessons(info) { if (info.timetable === 'off') { ui.lessonsHint.textContent = 'Ohne WebUntis-Schlüssel: Überschriften selbst anlegen.'; return; } if (info.timetable === 'unavailable') { ui.lessonsHint.textContent = 'WebUntis nicht erreichbar — Stunden fehlen.'; return; } const count = (info.lessons || []).length; ui.lessonsHint.textContent = count === 0 ? 'Kein Unterricht an diesem Tag.' : count + ' Stunde(n) laut Stundenplan.'; } function markDirty() { const text = editor.getMarkdown(); day.dirty = text !== day.saved; saveDraft(text); if (day.conflicted) return; if (day.dirty) setStatus('Nicht gespeichert …'); window.clearTimeout(day.timer); day.timer = window.setTimeout(() => void saveDay(true), AUTOSAVE_MS); } async function saveDay(automatic) { window.clearTimeout(day.timer); if (!day.dirty && automatic) return; const text = editor.getMarkdown(); setStatus('Wird gespeichert …'); try { const result = await json('PUT', '/api/notes/day', { date: day.date, text, // Absent for a note that does not exist yet: there is nothing to clash // with, and sending null would look like "I saw no version". ...(day.modifiedAt ? { expectedModifiedAt: day.modifiedAt } : {}), }); const isNew = !day.modifiedAt; day.saved = text; day.modifiedAt = result.modifiedAt; day.dirty = false; // A day that had no note until now is not in the list yet. if (isNew && !ui.searchInput.value.trim()) void loadRail(); day.conflicted = false; ui.conflict.hidden = true; clearDraft(day.date); setStatus('Gespeichert ' + clock() + '.', 'ok'); } catch (error) { if (error.message === 'unauthorized') return; if (error.status === 409) { // Stop autosaving: every further attempt would fail the same way, and // the choice of which version wins is not ours to make. day.conflicted = true; ui.conflict.hidden = false; ui.conflict.textContent = 'Diese Notiz wurde anderswo geändert, seit sie hier geöffnet wurde. ' + '„Neu laden" verwirft, was hier steht; „Trotzdem speichern" überschreibt die andere Fassung. ' + 'Deine Fassung ist lokal gesichert.'; ensureConflictButtons(); setStatus('Nicht gespeichert — Konflikt.', 'error'); return; } if (error.status === 403) { setStatus('Der Server nimmt keine Änderungen an (NOTES_READONLY).', 'error'); return; } setStatus('Nicht gespeichert — ' + error.message + '. Lokal gesichert.', 'error'); } } /** The two ways out of a conflict, added once and only when one happens. */ function ensureConflictButtons() { if (document.getElementById('conflict-reload')) return; const reload = document.createElement('button'); reload.id = 'conflict-reload'; reload.type = 'button'; reload.textContent = 'Neu laden'; reload.addEventListener('click', () => { clearDraft(day.date); void loadDay(day.date); }); const force = document.createElement('button'); force.id = 'conflict-force'; force.type = 'button'; force.textContent = 'Trotzdem speichern'; force.addEventListener('click', () => { day.modifiedAt = null; day.conflicted = false; ui.conflict.hidden = true; void saveDay(false); }); ui.conflict.append(document.createElement('br'), reload, document.createTextNode(' '), force); } // --- settings ------------------------------------------------------------ async function loadSettings() { ui.tokenState.textContent = 'Wird geladen …'; try { const info = await api('/api/token'); const budget = info.keepalive && info.keepalive.budgetSeconds; ui.tokenState.textContent = 'Noch ' + info.daysLeft + ' Tag(e) gültig' + (budget ? ', Sitzung noch ' + Math.round(budget / 60) + ' min' : '') + ' (' + info.source + ').'; ui.tokenState.className = 'status' + (info.daysLeft <= 3 ? ' warn' : ''); } catch (error) { if (error.message === 'unauthorized') return; ui.tokenState.textContent = 'Token-Status nicht lesbar: ' + error.message; ui.tokenState.className = 'status error'; } ui.serverState.replaceChildren(); try { const status = await api('/api/status'); addFact('Index', status.crawlId ? 'Stand ' + status.crawlId + ', ' + status.nodes + ' Einträge' : 'leer'); addFact('Dateien', status.files + ' (' + status.extracted + ' mit Text)'); if (status.indexer && status.indexer.running) addFact('Gerade', 'Durchlauf läuft'); } catch (error) { addFact('Index', 'nicht verfügbar'); } try { const notes = await api('/api/notes?limit=1'); addFact('Notizen', notes.count + ' · ' + notes.root + (notes.writable ? '' : ' (schreibgeschützt)')); } catch (error) { addFact('Notizen', 'nicht verfügbar'); } } function addFact(term, value) { const dt = document.createElement('dt'); dt.textContent = term; const dd = document.createElement('dd'); dd.textContent = value; ui.serverState.append(dt, dd); } // --- the note list ------------------------------------------------------- /* * The rail: every note, newest first, with the open one marked — and the * search box at the top of it, because searching your notes is a way of * finding one, not a separate place to be. * * Searching reads the **files** on the server rather than the Postgres index. * Notes reach that index only on a full crawl, so a lesson written this * morning would not be findable this morning, which is most of what anyone * searches their own notes for. The `search` tool in Claude is the other half: * it spans Schulcloud and the class register too, at the cost of being only as * fresh as the last crawl. */ const SEARCH_DEBOUNCE_MS = 350; /** Day notes live at `2026/2026-09-04.md`; anything else opens read-only. */ const DAY_NOTE = /^\d{4}\/(\d{4}-\d{2}-\d{2})\.md$/; let searchTimer = 0; let searchTerms = []; async function loadRail() { ui.railStatus.textContent = 'Wird geladen …'; try { const listing = await api('/api/notes?limit=400'); searchTerms = []; showRows(listing.notes.map(noteRow)); ui.railStatus.textContent = listing.count === 0 ? 'Noch keine Notizen. + öffnet den heutigen Tag.' : listing.count + ' Notiz(en)' + (listing.notes.length < listing.count ? ', neueste 400' : ''); } catch (error) { if (error.message === 'unauthorized') return; ui.railList.replaceChildren(); ui.railStatus.textContent = error.status ? error.message : 'Offline — die Liste braucht den Server.'; } } /** One note as a row: what it is, and the first thing written in it. */ function noteRow(note) { const lessons = note.lessons > 0 ? note.lessons + ' Stunde' + (note.lessons === 1 ? '' : 'n') : ''; const subjects = (note.subjects || []).join(' · '); return { path: note.path, date: note.date, title: note.title, // Subjects say more than the date repeated, and the preview says more // than either when a note is a single page of prose. line: subjects || lessons || note.preview || '', second: subjects && note.preview ? note.preview : '', }; } /** One search hit as a row: the lesson it matched in, and why. */ function hitRow(hit) { return { path: hit.path, date: hit.date, heading: hit.heading, title: hit.heading || hit.subject || hit.title, line: hit.date ? germanDate(hit.date) : hit.path, second: hit.snippet, mark: true, }; } function showRows(rows) { const list = document.createDocumentFragment(); for (const row of rows) { const item = document.createElement('button'); item.type = 'button'; item.className = 'row'; item.dataset.path = row.path; if (row.heading) item.dataset.heading = row.heading; const title = document.createElement('div'); title.className = 'row-title'; if (row.mark) highlight(title, row.title); else title.textContent = row.title; item.append(title); for (const [text, clamp] of [ [row.line, false], [row.second, true], ]) { if (!text) continue; const line = document.createElement('p'); line.className = 'row-line' + (clamp ? ' clamp' : ''); if (row.mark) highlight(line, text); else line.textContent = text; item.append(line); } item.addEventListener('click', () => void openRow(row)); list.append(item); } ui.railList.replaceChildren(list); markOpenRow(); } /** * The matched words marked, without building HTML from them. * * A snippet is the user's own text, and text that has been through a URL and a * JSON response is exactly what should not be handed to `innerHTML`. */ function highlight(target, text) { const terms = searchTerms.map(fold).filter((term) => term.length > 1); if (terms.length === 0) { target.textContent = text; return; } const folded = fold(text); const marks = []; for (const term of terms) { for (let at = folded.indexOf(term); at !== -1; at = folded.indexOf(term, at + term.length)) { marks.push([at, at + term.length]); } } marks.sort((a, b) => a[0] - b[0]); let cursor = 0; for (const [from, to] of marks) { if (from < cursor) continue; target.append(text.slice(cursor, from)); const mark = document.createElement('mark'); mark.textContent = text.slice(from, to); target.append(mark); cursor = to; } target.append(text.slice(cursor)); } /** Lowercase without accents — the same folding the server searches with. */ function fold(value) { return value.toLowerCase().normalize('NFD').replace(/\p{Diacritic}/gu, ''); } function germanDate(date) { const parts = date.split('-'); return parts[2] + '.' + parts[1] + '.' + parts[0]; } async function runSearch(query) { window.clearTimeout(searchTimer); const value = query.trim(); if (value.length === 0) return loadRail(); if (value.length < 2) { ui.railStatus.textContent = 'Mindestens zwei Zeichen.'; return; } ui.railStatus.textContent = 'Wird gesucht …'; try { const result = await api('/api/notes/search?q=' + encodeURIComponent(value) + '&limit=100'); searchTerms = value.split(/\s+/).filter(Boolean); showRows(result.hits.map(hitRow)); ui.railStatus.textContent = result.count === 0 ? 'Nichts gefunden — jedes Wort muss vorkommen.' : result.count + ' Treffer' + (result.count >= 100 ? ' (mehr vorhanden)' : ''); } catch (error) { if (error.message === 'unauthorized') return; ui.railList.replaceChildren(); ui.railStatus.textContent = error.status ? error.message : 'Offline — die Suche braucht den Server.'; } } /** * A row, opened. * * A school day opens in the editor, because that is where it is written. * Anything else — an imported note, a page of revision — has no day to open, so * it is shown read-only rather than forced into a day-shaped screen. */ async function openRow(row) { const day = DAY_NOTE.exec(row.path); showPane('note'); if (day) { ui.notePreview.hidden = true; showEditor(true); await loadDay(day[1]); // A search hit names a lesson, so put that lesson on screen rather than // the top of a day with six of them. if (row.heading) scrollToHeading(row.heading); return; } await showNoteReadOnly(row.path); } function scrollToHeading(heading) { const wanted = fold(heading).trim(); for (const element of ui.editor.querySelectorAll('h2')) { if (fold(element.textContent).trim() === wanted) { element.scrollIntoView({ block: 'start' }); return; } } } async function showNoteReadOnly(path) { showEditor(false); ui.notePreview.hidden = false; ui.notePreviewTitle.textContent = '…'; try { const note = await api('/api/notes?path=' + encodeURIComponent(path)); ui.notePreviewTitle.textContent = note.title; ui.notePreviewPath.textContent = note.path + ' — schreibgeschützt, weil diese Notiz kein Schultag ist.'; // Markdown from our own store, through the parser the editor trusts with // the same input: it escapes everything it did not produce itself. ui.notePreviewBody.innerHTML = markdownToHtml(note.text ?? ''); day.path = note.path; markOpenRow(); } catch (error) { if (error.message === 'unauthorized') return; ui.notePreviewTitle.textContent = 'Konnte die Notiz nicht öffnen'; ui.notePreviewPath.textContent = error.message; } } /** The day editor and everything that belongs to it, on or off. */ function showEditor(on) { for (const element of [ui.daybar, ui.dayStatus, ui.toolbar, ui.actions]) element.hidden = !on; ui.editor.hidden = !on || editor.mode !== 'rich'; ui.source.hidden = !on || editor.mode !== 'source'; // The hint belongs to whatever is loaded next; leaving it visible and empty // would cost a line of editor for nothing. if (!on) { ui.conflict.hidden = true; ui.editorHint.hidden = true; } } function showPane(pane) { ui.viewNotes.dataset.pane = pane; } /** Marks the row whose note is open, whichever list is showing. */ function markOpenRow() { for (const row of ui.railList.querySelectorAll('.row')) { row.setAttribute('aria-current', String(row.dataset.path === day.path)); } } // --- views --------------------------------------------------------------- function showLogin() { ui.app.hidden = true; ui.login.hidden = false; ui.password.focus(); } function showApp() { ui.login.hidden = true; ui.app.hidden = false; } function showTab(name) { const notes = name !== 'settings'; ui.viewNotes.hidden = !notes; ui.viewSettings.hidden = notes; ui.tabNotes.setAttribute('aria-current', notes ? 'page' : 'false'); ui.tabSettings.setAttribute('aria-current', notes ? 'false' : 'page'); if (!notes) void loadSettings(); } // --- wiring -------------------------------------------------------------- ui.loginForm.addEventListener('submit', async (event) => { event.preventDefault(); ui.loginError.textContent = ''; try { await json('POST', '/app/login', { password: ui.password.value }); ui.password.value = ''; showApp(); await loadDay(day.date); if (window.matchMedia('(min-width: 721px)').matches) showPane('note'); await loadRail(); } catch (error) { ui.loginError.textContent = error.status === 429 ? 'Zu viele Versuche. ' + error.message : 'Passwort falsch.'; } }); ui.logout.addEventListener('click', async () => { // The draft stays: logging out is not the same as discarding a lesson. await json('POST', '/app/logout', {}).catch(() => {}); showLogin(); }); ui.tabNotes.addEventListener('click', () => showTab('notes')); ui.tabSettings.addEventListener('click', () => showTab('settings')); ui.searchForm.addEventListener('submit', (event) => { event.preventDefault(); void runSearch(ui.searchInput.value); }); ui.searchInput.addEventListener('input', () => { // As you type, but not on every keystroke: each search re-reads the notes // directory on the server. window.clearTimeout(searchTimer); const value = ui.searchInput.value; searchTimer = window.setTimeout(() => void runSearch(value), SEARCH_DEBOUNCE_MS); }); ui.today.addEventListener('click', () => { // The day you are in, whether or not it has a note yet — the one thing the // list cannot show, because an empty day is not a note. void openRow({ path: dayPathFor(today()) }); }); ui.back.addEventListener('click', () => showPane('list')); ui.prev.addEventListener('click', () => void openDay(shiftDate(day.date, -1))); ui.next.addEventListener('click', () => void openDay(shiftDate(day.date, 1))); ui.dayDate.addEventListener('change', () => { if (ui.dayDate.value) void openDay(ui.dayDate.value); }); ui.save.addEventListener('click', () => void saveDay(false)); ui.fill.addEventListener('click', () => { // Appended, never merged into place: the person's own text is not something // to reorder, and a heading in the wrong order is trivial to move. editor.append(day.missing); day.missing = ''; ui.fill.hidden = true; }); // A phone locking, the app going to the background, or the tab closing: all of // them end the session without a "save" ever being pressed. document.addEventListener('visibilitychange', () => { if (document.visibilityState === 'hidden' && day.dirty) { saveDraft(); if (!day.conflicted) void saveDay(true); } }); window.addEventListener('beforeunload', (event) => { if (!day.dirty) return; saveDraft(); event.preventDefault(); event.returnValue = ''; }); ui.tokenForm.addEventListener('submit', async (event) => { event.preventDefault(); ui.tokenResult.textContent = 'Wird geprüft …'; ui.tokenResult.className = 'status'; try { const result = await json('PUT', '/api/token', { jwt: ui.jwt.value }); ui.jwt.value = ''; ui.tokenResult.textContent = result.changed ? 'Ersetzt. Noch ' + result.daysLeft + ' Tag(e) gültig.' + (result.persisted ? '' : ' (Nicht dauerhaft gespeichert.)') : 'Das ist der Token, der bereits benutzt wird.'; ui.tokenResult.className = 'status ok'; void loadSettings(); } catch (error) { if (error.message === 'unauthorized') return; ui.tokenResult.textContent = error.message; ui.tokenResult.className = 'status error'; } }); // --- start --------------------------------------------------------------- void (async () => { try { const session = await api('/app/session'); if (!session.authenticated) { showLogin(); return; } } catch (error) { showLogin(); return; } showApp(); await loadDay(day.date); // Wide enough for both panes: the day is already open beside the list. // Narrow: the list comes first, the way a notes app opens. if (window.matchMedia('(min-width: 721px)').matches) showPane('note'); await loadRail(); })();