Skip to main content

max / goingson

14.3 KB · 364 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 // ============ Application Initialization ============
10
11 document.addEventListener('DOMContentLoaded', async () => {
12 console.log('GoingsOn Desktop loaded');
13
14
15 // Check if api is available
16 if (!GoingsOn.api) {
17 console.error('API not available');
18 const errorTarget = document.getElementById('projects-grid') || document.getElementById('task-list-container');
19 if (errorTarget) errorTarget.innerHTML =
20 '<div class="loading loading--error">GoingsOn failed to start. Please relaunch the app. If this persists, contact info@makenot.work.</div>';
21 return;
22 }
23
24 // Load projects cache first (needed for task dropdowns)
25 try {
26 const projects = await GoingsOn.api.projects.list();
27 GoingsOn.projects.setCache(projects);
28 console.log('Loaded', projects.length, 'projects');
29 } catch (err) {
30 console.error('Failed to load projects:', err);
31 }
32
33 // Load email accounts cache
34 try {
35 const accounts = await GoingsOn.api.emailAccounts.list();
36 GoingsOn.emails.setAccountsCache(accounts);
37 console.log('Loaded', accounts.length, 'email accounts');
38 } catch (err) {
39 console.error('Failed to load email accounts:', err);
40 }
41
42 // Initialize router (handles initial view from URL)
43 if (GoingsOn.router && typeof GoingsOn.router.init === 'function') {
44 GoingsOn.router.init();
45 } else {
46 // Fallback if router not available
47 GoingsOn.tasks.load();
48 }
49
50 // First-run welcome
51 if (!localStorage.getItem('go-welcomed')) {
52 showWelcome();
53 } else if (!localStorage.getItem('go-hint-shortcuts')) {
54 // One-time hint after first session
55 setTimeout(() => showHint('go-hint-shortcuts', 'Press ? anytime to see keyboard shortcuts'), 2000);
56 }
57
58 // Check weekly review nudge on startup
59 if (GoingsOn.weeklyReview && typeof GoingsOn.weeklyReview.checkNudge === 'function') {
60 GoingsOn.weeklyReview.checkNudge();
61 }
62
63 // Start event status indicator polling
64 if (GoingsOn.events && typeof GoingsOn.events.startEventStatusPolling === 'function') {
65 GoingsOn.events.startEventStatusPolling();
66 }
67
68 // Initialize sync status indicator
69 if (GoingsOn.settings && typeof GoingsOn.settings.refreshSyncIndicator === 'function') {
70 GoingsOn.settings.refreshSyncIndicator();
71 }
72
73 // Initialize time tracking widget (check for active timer)
74 if (GoingsOn.timeTracking && typeof GoingsOn.timeTracking.init === 'function') {
75 GoingsOn.timeTracking.init();
76 }
77 });
78
79 // Initialize theme on page load
80 document.addEventListener('DOMContentLoaded', () => {
81 if (GoingsOn.themes && typeof GoingsOn.themes.loadFromStorage === 'function') {
82 GoingsOn.themes.loadFromStorage();
83 }
84 });
85
86 // Close dropdowns when clicking outside
87 document.addEventListener('click', (e) => {
88 // If click is not on a dropdown button, close all dropdowns
89 if (!e.target.closest('.dropdown')) {
90 document.querySelectorAll('.dropdown-menu.show').forEach(menu => {
91 menu.classList.remove('show');
92 });
93 }
94 });
95
96 // ============ Native Menu Bar Event Handlers ============
97
98 // Initialize menu event listeners when Tauri is available
99 if (window.__TAURI__) {
100 const { listen } = window.__TAURI__.event;
101
102 // Compose → main app: queue a send with an undo window.
103 // Fired by compose.html sendEmail() so the compose window can close
104 // immediately while the main app holds the 5 s undo toast.
105 listen('compose:queue-send', (event) => {
106 const payload = event?.payload || {};
107 if (payload.input) {
108 GoingsOn.emails.queueSend({
109 input: payload.input,
110 delaySeconds: payload.delaySeconds || 5,
111 });
112 }
113 });
114
115 // File menu
116 listen('menu:new_task', () => GoingsOn.tasks.openNew());
117 listen('menu:new_project', () => GoingsOn.projects.openNew());
118 listen('menu:import', () => GoingsOn.import.openModal());
119 listen('menu:save_view', () => GoingsOn.savedViews?.openSaveModal?.());
120
121 // View menu - tab shortcuts
122 listen('menu:view_work', () => GoingsOn.navigation.switchView('work'));
123 listen('menu:view_time', () => GoingsOn.navigation.switchView('time'));
124 listen('menu:view_messages', () => GoingsOn.navigation.switchView('messages'));
125 // View menu - individual sub-views
126 listen('menu:view_projects', () => GoingsOn.navigation.switchView('projects'));
127 listen('menu:view_tasks', () => GoingsOn.navigation.switchView('tasks'));
128 listen('menu:view_events', () => GoingsOn.navigation.switchView('events'));
129 listen('menu:view_emails', () => GoingsOn.navigation.switchView('emails'));
130 listen('menu:view_contacts', () => GoingsOn.navigation.switchView('contacts'));
131 listen('menu:view_day_plan', () => GoingsOn.navigation.switchView('day-plan'));
132 listen('menu:view_weekly_review', () => GoingsOn.navigation.switchView('weekly-review'));
133 listen('menu:view_monthly_review', () => GoingsOn.navigation.switchView('monthly-review'));
134 listen('menu:toggle_sidebar', () => GoingsOn.app.toggleSidebar());
135
136 // Tools menu
137 listen('menu:sync_email', () => GoingsOn.app.syncAllEmailAccounts());
138 listen('menu:settings', () => GoingsOn.settings.open());
139
140 // Help menu
141 listen('menu:keyboard_shortcuts', () => GoingsOn.keyboard.toggleShortcuts());
142 listen('menu:about', () => GoingsOn.app.openAboutModal());
143
144 // Database external change detection
145 listen('db:external-change', () => {
146 console.log('External database change detected, refreshing view');
147 GoingsOn.cache.invalidateAll();
148 refreshCurrentViewData();
149 });
150
151 // Cloud sync: remote changes applied
152 listen('sync:changes-applied', () => {
153 console.log('Sync: remote changes applied, refreshing view');
154 GoingsOn.cache.invalidateAll();
155 refreshCurrentViewData();
156 });
157
158 // Cloud sync: subscription required (402 from server)
159 listen('sync:subscription-required', () => {
160 GoingsOn.ui.showToast('Cloud sync paused — subscription required', 'error', {
161 action: { label: 'Subscribe', fn: () => GoingsOn.settings.openCloudSync() },
162 duration: 10000,
163 });
164 });
165
166 // Cloud sync: status changed (syncing/idle/error)
167 listen('sync:status-changed', (event) => {
168 const dot = document.getElementById('sync-dot');
169 const indicator = document.getElementById('sync-indicator');
170 if (!dot || !indicator) return;
171 indicator.classList.remove('hidden');
172 dot.className = 'sync-dot';
173 if (event.payload === 'syncing') {
174 dot.classList.add('syncing');
175 } else if (event.payload === 'error') {
176 dot.classList.add('error');
177 } else {
178 dot.classList.add('connected');
179 }
180 });
181 }
182
183 // ============ External Change Handler ============
184
185 /**
186 * Refresh the current view's data without full navigation.
187 * Used when external changes are detected (e.g., an external process modified the database).
188 */
189 async function refreshCurrentViewData() {
190 // Don't refresh if a modal is open (user is editing something)
191 if (document.querySelector('.modal:not(.hidden)')) {
192 console.log('Modal open, skipping external refresh');
193 return;
194 }
195
196 const currentView = GoingsOn.navigation?.getCurrentView?.() || 'tasks';
197
198 try {
199 // Refresh projects cache first (needed for dropdowns)
200 const projects = await GoingsOn.api.projects.list();
201 GoingsOn.projects.setCache(projects);
202
203 // Reload the current view's data
204 await GoingsOn.navigation.loadViewData(currentView);
205
206 // Subtle indication that data was refreshed
207 console.log(`View "${currentView}" refreshed due to external change`);
208 } catch (err) {
209 console.error('Failed to refresh view after external change:', err);
210 }
211 }
212
213 // ============ Sidebar Toggle ============
214
215 function toggleSidebar() {
216 const sidebar = document.querySelector('.saved-views-sidebar');
217 if (sidebar) {
218 sidebar.classList.toggle('hidden');
219 }
220 }
221
222 // ============ Email Sync ============
223
224 async function syncAllEmailAccounts() {
225 try {
226 const accounts = await GoingsOn.api.emailAccounts.list();
227 if (accounts.length === 0) {
228 GoingsOn.ui.showToast('No email accounts configured', 'info');
229 return;
230 }
231 GoingsOn.ui.showToast('Syncing email accounts...', 'info');
232 for (const account of accounts) {
233 await GoingsOn.api.emailAccounts.sync(account.id, false);
234 }
235 GoingsOn.ui.showToast('Email sync complete!', 'success');
236 GoingsOn.emails.load();
237 } catch (err) {
238 GoingsOn.ui.showToast('Email sync failed: ' + GoingsOn.utils.getErrorMessage(err), 'error', {
239 action: { label: 'Retry', fn: syncAllEmailAccounts },
240 duration: 8000,
241 });
242 }
243 }
244
245 // ============ About Modal ============
246
247 async function openAboutModal() {
248 let appVersion = "unknown";
249 try { appVersion = await window.__TAURI__.app.getVersion(); } catch (_) {}
250 const content = `
251 <div class="about-panel">
252 <h2 class="about-title">GoingsOn</h2>
253 <p class="about-tagline">Tasks, email, calendar, contacts.</p>
254 <p class="about-version">Version ${appVersion}</p>
255 <dl class="about-info-list">
256 <dt>Publisher</dt><dd>Make Creative, LLC</dd>
257 <dt>License</dt><dd>PolyForm Noncommercial 1.0.0</dd>
258 <dt>Contact</dt><dd><a href="mailto:info@makenot.work">info@makenot.work</a></dd>
259 <dt>Source</dt><dd><a href="https://makenot.work" target="_blank" rel="noopener">makenot.work</a></dd>
260 <dt>Privacy</dt><dd><a href="https://makenot.work/policy" target="_blank" rel="noopener">makenot.work/policy</a></dd>
261 </dl>
262 <p class="about-copyright">&copy; 2026 Make Creative, LLC</p>
263 </div>
264 <div class="form-actions">
265 <button type="button" class="btn btn-secondary" onclick="GoingsOn.ui.closeModal()">Close</button>
266 </div>
267 `;
268 GoingsOn.ui.openModal('About', content);
269 }
270
271 // ============ Populate GoingsOn.app Namespace ============
272
273 function showWelcome() {
274 const isTouch = !!GoingsOn.touch?.isTouchDevice;
275 const step1 = isTouch
276 ? '<strong>1.</strong> Create your first task &mdash; tap the <strong>+</strong> tab to quick add'
277 : '<strong>1.</strong> Create your first task &mdash; press <kbd>q</kbd> for quick add';
278 const step2 = isTouch
279 ? '<strong>2.</strong> Plan your day &mdash; tap or long-press the timeline to schedule'
280 : '<strong>2.</strong> Plan your day &mdash; drag tasks onto the timeline';
281 const shortcutsHint = isTouch
282 ? ''
283 : '<p class="welcome-hint">Press <kbd>?</kbd> anytime for keyboard shortcuts.</p>';
284 const content = `
285 <div class="welcome-panel">
286 <p class="welcome-intro">
287 GoingsOn brings your tasks, email, calendar, and contacts into one place.
288 </p>
289 <div class="welcome-section">
290 <h3 class="welcome-subhead">Get Started</h3>
291 <div class="welcome-step-stack">
292 <button class="btn btn-secondary text-left" onclick="localStorage.setItem('go-welcomed', '1'); GoingsOn.ui.closeModal(); GoingsOn.keyboard.openQuickAdd();">${step1}</button>
293 <button class="btn btn-secondary text-left" onclick="localStorage.setItem('go-welcomed', '1'); GoingsOn.ui.closeModal(); GoingsOn.navigation.switchView('day-plan');">${step2}</button>
294 <button class="btn btn-secondary text-left" onclick="localStorage.setItem('go-welcomed', '1'); GoingsOn.ui.closeModal(); GoingsOn.emails.openAccountsModal();"><strong>3.</strong> Add an email account</button>
295 </div>
296 </div>
297 <div class="welcome-section">
298 <h3 class="welcome-subhead welcome-subhead--tight">Three Tabs</h3>
299 <ul class="welcome-tabs-list">
300 <li><strong>Work</strong> &mdash; tasks &amp; projects</li>
301 <li><strong>Time</strong> &mdash; day plan, weekly review &amp; calendar</li>
302 <li><strong>Messages</strong> &mdash; email &amp; contacts</li>
303 </ul>
304 </div>
305 ${shortcutsHint}
306 </div>
307 <div class="form-actions">
308 <button class="btn btn-primary" onclick="localStorage.setItem('go-welcomed', '1'); GoingsOn.ui.closeModal()">Get Started</button>
309 </div>
310 `;
311 GoingsOn.ui.openModal('Welcome to GoingsOn', content);
312 }
313
314 /**
315 * Show a one-time dismissible hint toast. Sets localStorage key so it only shows once.
316 */
317 function showHint(storageKey, message) {
318 if (localStorage.getItem(storageKey)) return;
319 localStorage.setItem(storageKey, '1');
320 GoingsOn.ui.showToast(message, 'info', { duration: 5000 });
321 }
322
323 GoingsOn.app = {
324 toggleSidebar,
325 syncAllEmailAccounts,
326 openAboutModal,
327 refreshCurrentViewData,
328 showWelcome,
329 showHint,
330 };
331
332 // ============ Background/Foreground Transitions ============
333 // On mobile (and laptop sleep/wake), the app can sit hidden for arbitrary
334 // durations. Browsers throttle JS while hidden; the bigger issue is that
335 // rendered state (current time, sync status, lists) is stale on resume.
336 // If the app was hidden long enough that anything time-sensitive could have
337 // shifted, refresh on visibility return.
338 (function wireVisibilityRefresh() {
339 const STALE_THRESHOLD_MS = 30_000;
340 let hiddenAt = null;
341
342 document.addEventListener('visibilitychange', () => {
343 if (document.hidden) {
344 hiddenAt = Date.now();
345 return;
346 }
347 if (hiddenAt == null) return;
348 const hiddenFor = Date.now() - hiddenAt;
349 hiddenAt = null;
350 if (hiddenFor < STALE_THRESHOLD_MS) return;
351
352 // Refresh data and time-sensitive UI. Skip if a modal is open —
353 // refreshCurrentViewData already guards against that.
354 GoingsOn.cache?.invalidateAll?.();
355 refreshCurrentViewData();
356 GoingsOn.settings?.refreshSyncIndicator?.();
357 if (GoingsOn.dayPlanning?.updateCurrentTimeIndicator) {
358 GoingsOn.dayPlanning.updateCurrentTimeIndicator(true);
359 }
360 });
361 })();
362
363 })();
364