Skip to main content

max / goingson

Resolve CHRONIC-E: surgical task/email list updates, email paging Wire the dead state pub/sub (ultra-fuzz #28 S3) so optimistic mutations re-render in place, and route the frequent single-item actions through surgical state updates instead of a full-window refetch + scroll-to-top (#28 S4). Task complete/delete/start and email open/markRead/markUnread/ markAllRead/archive/unarchive/delete/saveLabels/moveToFolder now patch or drop only the affected row; the state subscriber refreshes the scroller. A render-suppression flag gates the subscriber during the full rebuild in renderFilteredTasks/load so it never refreshes a half-built scroller. Add onNeedMore paging to the email list (mirrors tasks) so the tail past the first page is reachable (#28 S4, was a flat 500 cap). Undo restores a removed row at its original index; completing a recurring task still refetches to surface the spawned instance. Edits that genuinely re-sort (task create/update/set-milestone) keep an intentional refetch. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Author: Max Johnson <me@maxj.phd> · 2026-06-21 23:25 UTC
Commit: c3024b77d3f36261bf5135cdebb58fcb66ee1364
Parent: 8bb422f
2 files changed, +236 insertions, -34 deletions
@@ -27,6 +27,95 @@
27 27 // Email threads stored in centralized state for virtual scrolling
28 28 GoingsOn.state.set('emailThreads', []);
29 29
30 + // CHRONIC-E structural fix (ultra-fuzz Run #28 S3/S4). Reading an email — the
31 + // single most frequent action in the app — used to refetch the whole 500-thread
32 + // window. Wire one subscriber so a surgical state.set('emailThreads', …)
33 + // re-renders the list in place. `suppressEmailRender` gates it during the full
34 + // rebuild in load()/searchEmails so set() there doesn't refresh a scroller that
35 + // is about to be (re)created on the next line.
36 + let suppressEmailRender = false;
37 + GoingsOn.state.subscribe('emailThreads', () => {
38 + if (suppressEmailRender) return;
39 + if (emailScroller) emailScroller.refresh();
40 + });
41 +
42 + // ============ Surgical single-thread list mutations (CHRONIC-E) ============
43 + // Each mutates state.emailThreads in place and lets the subscriber refresh the
44 + // scroller — replacing the old `reload: load` that refetched 500 threads on
45 + // every single-email action (ultra-fuzz Run #28 S4). Threads are matched by
46 + // their representative (most-recent) email id, which is the id the list rows
47 + // and the reader action bar operate on.
48 +
49 + /** Drop the thread whose representative email is `emailId` from the list. */
50 + function _removeThread(emailId) {
51 + const threads = GoingsOn.state.emailThreads || [];
52 + GoingsOn.state.set('emailThreads', threads.filter(t => t.mostRecentEmail.id !== emailId));
53 + }
54 +
55 + /** Toggle a thread's read state. Mirrors the bulk markRead pattern. */
56 + function _setThreadRead(emailId, read) {
57 + const threads = GoingsOn.state.emailThreads || [];
58 + GoingsOn.state.set('emailThreads', threads.map(t =>
59 + t.mostRecentEmail.id === emailId
60 + ? { ...t, mostRecentEmail: { ...t.mostRecentEmail, isRead: read }, hasUnread: !read }
61 + : t
62 + ));
63 + }
64 +
65 + /**
66 + * Mark the opened message read and recompute the thread's unread flag from the
67 + * thread we just loaded. hasUnread is cleared only when no *other* message in
68 + * the thread is still unread, so a multi-message thread with remaining unread
69 + * keeps its dot rather than wrongly showing read (GO todo hazard note).
70 + */
71 + function _markThreadReadForEmail(emailId, threadEmails) {
72 + const threads = GoingsOn.state.emailThreads || [];
73 + const stillUnread = (threadEmails || []).some(e => e.id !== emailId && !e.isRead);
74 + let changed = false;
75 + const next = threads.map(t => {
76 + if (t.mostRecentEmail.id !== emailId) return t;
77 + changed = true;
78 + return { ...t, mostRecentEmail: { ...t.mostRecentEmail, isRead: true }, hasUnread: stillUnread };
79 + });
80 + if (changed) GoingsOn.state.set('emailThreads', next);
81 + }
82 +
83 + // Incremental pagination. The thread list streams in pages via the scroller's
84 + // onNeedMore hook instead of capping at a fixed 500 rows, so a large mailbox's
85 + // tail is reachable (ultra-fuzz Run #28 S4). baseFilters holds the current
86 + // folder/label so each page request stays consistent.
87 + const EMAIL_PAGE_SIZE = 200;
88 + const emailPaging = { loadedCount: 0, total: 0, baseFilters: null };
89 +
90 + /**
91 + * Fetch and append the next page of threads. Wired to the scroller's
92 + * onNeedMore hook. No-ops once every thread is loaded; on error it surfaces a
93 + * toast and stops paging rather than spinning.
94 + */
95 + async function loadMoreEmails() {
96 + if (emailPaging.loadedCount >= emailPaging.total || !emailPaging.baseFilters) return;
97 + let response;
98 + try {
99 + response = await GoingsOn.api.emails.listThreaded({
100 + ...emailPaging.baseFilters,
101 + offset: emailPaging.loadedCount,
102 + limit: EMAIL_PAGE_SIZE,
103 + });
104 + } catch (err) {
105 + GoingsOn.ui.showToast(GoingsOn.utils.getErrorMessage(err, 'Failed to load more emails'), 'error');
106 + return;
107 + }
108 + const threads = (GoingsOn.state.emailThreads || []).concat(response.threads);
109 + GoingsOn.state.set('emails', threads.map(t => t.mostRecentEmail));
110 + GoingsOn.state.set('emailThreads', threads);
111 + emailPaging.loadedCount = threads.length;
112 + emailPaging.total = response.total;
113 + emailSelection.setItems(threads.map(t => ({ id: t.mostRecentEmail.id })));
114 + _updateEmailCount(emailPaging.total, emailPaging.loadedCount);
115 + // Re-arms onNeedMore so the next page can load on further scroll.
116 + if (emailScroller) emailScroller.refresh();
117 + }
118 +
30 119 // ============ Core Functions ============
31 120
32 121 /**
@@ -46,14 +135,24 @@
46 135 }
47 136
48 137 const container = document.getElementById('email-list');
138 + // Suppress the state subscriber for the whole rebuild (see subscribe() above).
139 + suppressEmailRender = true;
140 + // Reset paging for the current folder/label and fetch the first page; the
141 + // list streams in later pages via the scroller's onNeedMore hook.
142 + const baseFilters = {
143 + includeArchived: false,
144 + folder: activeFolder || null,
145 + label: activeLabel || null,
146 + };
147 + emailPaging.baseFilters = baseFilters;
148 + emailPaging.loadedCount = 0;
149 + emailPaging.total = 0;
49 150 try {
50 - // Fetch pre-grouped threads from backend - fetch more for virtual scrolling
151 + // Fetch the first page of pre-grouped threads from the backend.
51 152 const response = await GoingsOn.api.emails.listThreaded({
52 - includeArchived: false,
153 + ...baseFilters,
53 154 offset: 0,
54 - limit: 500, // Fetch more for virtual scrolling
55 - folder: activeFolder || null,
56 - label: activeLabel || null,
155 + limit: EMAIL_PAGE_SIZE,
57 156 });
58 157
59 158 // Refresh filter dropdowns
@@ -62,8 +161,11 @@
62 161 // Update cache with most recent emails
63 162 GoingsOn.state.set('emails', response.threads.map(t => t.mostRecentEmail));
64 163 GoingsOn.state.set('emailThreads', response.threads);
164 + emailPaging.loadedCount = response.threads.length;
165 + emailPaging.total = response.total;
65 166
66 - // Phase 7 Tier 2 #9 — surface the count + 500-cap warning.
167 + // Phase 7 Tier 2 #9 — surface the count; reads "X of N" while later
168 + // pages stream in on scroll.
67 169 _updateEmailCount(response.total, response.threads.length);
68 170
69 171 if (response.total === 0) {
@@ -97,6 +199,7 @@
97 199 getItems: () => GoingsOn.state.emailThreads,
98 200 rowHeight: { estimated: 90, measure: true },
99 201 overscan: 5,
202 + onNeedMore: loadMoreEmails,
100 203 });
101 204 } else {
102 205 emailScroller.refresh();
@@ -108,6 +211,8 @@
108 211 action: { label: 'Retry', fn: load },
109 212 duration: 8000,
110 213 });
214 + } finally {
215 + suppressEmailRender = false;
111 216 }
112 217 }
113 218
@@ -306,12 +411,20 @@
306 411 }
307 412
308 413 async function markAllRead() {
309 - GoingsOn.cache.invalidate('emails');
310 414 await GoingsOn.ui.apiCall(GoingsOn.api.emails.markAllRead(), {
311 415 successMessage: 'All emails marked as read!',
312 416 errorMessage: 'Failed to mark emails as read',
313 417 closeModal: false,
314 - reload: load,
418 + onSuccess: () => {
419 + GoingsOn.cache.invalidate('emails');
420 + // Surgical: clear unread across the streamed threads in place.
421 + const threads = GoingsOn.state.emailThreads || [];
422 + GoingsOn.state.set('emailThreads', threads.map(t => ({
423 + ...t,
424 + mostRecentEmail: { ...t.mostRecentEmail, isRead: true },
425 + hasUnread: false,
426 + })));
427 + },
315 428 });
316 429 }
317 430
@@ -504,7 +617,11 @@
504 617 </div>
505 618 `;
506 619 GoingsOn.ui.openModal(isThread ? 'Thread' : 'Email', content, { large: true });
507 - load(); // Refresh to update read status
620 + // Surgically reflect the read state instead of refetching the whole
621 + // window (ultra-fuzz Run #28 S4) — this is the single most frequent
622 + // action in the app. threadEmails is the thread we just loaded.
623 + GoingsOn.cache.invalidate('emails');
624 + _markThreadReadForEmail(id, threadEmails);
508 625 } catch (err) {
509 626 GoingsOn.ui.showToast(GoingsOn.utils.getErrorMessage(err, 'Failed to load email'), 'error');
510 627 }
@@ -517,11 +634,13 @@
517 634 async function deleteEmail(id) {
518 635 if (!await GoingsOn.ui.confirmDelete('email')) return;
519 636
520 - GoingsOn.cache.invalidate('emails');
521 637 await GoingsOn.ui.apiCall(GoingsOn.api.emails.delete(id), {
522 638 successMessage: 'Email deleted!',
523 639 errorMessage: 'Failed to delete email',
524 - reload: load,
640 + onSuccess: () => {
641 + GoingsOn.cache.invalidate('emails');
642 + _removeThread(id); // surgical: the deleted email leaves the list
643 + },
525 644 });
526 645 }
527 646
@@ -530,11 +649,13 @@
530 649 * @param {string} id - Email ID to archive
531 650 */
532 651 async function archive(id) {
533 - GoingsOn.cache.invalidate('emails');
534 652 await GoingsOn.ui.apiCall(GoingsOn.api.emails.archive(id), {
535 653 successMessage: 'Email archived!',
536 654 errorMessage: 'Failed to archive email',
537 - reload: load,
655 + onSuccess: () => {
656 + GoingsOn.cache.invalidate('emails');
657 + _removeThread(id); // surgical: archived email leaves the inbox view
658 + },
538 659 });
539 660 }
540 661
@@ -543,11 +664,15 @@
543 664 * @param {string} id - Email ID to unarchive
544 665 */
545 666 async function unarchive(id) {
546 - GoingsOn.cache.invalidate('emails');
547 667 await GoingsOn.ui.apiCall(GoingsOn.api.emails.unarchive(id), {
548 668 successMessage: 'Email unarchived!',
549 669 errorMessage: 'Failed to unarchive email',
550 - reload: load,
670 + onSuccess: () => {
671 + GoingsOn.cache.invalidate('emails');
672 + // Surgical: drop it from the archived-folder view. In the inbox view
673 + // it isn't present, so this is a no-op there.
674 + _removeThread(id);
675 + },
551 676 });
552 677 }
553 678
@@ -556,11 +681,13 @@
556 681 * @param {string} id - Email ID
557 682 */
558 683 async function markRead(id) {
559 - GoingsOn.cache.invalidate('emails');
560 684 await GoingsOn.ui.apiCall(GoingsOn.api.emails.markRead(id), {
561 685 errorMessage: 'Failed to mark email as read',
562 686 closeModal: false,
563 - reload: load,
687 + onSuccess: () => {
688 + GoingsOn.cache.invalidate('emails');
689 + _setThreadRead(id, true);
690 + },
564 691 });
565 692 }
566 693
@@ -569,11 +696,13 @@
569 696 * @param {string} id - Email ID
570 697 */
571 698 async function markUnread(id) {
572 - GoingsOn.cache.invalidate('emails');
573 699 await GoingsOn.ui.apiCall(GoingsOn.api.emails.markUnread(id), {
574 700 errorMessage: 'Failed to mark email as unread',
575 701 closeModal: false,
576 - reload: load,
702 + onSuccess: () => {
703 + GoingsOn.cache.invalidate('emails');
704 + _setThreadRead(id, false);
705 + },
577 706 });
578 707 }
579 708
@@ -1100,7 +1229,13 @@
1100 1229 GoingsOn.ui.showToast('Labels updated!', 'success');
1101 1230 GoingsOn.ui.closeModal();
1102 1231 GoingsOn.cache.invalidate('emails');
1103 - load();
1232 + // Surgical: patch the thread's labels in place (ultra-fuzz Run #28 S4).
1233 + const threads = GoingsOn.state.emailThreads || [];
1234 + GoingsOn.state.set('emailThreads', threads.map(t =>
1235 + t.mostRecentEmail.id === emailId
1236 + ? { ...t, mostRecentEmail: { ...t.mostRecentEmail, labels } }
1237 + : t
1238 + ));
1104 1239 loadFilters();
1105 1240 } catch (err) {
1106 1241 GoingsOn.ui.showToast(GoingsOn.utils.getErrorMessage(err, 'Failed to update labels'), 'error');
@@ -1146,7 +1281,9 @@
1146 1281 GoingsOn.ui.showToast(`Moved to ${folder}`, 'success');
1147 1282 GoingsOn.ui.closeModal();
1148 1283 GoingsOn.cache.invalidate('emails');
1149 - load();
1284 + // Surgical: the moved email leaves the current folder/inbox view
1285 + // (ultra-fuzz Run #28 S4).
1286 + _removeThread(emailId);
1150 1287 loadFilters();
1151 1288 } catch (err) {
1152 1289 GoingsOn.ui.showToast(GoingsOn.utils.getErrorMessage(err, 'Failed to move email'), 'error');
@@ -1168,7 +1305,10 @@
1168 1305 GoingsOn.queryState?.write('q', trimmed);
1169 1306
1170 1307 if (!trimmed) {
1171 - // Clear search — reload normal email list
1308 + // Clear search — reload normal email list. Drop the search-built
1309 + // scroller (which has no onNeedMore) so load() recreates it with the
1310 + // paging hook re-armed (ultra-fuzz Run #28 S4).
1311 + if (emailScroller) { emailScroller.destroy(); emailScroller = null; }
1172 1312 GoingsOn.cache.invalidate('emails');
1173 1313 load();
1174 1314 return;
@@ -22,6 +22,22 @@
22 22 // Virtual scroller instance
23 23 let taskScroller = null;
24 24
25 + // CHRONIC-E structural fix (ultra-fuzz Run #28 S3/S4). The state pub/sub was
26 + // dead — not one subscriber existed, so optimistic mutations that called
27 + // state.set('tasks', …) never re-rendered, and handlers fell back to a full
28 + // reload (refetch page 1, scroll to top) on every single-item edit. Wire
29 + // exactly one subscriber so any task-state change refreshes the scroller in
30 + // place. `suppressTaskRender` gates it during the full rebuild in
31 + // renderFilteredTasks: that path sets state to raw (un-decorated) rows mid-way
32 + // and (re)creates the scroller on the next line, so a subscriber-driven
33 + // refresh there would render half-built rows — the re-entrancy trap flagged in
34 + // the GO todo. Outside that window, set() is the render trigger.
35 + let suppressTaskRender = false;
36 + GoingsOn.state.subscribe('tasks', () => {
37 + if (suppressTaskRender) return;
38 + if (taskScroller) taskScroller.refresh();
39 + });
40 +
25 41 // Incremental pagination state. The task list streams in pages as the user
26 42 // scrolls (via the scroller's onNeedMore hook) instead of capping at a fixed
27 43 // row count, so a large task set is fully reachable. baseFilters holds the
@@ -162,6 +178,10 @@
162 178 taskPaging.loadedCount = 0;
163 179 taskPaging.total = 0;
164 180
181 + // Suppress the state subscriber for the whole rebuild — see the subscribe()
182 + // wiring above. The finally restores it so later optimistic edits re-render.
183 + suppressTaskRender = true;
184 + try {
165 185 let response;
166 186 try {
167 187 // Fetch the first page of filtered/sorted tasks from the backend.
@@ -251,6 +271,9 @@
251 271 } else {
252 272 taskScroller.refresh();
253 273 }
274 + } finally {
275 + suppressTaskRender = false;
276 + }
254 277 }
255 278
256 279 function openNew() {
@@ -647,12 +670,35 @@
647 670 * Transition a task from Pending to Started status.
648 671 * @param {string} id - Task ID to start
649 672 */
673 + /**
674 + * Surgically reflect a single task's status change in the streamed list. If
675 + * the active status filter no longer matches the new status, the row leaves
676 + * the view; otherwise it is patched in place. Either way the state subscriber
677 + * refreshes the scroller — no full-window refetch + scroll-to-top (ultra-fuzz
678 + * Run #28 S4). Genuine re-sorts (priority/due edits) still reload; a status
679 + * transition does not move a task within the urgency sort.
680 + * @param {string} id - Task ID
681 + * @param {string} newStatus - Capitalized backend status ('Started', etc.)
682 + */
683 + function _applyTaskStatusChange(id, newStatus) {
684 + const filterStatus = GoingsOn.tasksFilter.getFilters().status; // '' = all
685 + const tasks = GoingsOn.state.tasks || [];
686 + const stillMatches = !filterStatus || filterStatus.toLowerCase() === newStatus.toLowerCase();
687 + if (stillMatches) {
688 + GoingsOn.state.set('tasks', tasks.map(t => t.id === id ? { ...t, status: newStatus } : t));
689 + } else {
690 + GoingsOn.state.set('tasks', tasks.filter(t => t.id !== id));
691 + }
692 + }
693 +
650 694 async function start(id) {
651 695 await GoingsOn.ui.apiCall(GoingsOn.api.tasks.start(id), {
652 696 successMessage: 'Task started!',
653 697 errorMessage: 'Failed to start task',
654 - onSuccess: () => GoingsOn.cache.invalidate('tasks'),
655 - reload: load,
698 + onSuccess: () => {
699 + GoingsOn.cache.invalidate('tasks');
700 + _applyTaskStatusChange(id, 'Started');
701 + },
656 702 });
657 703 }
658 704
@@ -668,26 +714,38 @@
668 714 if (row) row.classList.add('task-row-removing');
669 715
670 716 const cachedTasks = GoingsOn.state.tasks;
671 - const removedTask = cachedTasks.find(t => t.id === id);
717 + const removedIndex = cachedTasks.findIndex(t => t.id === id);
718 + const removedTask = removedIndex > -1 ? cachedTasks[removedIndex] : null;
672 719
673 - // Delay state update to let animation play
720 + // Optimistic surgical removal once the row has animated out. The state
721 + // subscriber refreshes the scroller in place — no full-window refetch
722 + // (ultra-fuzz Run #28 S4). The completed task leaves the pending view.
674 723 setTimeout(() => {
675 - GoingsOn.state.set('tasks', cachedTasks.filter(t => t.id !== id));
724 + GoingsOn.state.set('tasks', GoingsOn.state.tasks.filter(t => t.id !== id));
676 725 }, 250);
677 726
678 727 GoingsOn.ui.showUndoToast('Task completed', {
679 728 onConfirm: async () => {
680 729 try {
681 730 await GoingsOn.api.tasks.complete(id);
682 - load();
731 + // A recurring task spawns its next instance on completion, so
732 + // refetch to surface it. A plain task is already reflected by the
733 + // optimistic removal — no full-window refetch (ultra-fuzz #28 S4).
734 + if (removedTask && removedTask.recurrence && removedTask.recurrence !== 'None') {
735 + load();
736 + }
683 737 } catch (err) {
684 738 GoingsOn.ui.showToast('Failed to complete task', 'error');
685 - load();
739 + load(); // restore truth after a failed commit
686 740 }
687 741 },
688 742 onUndo: () => {
689 743 if (removedTask) {
690 - GoingsOn.state.set('tasks', [...GoingsOn.state.tasks, removedTask]);
744 + // Restore at the original position so the undo doesn't drop the
745 + // row to the list tail (ultra-fuzz Run #28 S3).
746 + const restored = GoingsOn.state.tasks.slice();
747 + restored.splice(Math.min(removedIndex, restored.length), 0, removedTask);
748 + GoingsOn.state.set('tasks', restored);
691 749 }
692 750 },
693 751 });
@@ -704,9 +762,11 @@
704 762 // recovery. A "this cannot be undone" modal here would be both redundant
705 763 // and false. Confirms are reserved for immediate, non-undoable deletes.
706 764 GoingsOn.cache.invalidate('tasks');
707 - // Hide task from UI immediately
765 + // Hide task from UI immediately. The state subscriber refreshes the
766 + // scroller in place — no full-window refetch (ultra-fuzz Run #28 S3/S4).
708 767 const cachedTasks = GoingsOn.state.tasks;
709 - const removedTask = cachedTasks.find(t => t.id === id);
768 + const removedIndex = cachedTasks.findIndex(t => t.id === id);
769 + const removedTask = removedIndex > -1 ? cachedTasks[removedIndex] : null;
710 770 GoingsOn.state.set('tasks', cachedTasks.filter(t => t.id !== id));
711 771
712 772 GoingsOn.ui.showUndoToast('Task deleted', {
@@ -720,9 +780,11 @@
720 780 }
721 781 },
722 782 onUndo: () => {
723 - // Restore task in UI
783 + // Restore at the original position (ultra-fuzz Run #28 S3).
724 784 if (removedTask) {
725 - GoingsOn.state.set('tasks', [...GoingsOn.state.tasks, removedTask]);
785 + const restored = GoingsOn.state.tasks.slice();
786 + restored.splice(Math.min(removedIndex, restored.length), 0, removedTask);
787 + GoingsOn.state.set('tasks', restored);
726 788 }
727 789 },
728 790 });