// ============================================================
// NOTES — per-user personal scratchpad under Clinical Tools.
// Rich-text via the app-wide Tiptap bundle (window.Tiptap).
// CRUD against /api/notes (auth + server-side encryption).
//
// Three panes — list / reader / editor. After opening or saving
// a note, the reader shows a clean, read-only rendering; tap
// Edit to switch to the Tiptap editor. Autosaves during edit
// (1.2s quiet period) so you never lose progress if you close
// the tab or the browser crashes.
//
// On mobile (<900px) only one pane is visible at a time — the
// layout's data-view attribute controls which one. Desktop
// always shows the sidebar + the right pane together.
// ============================================================
(function() {
var _inited = false;
var _notes = []; // active notes (deleted_at IS NULL)
var _trash = []; // trashed notes (deleted_at IS NOT NULL)
var _pane = 'active'; // 'active' | 'trash' — drives the list view
var _activeId = null; // currently open note id (reader OR editor)
var _editor = null; // Tiptap instance
var _search = '';
// Autosave state
var _dirty = false;
var _autosaveTimer = null;
var _autosaveInFlight = false;
var _pendingAfterInflight = false;
var AUTOSAVE_DEBOUNCE = 1200;
// Voice recording state
var _recorder = null;
var _recTimer = null;
var _recPaused = false;
var _recActive = false;
var _statusTimer = null;
// Wait for the Notes tab to be activated — the component HTML is
// lazy-loaded, so elements don't exist until the user clicks the tab.
document.addEventListener('tabChanged', function(e) {
if (e.detail.tab === 'notes') {
if (_inited) { refreshList(); return; }
_inited = true;
init();
return;
}
// Leaving the Notes tab — cancel any in-flight recording so the
// mic doesn't stay live + the recorder doesn't keep buffering.
if (_recActive) stopRecording(true);
flushAutosave();
});
function $(id) { return document.getElementById(id); }
function init() {
var newBtn = $('btn-notes-new');
var searchEl = $('notes-search');
var titleEl = $('note-title');
var listEl = $('notes-list');
if (!newBtn || !listEl) return;
// List + sidebar
newBtn.addEventListener('click', function() { startCreate(); });
searchEl.addEventListener('input', function() {
_search = searchEl.value.trim().toLowerCase();
renderList();
});
listEl.addEventListener('click', function(e) {
// Trash-view actions get handled by their own classes; route them
// first so a click on Restore or × doesn't also open the note.
var restoreBtn = e.target.closest('.notes-restore-btn');
if (restoreBtn) {
e.stopPropagation();
restoreNote(parseInt(restoreBtn.dataset.id));
return;
}
var hardBtn = e.target.closest('.notes-hard-delete-btn');
if (hardBtn) {
e.stopPropagation();
hardDeleteNote(parseInt(hardBtn.dataset.id));
return;
}
var item = e.target.closest('.notes-list-item');
if (!item) return;
var id = parseInt(item.dataset.id);
if (!id) return;
if (_pane === 'active') openReader(id);
// In trash, clicking the row body is a no-op — explicit Restore /
// delete-forever buttons are the only actions, so a stray click
// can't accidentally open a stale draft.
});
// Active <-> Trash tab toggle
var tabActive = $('btn-notes-tab-active');
var tabTrash = $('btn-notes-tab-trash');
if (tabActive) tabActive.addEventListener('click', function() { switchPane('active'); });
if (tabTrash) tabTrash.addEventListener('click', function() { switchPane('trash'); });
var emptyTrashBtn = $('btn-notes-trash-empty');
if (emptyTrashBtn) emptyTrashBtn.addEventListener('click', emptyTrash);
// Empty-state CTA
var newFromEmpty = $('btn-notes-new-empty');
if (newFromEmpty) newFromEmpty.addEventListener('click', function() { startCreate(); });
// Reader pane
var editBtn = $('btn-note-edit');
var delFromRead = $('btn-note-delete-read');
var backFromRead = $('btn-notes-reader-back');
if (editBtn) editBtn.addEventListener('click', function() { if (_activeId != null) openEditor(_activeId); });
if (delFromRead) delFromRead.addEventListener('click', deleteActive);
if (backFromRead) backFromRead.addEventListener('click', closeToList);
// Editor pane
var saveBtn = $('btn-note-save');
var delBtn = $('btn-note-delete');
var backFromEdit = $('btn-notes-editor-back');
if (saveBtn) saveBtn.addEventListener('click', function() { saveNote({ source: 'manual', returnToReader: true }); });
if (delBtn) delBtn.addEventListener('click', deleteActive);
if (backFromEdit) backFromEdit.addEventListener('click', backFromEditor);
titleEl.addEventListener('input', function() {
_dirty = true; updateStatus('Saving…', 'saving'); scheduleAutosave();
});
// Voice recording controls. addEventListener passes the click Event
// as the first argument — wrap so the truthy Event doesn't get passed
// to stopRecording(silent) and trigger the silent-cancel branch
// (which shuts the mic without transcribing → "Stop does nothing").
var recStart = $('btn-note-rec-start');
var recPause = $('btn-note-rec-pause');
var recStop = $('btn-note-rec-stop');
if (recStart) recStart.addEventListener('click', function() { startRecording(); });
if (recPause) recPause.addEventListener('click', function() { togglePauseRecording(); });
if (recStop) recStop.addEventListener('click', function() { stopRecording(false); });
// Ctrl/Cmd+S still saves manually from the editor
document.addEventListener('keydown', function(e) {
var tab = $('notes-tab');
if (!tab || !tab.classList.contains('active')) return;
if ((e.ctrlKey || e.metaKey) && e.key === 's') {
if (!$('notes-editor').classList.contains('hidden')) {
e.preventDefault();
saveNote({ source: 'shortcut', returnToReader: true });
}
}
});
// Persist dirty state on tab close / page unload — best-effort final flush.
// navigator.sendBeacon is POST-only so it can't drive a PUT update; fetch
// with keepalive:true is the right primitive here (works for both POST
// for new notes and PUT for existing).
window.addEventListener('beforeunload', function() {
if (!_dirty) return;
var title = getTitle();
if (!title) return;
var body = getBody();
var isNew = _activeId == null;
var url = isNew ? '/api/notes' : '/api/notes/' + _activeId;
var method = isNew ? 'POST' : 'PUT';
try {
fetch(url, {
method: method,
keepalive: true,
credentials: 'include',
headers: Object.assign({ 'Content-Type': 'application/json' }, getAuthHeaders()),
body: JSON.stringify({ title: title, body: body }),
});
} catch (e) {}
});
refreshList();
setView('list');
}
// ── View management ──────────────────────────────────────
// On mobile (<900px), only one pane visible at a time, driven
// by the layout's data-view attribute.
function setView(view) {
var layout = $('notes-layout');
if (layout) layout.setAttribute('data-view', view);
var reader = $('notes-reader');
var editor = $('notes-editor');
var empty = $('notes-empty-state');
if (reader) reader.classList.toggle('hidden', view !== 'reader');
if (editor) editor.classList.toggle('hidden', view !== 'editor');
// Empty state is only the "no note picked" desktop placeholder
if (empty) empty.classList.toggle('hidden', view !== 'empty' && view !== 'list');
}
function closeToList() {
// Any unsaved edits are already autosaved, so no discard prompt needed.
flushAutosave();
_activeId = null;
_dirty = false;
if (_editor) { _editor.destroy(); _editor = null; }
stopRecording(true); // silent cancel if in flight
setView('list');
renderList();
}
function backFromEditor() {
flushAutosave();
if (_activeId != null) openReader(_activeId);
else closeToList();
}
// ── Data fetching ─────────────────────────────────────────
// Always pulls both lists in parallel so the trash-count badge stays
// current regardless of which pane the user has open.
function refreshList() {
var listEl = $('notes-list');
if (!listEl) return;
listEl.innerHTML = '