Files
odysseus/static/js/features/notes/application/reminders.js
T
Alexandre TeixeiraandLéo f1a47f3812 refactor(notes): decompose Notes into feature-owned modules (#6713)
* refactor(notes): decompose Notes into feature-owned modules

* fix(notes): avoid HTML interpolation in reminder picker

* docs(notes): drop checkpoint archives, session logs and publication drafts

The N0-N6 tarballs and raw pytest/node logs are not read by any script or
test, and the logs carry absolute home-directory paths. Once squashed into
dev they would stay in history for good. notes-pr.md and notes-issue.md
were the drafts used to open #6712 and #6713, not documentation.

notes-validation.md keeps the counts, the checkpoint hashes and
implementation.sha256 as the record; local interpreter paths and the local
Docker image name are replaced with portable placeholders.

* fix(notes): keep the state store raw and stop masonry re-observing after close

- state: writes built from views (filter, spread, splice) stored nested
  read-only views in the store, so structuredClone on a stored note threw
  DataCloneError. Store writes now unwrap nested views.
- layout: a save that resolves after closePanel re-renders the leaving pane
  and re-created the masonry ResizeObserver that teardown had disposed.
  Skip creating it once Notes is closed.
- index.js: drop 30 imported or declared names that were never used and
  group the imports at the top. Import order is unchanged.
- Revert unrelated punctuation edits in .gitattributes and keep the header
  comment and docstring first in compare/vote.js and
  test_notes_dom_xss_helpers.py.

Both fixes have a node test that fails on the previous head.

* test(notes): make script rejection assertion case-insensitive

Address CodeQL js/bad-tag-filter alert #1150 in test-only assertion and refresh Notes evidence manifest checksum.

---------

Co-authored-by: Léo <leograndcontact@gmail.com>
2026-10-15 09:29:41 +01:00

183 lines
6.8 KiB
JavaScript

/** Page-owned reminder scheduler and notification delivery workflow. */
import { _hasTimeComponent, _isDueTodayOrOverdue, _advanceRecurring } from '../domain/recurrence.js';
export function createNotesReminders({
app,
api,
persistence,
notifications,
clock,
scheduler,
effects,
fetchNotes,
isNoteFullyDone,
hasItems,
dismissedKey
}) {
const state = app.state;
const { _loadFiredReminders, _saveFiredReminders } = persistence;
const _patchNote = api.patchNote;
const _fetchNotes = fetchNotes;
const _isNoteFullyDone = isNoteFullyDone;
const _hasItems = hasItems;
const REMINDER_DISMISSED_AT_KEY = dismissedKey;
const _updateRailBadge = effects.badge;
let _reminderTimer = null;
let startupTimer = null;
let started = false;
let disposed = false;
function _checkReminders() {
if (!state.notes.length) return;
const now = clock.now();
const fired = _loadFiredReminders();
let changed = false;
for (const note of state.notes) {
if (!note.due_date || note.archived) continue;
if (!_hasTimeComponent(note.due_date)) continue;
if (fired.has(note.id)) continue;
const due = new Date(note.due_date).getTime();
if (isNaN(due)) continue;
if (due <= now && due > now - 60000) {
_fireReminder(note);
// Recurring? advance the due_date instead of marking as fired
if (note.repeat && note.repeat !== 'none') {
const next = _advanceRecurring(note.due_date, note.repeat, clock);
if (next) {
app.mergeNote(note, { due_date: next });
_patchNote(note.id, { due_date: next }).catch(() => {});
// Don't add to fired : new due_date is in the future
continue;
}
}
fired.add(note.id);
changed = true;
} else if (due <= now - 60000) {
// Past, never seen : silently advance recurring or mark fired
if (note.repeat && note.repeat !== 'none') {
const next = _advanceRecurring(note.due_date, note.repeat, clock);
if (next) {
app.mergeNote(note, { due_date: next });
_patchNote(note.id, { due_date: next }).catch(() => {});
continue;
}
}
fired.add(note.id);
changed = true;
}
}
if (changed) _saveFiredReminders(fired);
// Always refresh badge : fired state may have changed visually without note mutation
_updateRailBadge();
}
function _fireReminder(note) {
const title = note.title || 'Note reminder';
// Include the verbatim note content so the email/notification actually
// shows what to do, not just a count. Cap the per-item lines (8 max) and
// total length so the body stays inbox-friendly.
let rawBody;
if (_hasItems(note)) {
const pending = (note.items || [])
.filter(i => !i.done && !i.checked)
.map(i => (i.text || '').trim())
.filter(Boolean);
if (pending.length) {
const shown = pending.slice(0, 8).map(t => `- ${t}`).join('\n');
const extra = pending.length > 8 ? `\n…and ${pending.length - 8} more` : '';
rawBody = `Pending (${pending.length}):\n${shown}${extra}`;
} else {
rawBody = `${(note.items || []).length} item${(note.items || []).length === 1 ? '' : 's'}`;
}
} else {
rawBody = (note.content || '').slice(0, 400);
}
// Ask the server to dispatch according to user settings. The server may
// return an LLM-written synthesis line and/or send an email. We still show
// a local browser notification so the user gets immediate feedback even if
// the server path is disabled or slow.
const showLocal = body => notifications.show(title, body, note.id);
// Fire-and-forget server dispatch. If synthesis comes back quickly enough,
// use it as the notification body; otherwise the local notification has
// already shown with the raw body.
let shown = false;
const timer = scheduler.setTimeout(() => { if (!shown) { shown = true; showLocal(rawBody); } }, 1500);
api.fireReminder({ note_id: note.id, title, body: rawBody })
.then(r => r.ok ? r.json() : null)
.then(data => {
scheduler.clearTimeout(timer);
if (shown) return;
shown = true;
const body = (data && data.synthesis) ? data.synthesis : rawBody;
showLocal(body);
})
.catch(() => {
scheduler.clearTimeout(timer);
if (!shown) { shown = true; showLocal(rawBody); }
});
// Pulse the card if visible; otherwise queue it so the next time the user
// opens the notes panel the card gets a brief glow.
effects.highlight(note.id);
}
function _startReminderLoop() {
if (disposed || _reminderTimer) return;
_reminderTimer = scheduler.setInterval(_checkReminders, 30000);
_checkReminders(); // run once immediately
}
function _countDueReminders() {
return state.notes.filter(n => !n.archived && _isDueTodayOrOverdue(n.due_date, clock) && !_isNoteFullyDone(n)).length;
}
function _countFiredReminders() {
// Reminders whose time has actually passed (not just date-today),
// and which fired after the last user dismissal.
const now = clock.now();
return state.notes.filter(n => {
if (n.archived || _isNoteFullyDone(n)) return false;
if (!n.due_date || !_hasTimeComponent(n.due_date)) return false;
const t = new Date(n.due_date).getTime();
if (isNaN(t) || t > now) return false;
return t > state.firedDotDismissedAt;
}).length;
}
function dismissFiredReminderDot() {
app.setFiredDotDismissedAt(clock.now());
try { persistence.setItem(REMINDER_DISMISSED_AT_KEY, String(state.firedDotDismissedAt)); } catch {}
_updateRailBadge();
}
async function refreshDueBadge(opts = {}) {
// Usually lightweight, but callers that just created a note reminder can
// force a refresh so the background reminder loop sees it immediately.
if (opts.force || state.notes.length === 0) {
try {
const wasArchived = state.showingArchived;
app.setShowingArchived(false);
await _fetchNotes();
app.setShowingArchived(wasArchived);
} catch {}
}
_updateRailBadge();
}
async function _initReminders() {
try {
const res = await api.listNotes(false);
if (res.ok && !disposed) {
const data = await res.json();
app.replaceNotes(data.notes || data || []);
_startReminderLoop();
}
} catch {}
}
function start() { if (started || disposed) return; started = true; startupTimer = scheduler.setTimeout(_initReminders, 3000); }
function dispose() { disposed = true; scheduler.clearTimeout(startupTimer); scheduler.clearInterval(_reminderTimer); _reminderTimer = null; }
return { start, dispose, _checkReminders, _fireReminder, _startReminderLoop, _countDueReminders, _countFiredReminders, dismissFiredReminderDot, refreshDueBadge, _initReminders };
}