/**
* GoingsOn - App Bootstrap Module
* DOMContentLoaded, initial data loading, Tauri menu listeners
*/
(function() {
'use strict';
// How long after a JS-initiated DB write to treat a db:external-change event as the
// app's own echo and skip it (CHRONIC-E). Comfortably covers the watcher's debounce
// (it rate-limits to ~1/sec plus a trailing emit) without masking a genuinely
// external write that arrives later.
const LOCAL_WRITE_SUPPRESS_MS = 2500;
// Maps a synced DB table (from the `sync:changes-applied` payload) to the view
// cache entities it affects. A table mapping to `[]` has no cached view to bust
// (the re-render alone refetches it). Any table NOT present here triggers a full
// cache bust — keep this in sync with the backend `UPSERT_ORDER` list.
const SYNC_TABLE_TO_ENTITIES = {
projects: ['projects'],
milestones: ['projects'],
tasks: ['tasks'],
subtasks: ['tasks'],
annotations: ['tasks'],
time_sessions: ['tasks'],
attachments: ['tasks', 'projects'],
events: ['events'],
contacts: ['contacts'],
contact_emails: ['contacts'],
contact_phones: ['contacts'],
contact_social_handles: ['contacts'],
contact_custom_fields: ['contacts'],
email_accounts: ['emails'],
sync_accounts: [],
daily_notes: [],
};
// ============ Application Initialization ============
document.addEventListener('DOMContentLoaded', async () => {
// Check if api is available
if (!GoingsOn.api) {
console.error('API not available');
const errorTarget = document.getElementById('projects-grid') || document.getElementById('task-list-container');
if (errorTarget) errorTarget.innerHTML =
'
GoingsOn failed to start. Please relaunch the app. If this persists, contact info@makenot.work.
';
return;
}
// Load projects cache first (needed for task dropdowns)
try {
const projects = await GoingsOn.api.projects.list();
GoingsOn.projects.setCache(projects);
} catch (err) {
console.error('Failed to load projects:', err);
}
// Load email accounts cache
try {
const accounts = await GoingsOn.api.emailAccounts.list();
GoingsOn.emails.setAccountsCache(accounts);
} catch (err) {
console.error('Failed to load email accounts:', err);
}
// Initialize router (handles initial view from URL)
if (GoingsOn.router && typeof GoingsOn.router.init === 'function') {
GoingsOn.router.init();
} else {
// Fallback if router not available
GoingsOn.tasks.load();
}
// First-run welcome
if (!localStorage.getItem('go-welcomed')) {
showWelcome();
} else if (!localStorage.getItem('go-hint-shortcuts')) {
// One-time hint after first session
setTimeout(() => showHint('go-hint-shortcuts', 'Press ? anytime to see keyboard shortcuts'), 2000);
}
// After an OTA update, surface this version's changelog once. No-ops on a
// matching version and records first-launch silently (welcome owns that).
if (GoingsOn.whatsNew && typeof GoingsOn.whatsNew.maybeShow === 'function') {
GoingsOn.whatsNew.maybeShow();
}
// Check weekly review nudge on startup
if (GoingsOn.weeklyReview && typeof GoingsOn.weeklyReview.checkNudge === 'function') {
GoingsOn.weeklyReview.checkNudge();
}
// Start event status indicator polling
if (GoingsOn.events && typeof GoingsOn.events.startEventStatusPolling === 'function') {
GoingsOn.events.startEventStatusPolling();
}
// Initialize sync status indicator
if (GoingsOn.settings && typeof GoingsOn.settings.refreshSyncIndicator === 'function') {
GoingsOn.settings.refreshSyncIndicator();
}
// Initialize time tracking widget (check for active timer)
if (GoingsOn.timeTracking && typeof GoingsOn.timeTracking.init === 'function') {
GoingsOn.timeTracking.init();
}
});
// Initialize theme on page load
document.addEventListener('DOMContentLoaded', () => {
if (GoingsOn.themes && typeof GoingsOn.themes.loadFromStorage === 'function') {
GoingsOn.themes.loadFromStorage();
}
});
// Close dropdowns when clicking outside
document.addEventListener('click', (e) => {
// If click is not on a dropdown button, close all dropdowns
if (!e.target.closest('.dropdown')) {
document.querySelectorAll('.dropdown-menu.show').forEach(menu => {
menu.classList.remove('show');
});
}
});
// ============ Native Menu Bar Event Handlers ============
// Initialize menu event listeners when Tauri is available
if (window.__TAURI__) {
const { listen } = window.__TAURI__.event;
// Compose → main app: queue a send with an undo window.
// Fired by compose.html sendEmail() so the compose window can close
// immediately while the main app holds the 5 s undo toast.
listen('compose:queue-send', (event) => {
const payload = event?.payload || {};
if (payload.input) {
GoingsOn.emails.queueSend({
input: payload.input,
delaySeconds: payload.delaySeconds || 5,
});
}
});
// File menu
listen('menu:new_task', () => GoingsOn.tasks.openNew());
listen('menu:new_project', () => GoingsOn.projects.openNew());
listen('menu:import', () => GoingsOn.import.openModal());
listen('menu:save_view', () => GoingsOn.savedViews?.openSaveModal?.());
// View menu - tab shortcuts
listen('menu:view_work', () => GoingsOn.navigation.switchView('work'));
listen('menu:view_time', () => GoingsOn.navigation.switchView('time'));
listen('menu:view_messages', () => GoingsOn.navigation.switchView('messages'));
// View menu - individual sub-views
listen('menu:view_projects', () => GoingsOn.navigation.switchView('projects'));
listen('menu:view_tasks', () => GoingsOn.navigation.switchView('tasks'));
listen('menu:view_events', () => GoingsOn.navigation.switchView('events'));
listen('menu:view_emails', () => GoingsOn.navigation.switchView('emails'));
listen('menu:view_contacts', () => GoingsOn.navigation.switchView('contacts'));
listen('menu:view_day_plan', () => GoingsOn.navigation.switchView('day-plan'));
listen('menu:view_weekly_review', () => GoingsOn.navigation.switchView('weekly-review'));
listen('menu:view_monthly_review', () => GoingsOn.navigation.switchView('monthly-review'));
listen('menu:toggle_sidebar', () => GoingsOn.app.toggleSidebar());
// Tools menu
listen('menu:sync_email', () => GoingsOn.app.syncAllEmailAccounts());
listen('menu:settings', () => GoingsOn.settings.open());
// Help menu
listen('menu:keyboard_shortcuts', () => GoingsOn.keyboard.toggleShortcuts());
listen('menu:about', () => GoingsOn.app.openAboutModal());
// Database external change detection.
//
// The watcher fires on ANY change to the DB file, including this app's own
// writes. A JS-initiated mutation already refreshed the UI, so the echo here
// would be a redundant invalidate-everything storm (CHRONIC-E). Skip it when a
// local write happened in the last LOCAL_WRITE_SUPPRESS_MS. A genuinely external
// write (e.g. the background email-sync scheduler, which runs in Rust and never
// marks a JS write) is NOT recent-marked, so it still refreshes.
listen('db:external-change', () => {
const sinceLocalWrite = Date.now() - (window.__goLastLocalWriteAt || 0);
if (sinceLocalWrite < LOCAL_WRITE_SUPPRESS_MS) {
return;
}
GoingsOn.cache.invalidateAll();
refreshCurrentViewData();
});
// Cloud sync: remote changes applied. The payload is the list of DB tables
// the pull touched; invalidate only the affected cache entities so an
// unrelated change (e.g. a task edit) doesn't force the compose screen to
// re-hydrate every contact. Unknown/absent payload falls back to a full bust.
listen('sync:changes-applied', (event) => {
const tables = Array.isArray(event.payload) ? event.payload : null;
if (!tables) {
GoingsOn.cache.invalidateAll();
} else {
const entities = new Set();
let unknown = false;
for (const table of tables) {
if (table in SYNC_TABLE_TO_ENTITIES) {
for (const e of SYNC_TABLE_TO_ENTITIES[table]) entities.add(e);
} else {
unknown = true; // be safe about tables we don't have a mapping for
}
}
if (unknown) {
GoingsOn.cache.invalidateAll();
} else if (entities.size) {
GoingsOn.cache.invalidate(...entities);
}
}
// Cloud sync already refreshed selectively here; mark it so the redundant
// db:external-change echo from the same writes is suppressed (CHRONIC-E).
window.__goLastLocalWriteAt = Date.now();
refreshCurrentViewData();
});
// Cloud sync: subscription required (402 from server)
listen('sync:subscription-required', () => {
GoingsOn.ui.showToast('Cloud sync paused — subscription required', 'error', {
action: { label: 'Subscribe', fn: () => GoingsOn.settings.openCloudSync() },
duration: 10000,
});
});
// Cloud sync: status changed (syncing/idle/error)
listen('sync:status-changed', (event) => {
const dot = document.getElementById('sync-dot');
const indicator = document.getElementById('sync-indicator');
if (!dot || !indicator) return;
indicator.classList.remove('hidden');
dot.className = 'sync-dot';
if (event.payload === 'syncing') {
dot.classList.add('syncing');
} else if (event.payload === 'error') {
dot.classList.add('error');
} else {
dot.classList.add('connected');
}
});
}
// ============ External Change Handler ============
/**
* Refresh the current view's data without full navigation.
* Used when external changes are detected (e.g., an external process modified the database).
*/
async function refreshCurrentViewData() {
// Don't refresh if a modal is open (user is editing something)
if (document.querySelector('.modal:not(.hidden)')) {
return;
}
const currentView = GoingsOn.navigation?.getCurrentView?.() || 'tasks';
try {
// Refresh projects cache first (needed for dropdowns)
const projects = await GoingsOn.api.projects.list();
GoingsOn.projects.setCache(projects);
// Reload the current view's data
await GoingsOn.navigation.loadViewData(currentView);
} catch (err) {
console.error('Failed to refresh view after external change:', err);
}
}
// ============ Sidebar Toggle ============
function toggleSidebar() {
const sidebar = document.querySelector('.saved-views-sidebar');
if (sidebar) {
sidebar.classList.toggle('hidden');
}
}
// ============ Email Sync ============
async function syncAllEmailAccounts() {
try {
const accounts = await GoingsOn.api.emailAccounts.list();
if (accounts.length === 0) {
GoingsOn.ui.showToast('No email accounts configured', 'info');
return;
}
// Persistent progress modal: this loops a blocking IMAP fetch per
// account and can run for minutes. Without it the app looks frozen.
const plural = accounts.length === 1 ? 'account' : 'accounts';
GoingsOn.emails.showSyncProgressModal(`Syncing ${accounts.length} email ${plural}...`);
for (const account of accounts) {
await GoingsOn.api.emailAccounts.sync(account.id, false);
}
GoingsOn.ui.closeModal();
GoingsOn.ui.showToast('Email sync complete!', 'success');
GoingsOn.emails.load();
} catch (err) {
GoingsOn.ui.closeModal();
GoingsOn.ui.showToast('Email sync failed: ' + GoingsOn.utils.getErrorMessage(err), 'error', {
action: { label: 'Retry', fn: syncAllEmailAccounts },
duration: 8000,
});
}
}
// ============ About Modal ============
async function openAboutModal() {
let appVersion = "unknown";
try { appVersion = await window.__TAURI__.app.getVersion(); } catch (_) {}
const content = `
`;
GoingsOn.ui.openModal('About', content);
}
// ============ Populate GoingsOn.app Namespace ============
function showWelcome() {
const isTouch = !!GoingsOn.touch?.isTouchDevice;
const step1 = isTouch
? '1. Create your first task — tap the + tab to quick add'
: '1. Create your first task — press q for quick add';
const step2 = isTouch
? '2. Plan your day — tap or long-press the timeline to schedule'
: '2. Plan your day — drag tasks onto the timeline';
const shortcutsHint = isTouch
? ''
: '
Press ? anytime for keyboard shortcuts.
';
const content = `
GoingsOn brings your tasks, email, calendar, and contacts into one place.
Get Started
Three Tabs
Work — tasks & projects
Time — day plan, weekly review & calendar
Messages — email & contacts
${shortcutsHint}
`;
GoingsOn.ui.openModal('Welcome to GoingsOn', content);
}
/**
* Show a one-time dismissible hint toast. Sets localStorage key so it only shows once.
*/
function showHint(storageKey, message) {
if (localStorage.getItem(storageKey)) return;
localStorage.setItem(storageKey, '1');
GoingsOn.ui.showToast(message, 'info', { duration: 5000 });
}
GoingsOn.app = {
toggleSidebar,
syncAllEmailAccounts,
openAboutModal,
refreshCurrentViewData,
showWelcome,
showHint,
};
// ============ Background/Foreground Transitions ============
// On mobile (and laptop sleep/wake), the app can sit hidden for arbitrary
// durations. Browsers throttle JS while hidden; the bigger issue is that
// rendered state (current time, sync status, lists) is stale on resume.
// If the app was hidden long enough that anything time-sensitive could have
// shifted, refresh on visibility return.
(function wireVisibilityRefresh() {
const STALE_THRESHOLD_MS = 30_000;
let hiddenAt = null;
document.addEventListener('visibilitychange', () => {
if (document.hidden) {
hiddenAt = Date.now();
return;
}
if (hiddenAt == null) return;
const hiddenFor = Date.now() - hiddenAt;
hiddenAt = null;
if (hiddenFor < STALE_THRESHOLD_MS) return;
// Refresh data and time-sensitive UI. Skip if a modal is open —
// refreshCurrentViewData already guards against that.
GoingsOn.cache?.invalidateAll?.();
refreshCurrentViewData();
GoingsOn.settings?.refreshSyncIndicator?.();
if (GoingsOn.dayPlanning?.updateCurrentTimeIndicator) {
GoingsOn.dayPlanning.updateCurrentTimeIndicator(true);
}
});
})();
})();