Skip to main content

max / goingson

17.2 KB · 428 lines History Blame Raw
1 /**
2 * GoingsOn - App Bootstrap Module
3 * DOMContentLoaded, initial data loading, Tauri menu listeners
4 */
5
6 (function() {
7 'use strict';
8
9 // How long after a JS-initiated DB write to treat a db:external-change event as the
10 // app's own echo and skip it (CHRONIC-E). Comfortably covers the watcher's debounce
11 // (it rate-limits to ~1/sec plus a trailing emit) without masking a genuinely
12 // external write that arrives later.
13 const LOCAL_WRITE_SUPPRESS_MS = 2500;
14
15 // Maps a synced DB table (from the `sync:changes-applied` payload) to the view
16 // cache entities it affects. A table mapping to `[]` has no cached view to bust
17 // (the re-render alone refetches it). Any table NOT present here triggers a full
18 // cache bust — keep this in sync with the backend `UPSERT_ORDER` list.
19 const SYNC_TABLE_TO_ENTITIES = {
20 projects: ['projects'],
21 milestones: ['projects'],
22 tasks: ['tasks'],
23 subtasks: ['tasks'],
24 annotations: ['tasks'],
25 time_sessions: ['tasks'],
26 attachments: ['tasks', 'projects'],
27 events: ['events'],
28 contacts: ['contacts'],
29 contact_emails: ['contacts'],
30 contact_phones: ['contacts'],
31 contact_social_handles: ['contacts'],
32 contact_custom_fields: ['contacts'],
33 email_accounts: ['emails'],
34 sync_accounts: [],
35 daily_notes: [],
36 };
37
38 // ============ Application Initialization ============
39
40 document.addEventListener('DOMContentLoaded', async () => {
41 // Check if api is available
42 if (!GoingsOn.api) {
43 console.error('API not available');
44 const errorTarget = document.getElementById('projects-grid') || document.getElementById('task-list-container');
45 if (errorTarget) errorTarget.innerHTML =
46 '<div class="loading loading--error">GoingsOn failed to start. Please relaunch the app. If this persists, contact info@makenot.work.</div>';
47 return;
48 }
49
50 // Load projects cache first (needed for task dropdowns)
51 try {
52 const projects = await GoingsOn.api.projects.list();
53 GoingsOn.projects.setCache(projects);
54 } catch (err) {
55 console.error('Failed to load projects:', err);
56 }
57
58 // Load email accounts cache
59 try {
60 const accounts = await GoingsOn.api.emailAccounts.list();
61 GoingsOn.emails.setAccountsCache(accounts);
62 } catch (err) {
63 console.error('Failed to load email accounts:', err);
64 }
65
66 // Initialize router (handles initial view from URL)
67 if (GoingsOn.router && typeof GoingsOn.router.init === 'function') {
68 GoingsOn.router.init();
69 } else {
70 // Fallback if router not available
71 GoingsOn.tasks.load();
72 }
73
74 // First-run welcome
75 if (!localStorage.getItem('go-welcomed')) {
76 showWelcome();
77 } else if (!localStorage.getItem('go-hint-shortcuts')) {
78 // One-time hint after first session
79 setTimeout(() => showHint('go-hint-shortcuts', 'Press ? anytime to see keyboard shortcuts'), 2000);
80 }
81
82 // After an OTA update, surface this version's changelog once. No-ops on a
83 // matching version and records first-launch silently (welcome owns that).
84 if (GoingsOn.whatsNew && typeof GoingsOn.whatsNew.maybeShow === 'function') {
85 GoingsOn.whatsNew.maybeShow();
86 }
87
88 // Check weekly review nudge on startup
89 if (GoingsOn.weeklyReview && typeof GoingsOn.weeklyReview.checkNudge === 'function') {
90 GoingsOn.weeklyReview.checkNudge();
91 }
92
93 // Start event status indicator polling
94 if (GoingsOn.events && typeof GoingsOn.events.startEventStatusPolling === 'function') {
95 GoingsOn.events.startEventStatusPolling();
96 }
97
98 // Initialize sync status indicator
99 if (GoingsOn.settings && typeof GoingsOn.settings.refreshSyncIndicator === 'function') {
100 GoingsOn.settings.refreshSyncIndicator();
101 }
102
103 // Initialize time tracking widget (check for active timer)
104 if (GoingsOn.timeTracking && typeof GoingsOn.timeTracking.init === 'function') {
105 GoingsOn.timeTracking.init();
106 }
107 });
108
109 // Initialize theme on page load
110 document.addEventListener('DOMContentLoaded', () => {
111 if (GoingsOn.themes && typeof GoingsOn.themes.loadFromStorage === 'function') {
112 GoingsOn.themes.loadFromStorage();
113 }
114 });
115
116 // Close dropdowns when clicking outside
117 document.addEventListener('click', (e) => {
118 // If click is not on a dropdown button, close all dropdowns
119 if (!e.target.closest('.dropdown')) {
120 document.querySelectorAll('.dropdown-menu.show').forEach(menu => {
121 menu.classList.remove('show');
122 });
123 }
124 });
125
126 // ============ Native Menu Bar Event Handlers ============
127
128 // Initialize menu event listeners when Tauri is available
129 if (window.__TAURI__) {
130 const { listen } = window.__TAURI__.event;
131
132 // Compose → main app: queue a send with an undo window.
133 // Fired by compose.html sendEmail() so the compose window can close
134 // immediately while the main app holds the 5 s undo toast.
135 listen('compose:queue-send', (event) => {
136 const payload = event?.payload || {};
137 if (payload.input) {
138 GoingsOn.emails.queueSend({
139 input: payload.input,
140 delaySeconds: payload.delaySeconds || 5,
141 });
142 }
143 });
144
145 // File menu
146 listen('menu:new_task', () => GoingsOn.tasks.openNew());
147 listen('menu:new_project', () => GoingsOn.projects.openNew());
148 listen('menu:import', () => GoingsOn.import.openModal());
149 listen('menu:save_view', () => GoingsOn.savedViews?.openSaveModal?.());
150
151 // View menu - tab shortcuts
152 listen('menu:view_work', () => GoingsOn.navigation.switchView('work'));
153 listen('menu:view_time', () => GoingsOn.navigation.switchView('time'));
154 listen('menu:view_messages', () => GoingsOn.navigation.switchView('messages'));
155 // View menu - individual sub-views
156 listen('menu:view_projects', () => GoingsOn.navigation.switchView('projects'));
157 listen('menu:view_tasks', () => GoingsOn.navigation.switchView('tasks'));
158 listen('menu:view_events', () => GoingsOn.navigation.switchView('events'));
159 listen('menu:view_emails', () => GoingsOn.navigation.switchView('emails'));
160 listen('menu:view_contacts', () => GoingsOn.navigation.switchView('contacts'));
161 listen('menu:view_day_plan', () => GoingsOn.navigation.switchView('day-plan'));
162 listen('menu:view_weekly_review', () => GoingsOn.navigation.switchView('weekly-review'));
163 listen('menu:view_monthly_review', () => GoingsOn.navigation.switchView('monthly-review'));
164 listen('menu:toggle_sidebar', () => GoingsOn.app.toggleSidebar());
165
166 // Tools menu
167 listen('menu:sync_email', () => GoingsOn.app.syncAllEmailAccounts());
168 listen('menu:settings', () => GoingsOn.settings.open());
169
170 // Help menu
171 listen('menu:keyboard_shortcuts', () => GoingsOn.keyboard.toggleShortcuts());
172 listen('menu:about', () => GoingsOn.app.openAboutModal());
173
174 // Database external change detection.
175 //
176 // The watcher fires on ANY change to the DB file, including this app's own
177 // writes. A JS-initiated mutation already refreshed the UI, so the echo here
178 // would be a redundant invalidate-everything storm (CHRONIC-E). Skip it when a
179 // local write happened in the last LOCAL_WRITE_SUPPRESS_MS. A genuinely external
180 // write (e.g. the background email-sync scheduler, which runs in Rust and never
181 // marks a JS write) is NOT recent-marked, so it still refreshes.
182 listen('db:external-change', () => {
183 const sinceLocalWrite = Date.now() - (window.__goLastLocalWriteAt || 0);
184 if (sinceLocalWrite < LOCAL_WRITE_SUPPRESS_MS) {
185 return;
186 }
187 GoingsOn.cache.invalidateAll();
188 refreshCurrentViewData();
189 });
190
191 // Cloud sync: remote changes applied. The payload is the list of DB tables
192 // the pull touched; invalidate only the affected cache entities so an
193 // unrelated change (e.g. a task edit) doesn't force the compose screen to
194 // re-hydrate every contact. Unknown/absent payload falls back to a full bust.
195 listen('sync:changes-applied', (event) => {
196 const tables = Array.isArray(event.payload) ? event.payload : null;
197 if (!tables) {
198 GoingsOn.cache.invalidateAll();
199 } else {
200 const entities = new Set();
201 let unknown = false;
202 for (const table of tables) {
203 if (table in SYNC_TABLE_TO_ENTITIES) {
204 for (const e of SYNC_TABLE_TO_ENTITIES[table]) entities.add(e);
205 } else {
206 unknown = true; // be safe about tables we don't have a mapping for
207 }
208 }
209 if (unknown) {
210 GoingsOn.cache.invalidateAll();
211 } else if (entities.size) {
212 GoingsOn.cache.invalidate(...entities);
213 }
214 }
215 // Cloud sync already refreshed selectively here; mark it so the redundant
216 // db:external-change echo from the same writes is suppressed (CHRONIC-E).
217 window.__goLastLocalWriteAt = Date.now();
218 refreshCurrentViewData();
219 });
220
221 // Cloud sync: subscription required (402 from server)
222 listen('sync:subscription-required', () => {
223 GoingsOn.ui.showToast('Cloud sync paused — subscription required', 'error', {
224 action: { label: 'Subscribe', fn: () => GoingsOn.settings.openCloudSync() },
225 duration: 10000,
226 });
227 });
228
229 // Cloud sync: status changed (syncing/idle/error)
230 listen('sync:status-changed', (event) => {
231 const dot = document.getElementById('sync-dot');
232 const indicator = document.getElementById('sync-indicator');
233 if (!dot || !indicator) return;
234 indicator.classList.remove('hidden');
235 dot.className = 'sync-dot';
236 if (event.payload === 'syncing') {
237 dot.classList.add('syncing');
238 } else if (event.payload === 'error') {
239 dot.classList.add('error');
240 } else {
241 dot.classList.add('connected');
242 }
243 });
244 }
245
246 // ============ External Change Handler ============
247
248 /**
249 * Refresh the current view's data without full navigation.
250 * Used when external changes are detected (e.g., an external process modified the database).
251 */
252 async function refreshCurrentViewData() {
253 // Don't refresh if a modal is open (user is editing something)
254 if (document.querySelector('.modal:not(.hidden)')) {
255 return;
256 }
257
258 const currentView = GoingsOn.navigation?.getCurrentView?.() || 'tasks';
259
260 try {
261 // Refresh projects cache first (needed for dropdowns)
262 const projects = await GoingsOn.api.projects.list();
263 GoingsOn.projects.setCache(projects);
264
265 // Reload the current view's data
266 await GoingsOn.navigation.loadViewData(currentView);
267 } catch (err) {
268 console.error('Failed to refresh view after external change:', err);
269 }
270 }
271
272 // ============ Sidebar Toggle ============
273
274 function toggleSidebar() {
275 const sidebar = document.querySelector('.saved-views-sidebar');
276 if (sidebar) {
277 sidebar.classList.toggle('hidden');
278 }
279 }
280
281 // ============ Email Sync ============
282
283 async function syncAllEmailAccounts() {
284 try {
285 const accounts = await GoingsOn.api.emailAccounts.list();
286 if (accounts.length === 0) {
287 GoingsOn.ui.showToast('No email accounts configured', 'info');
288 return;
289 }
290 // Persistent progress modal: this loops a blocking IMAP fetch per
291 // account and can run for minutes. Without it the app looks frozen.
292 const plural = accounts.length === 1 ? 'account' : 'accounts';
293 GoingsOn.emails.showSyncProgressModal(`Syncing ${accounts.length} email ${plural}...`);
294 for (const account of accounts) {
295 await GoingsOn.api.emailAccounts.sync(account.id, false);
296 }
297 GoingsOn.ui.closeModal();
298 GoingsOn.ui.showToast('Email sync complete!', 'success');
299 GoingsOn.emails.load();
300 } catch (err) {
301 GoingsOn.ui.closeModal();
302 GoingsOn.ui.showToast('Email sync failed: ' + GoingsOn.utils.getErrorMessage(err), 'error', {
303 action: { label: 'Retry', fn: syncAllEmailAccounts },
304 duration: 8000,
305 });
306 }
307 }
308
309 // ============ About Modal ============
310
311 async function openAboutModal() {
312 let appVersion = "unknown";
313 try { appVersion = await window.__TAURI__.app.getVersion(); } catch (_) {}
314 const content = `
315 <div class="about-panel">
316 <h2 class="about-title">GoingsOn</h2>
317 <p class="about-tagline">Tasks, email, calendar, contacts.</p>
318 <p class="about-version">Version ${appVersion}</p>
319 <dl class="about-info-list">
320 <dt>Publisher</dt><dd>Make Creative, LLC</dd>
321 <dt>License</dt><dd>PolyForm Noncommercial 1.0.0</dd>
322 <dt>Contact</dt><dd><a href="mailto:info@makenot.work">info@makenot.work</a></dd>
323 <dt>Source</dt><dd><a href="https://makenot.work" target="_blank" rel="noopener">makenot.work</a></dd>
324 <dt>Privacy</dt><dd><a href="https://makenot.work/policy" target="_blank" rel="noopener">makenot.work/policy</a></dd>
325 </dl>
326 <p class="about-copyright">&copy; 2026 Make Creative, LLC</p>
327 </div>
328 <div class="form-actions">
329 <button type="button" class="btn btn-secondary" data-act="ui.closeModal">Close</button>
330 </div>
331 `;
332 GoingsOn.ui.openModal('About', content);
333 }
334
335 // ============ Populate GoingsOn.app Namespace ============
336
337 function showWelcome() {
338 const isTouch = !!GoingsOn.touch?.isTouchDevice;
339 const step1 = isTouch
340 ? '<strong>1.</strong> Create your first task &mdash; tap the <strong>+</strong> tab to quick add'
341 : '<strong>1.</strong> Create your first task &mdash; press <kbd>q</kbd> for quick add';
342 const step2 = isTouch
343 ? '<strong>2.</strong> Plan your day &mdash; tap or long-press the timeline to schedule'
344 : '<strong>2.</strong> Plan your day &mdash; drag tasks onto the timeline';
345 const shortcutsHint = isTouch
346 ? ''
347 : '<p class="welcome-hint">Press <kbd>?</kbd> anytime for keyboard shortcuts.</p>';
348 const content = `
349 <div class="welcome-panel">
350 <p class="welcome-intro">
351 GoingsOn brings your tasks, email, calendar, and contacts into one place.
352 </p>
353 <div class="welcome-section">
354 <h3 class="welcome-subhead">Get Started</h3>
355 <div class="welcome-step-stack">
356 <button class="btn btn-secondary text-left" data-act="ui.markSeenThen" data-a1="go-welcomed" data-a2="keyboard.openQuickAdd">${step1}</button>
357 <button class="btn btn-secondary text-left" data-act="ui.markSeenThen" data-a1="go-welcomed" data-a2="navigation.switchView" data-a3="day-plan">${step2}</button>
358 <button class="btn btn-secondary text-left" data-act="ui.markSeenThen" data-a1="go-welcomed" data-a2="emails.openAccountsModal"><strong>3.</strong> Add an email account</button>
359 </div>
360 </div>
361 <div class="welcome-section">
362 <h3 class="welcome-subhead welcome-subhead--tight">Three Tabs</h3>
363 <ul class="welcome-tabs-list">
364 <li><strong>Work</strong> &mdash; tasks &amp; projects</li>
365 <li><strong>Time</strong> &mdash; day plan, weekly review &amp; calendar</li>
366 <li><strong>Messages</strong> &mdash; email &amp; contacts</li>
367 </ul>
368 </div>
369 ${shortcutsHint}
370 </div>
371 <div class="form-actions">
372 <button class="btn btn-primary" data-act="ui.markSeenThen" data-a1="go-welcomed">Get Started</button>
373 </div>
374 `;
375 GoingsOn.ui.openModal('Welcome to GoingsOn', content);
376 }
377
378 /**
379 * Show a one-time dismissible hint toast. Sets localStorage key so it only shows once.
380 */
381 function showHint(storageKey, message) {
382 if (localStorage.getItem(storageKey)) return;
383 localStorage.setItem(storageKey, '1');
384 GoingsOn.ui.showToast(message, 'info', { duration: 5000 });
385 }
386
387 GoingsOn.app = {
388 toggleSidebar,
389 syncAllEmailAccounts,
390 openAboutModal,
391 refreshCurrentViewData,
392 showWelcome,
393 showHint,
394 };
395
396 // ============ Background/Foreground Transitions ============
397 // On mobile (and laptop sleep/wake), the app can sit hidden for arbitrary
398 // durations. Browsers throttle JS while hidden; the bigger issue is that
399 // rendered state (current time, sync status, lists) is stale on resume.
400 // If the app was hidden long enough that anything time-sensitive could have
401 // shifted, refresh on visibility return.
402 (function wireVisibilityRefresh() {
403 const STALE_THRESHOLD_MS = 30_000;
404 let hiddenAt = null;
405
406 document.addEventListener('visibilitychange', () => {
407 if (document.hidden) {
408 hiddenAt = Date.now();
409 return;
410 }
411 if (hiddenAt == null) return;
412 const hiddenFor = Date.now() - hiddenAt;
413 hiddenAt = null;
414 if (hiddenFor < STALE_THRESHOLD_MS) return;
415
416 // Refresh data and time-sensitive UI. Skip if a modal is open —
417 // refreshCurrentViewData already guards against that.
418 GoingsOn.cache?.invalidateAll?.();
419 refreshCurrentViewData();
420 GoingsOn.settings?.refreshSyncIndicator?.();
421 if (GoingsOn.dayPlanning?.updateCurrentTimeIndicator) {
422 GoingsOn.dayPlanning.updateCurrentTimeIndicator(true);
423 }
424 });
425 })();
426
427 })();
428