Skip to main content

max / goingson

21.6 KB · 476 lines History Blame Raw
1 /**
2 * GoingsOn - Settings Module
3 * Settings page with sidebar navigation, plugin manager
4 */
5
6 (function() {
7 'use strict';
8 const esc = GoingsOn.utils.escapeHtml;
9 const escAttr = GoingsOn.utils.escapeAttr;
10
11 let currentSection = 'appearance';
12
13 // ============ Settings Page ============
14
15 /**
16 * Open settings as a modeless overlay above the current view. The
17 * underlying app stays mounted so theme/notification/account changes
18 * preview live against the real surfaces. (Phase 7 Tier 6.)
19 */
20 async function openSettings() {
21 const overlay = document.getElementById('settings-overlay');
22 if (!overlay) return;
23 overlay.classList.remove('hidden');
24 overlay.setAttribute('aria-hidden', 'false');
25 document.addEventListener('keydown', handleSettingsKeydown);
26 await showSection(currentSection);
27 }
28
29 /**
30 * Called by navigation.loadViewData when settings view becomes active.
31 * Renders the last-active section. Retained for back-compat with any
32 * remaining callers; new code should call openSettings.
33 */
34 async function loadSettings() {
35 await showSection(currentSection);
36 }
37
38 /**
39 * Close the settings overlay. Returns the user to whatever view was
40 * underneath — no view switch needed since settings is now an overlay,
41 * not a navigated subview.
42 */
43 function goBack() {
44 const overlay = document.getElementById('settings-overlay');
45 if (!overlay) return;
46 overlay.classList.add('hidden');
47 overlay.setAttribute('aria-hidden', 'true');
48 document.removeEventListener('keydown', handleSettingsKeydown);
49 }
50
51 function handleSettingsKeydown(e) {
52 if (e.key !== 'Escape') return;
53 const isTyping = ['INPUT', 'TEXTAREA', 'SELECT'].includes(e.target.tagName)
54 || e.target.isContentEditable;
55 if (isTyping) return;
56 goBack();
57 }
58
59 /**
60 * Show a specific settings section and update sidebar active state.
61 * @param {string} section - Section name (appearance, notifications, planning, plugins, sync, data)
62 */
63 async function showSection(section) {
64 currentSection = section;
65
66 // Update sidebar active state
67 document.querySelectorAll('.settings-nav-item').forEach(btn => {
68 btn.classList.toggle('active', btn.dataset.section === section);
69 });
70
71 const container = document.getElementById('settings-content');
72 if (!container) return;
73
74 switch (section) {
75 case 'appearance': await renderAppearance(container); break;
76 case 'notifications': renderNotifications(container); break;
77 case 'email': await GoingsOn.emails.renderAccountsSection(container); break;
78 case 'planning': renderPlanning(container); break;
79 case 'plugins': await renderPlugins(container); break;
80 case 'sync': await renderSync(container); break;
81 case 'data': await renderData(container); break;
82 case 'about': await renderAbout(container); break;
83 }
84 }
85
86 // ============ Section Renderers ============
87
88 async function renderAppearance(container) {
89 const savedTheme = localStorage.getItem('goingson-theme') || 'system';
90 const { light: lightThemes, dark: darkThemes, highContrast: highContrastThemes } = await GoingsOn.themes.getByType();
91
92 const highContrastGroup = highContrastThemes.length > 0
93 ? `<optgroup label="High Contrast">
94 ${highContrastThemes.map(t => `<option value="${t.id}" ${savedTheme === t.id ? 'selected' : ''}>${esc(t.name)}</option>`).join('')}
95 </optgroup>`
96 : '';
97
98 container.innerHTML = `
99 <div class="settings-section">
100 <h3 class="settings-heading">Appearance</h3>
101 <div class="form-group">
102 <label class="form-label">Theme</label>
103 <select id="theme-selector" class="form-select" onchange="GoingsOn.themes.onChange(this.value)">
104 <optgroup label="System">
105 <option value="system" ${savedTheme === 'system' ? 'selected' : ''}>Follow System</option>
106 </optgroup>
107 <optgroup label="Light Themes">
108 ${lightThemes.map(t => `<option value="${t.id}" ${savedTheme === t.id ? 'selected' : ''}>${esc(t.name)}</option>`).join('')}
109 </optgroup>
110 <optgroup label="Dark Themes">
111 ${darkThemes.map(t => `<option value="${t.id}" ${savedTheme === t.id ? 'selected' : ''}>${esc(t.name)}</option>`).join('')}
112 </optgroup>
113 ${highContrastGroup}
114 </select>
115 <p class="form-hint" style="margin-top: 0.5rem;">Choose a color theme for the interface.</p>
116 <div class="row-flex row-flex-2" style="margin-top: 0.5rem;">
117 <button class="btn btn-small" onclick="GoingsOn.themes.importTheme()">Import Theme</button>
118 <button class="btn btn-small" onclick="GoingsOn.themes.exportTheme()">Export Current</button>
119 </div>
120 </div>
121 </div>
122 `;
123 }
124
125 function renderNotifications(container) {
126 container.innerHTML = `
127 <div class="settings-section">
128 <h3 class="settings-heading">Notifications</h3>
129 <div class="form-group">
130 <label class="form-label">Event indicator lead time</label>
131 <select id="event-lead-time-selector" class="form-select" onchange="GoingsOn.settings.onEventLeadTimeChange(this.value)">
132 ${[5, 10, 15, 30, 60].map(m => {
133 const currentLead = parseInt(localStorage.getItem('goingson-event-lead-minutes') || '15', 10);
134 const label = m === 60 ? '1 hour' : m + ' minutes';
135 const suffix = m === 15 ? ' (default)' : '';
136 return '<option value="' + m + '"' + (currentLead === m ? ' selected' : '') + '>' + label + suffix + '</option>';
137 }).join('')}
138 </select>
139 <p class="form-hint" style="margin-top: 0.5rem;">How far in advance the Events tab dot turns yellow.</p>
140 </div>
141 </div>
142 `;
143 }
144
145 function renderPlanning(container) {
146 container.innerHTML = `
147 <div class="settings-section">
148 <h3 class="settings-heading">Planning & Review</h3>
149 <div class="form-group">
150 <label class="form-label">Work hours</label>
151 <div class="work-hours-row">
152 <select class="form-select" onchange="GoingsOn.settings.onWorkHoursChange('start', this.value)">
153 ${buildHourOptions(parseInt(localStorage.getItem('goingson-work-start-hour') || '9', 10))}
154 </select>
155 <span>to</span>
156 <select class="form-select" onchange="GoingsOn.settings.onWorkHoursChange('end', this.value)">
157 ${buildHourOptions(parseInt(localStorage.getItem('goingson-work-end-hour') || '17', 10))}
158 </select>
159 </div>
160 <p class="form-hint" style="margin-top: 0.5rem;">Controls when plan/review nudge dots appear.</p>
161 </div>
162 <div class="form-group">
163 <label class="form-label">Plan nudges</label>
164 <select class="form-select" onchange="localStorage.setItem('goingson-plan-nudges', this.value)">
165 <option value="enabled" ${localStorage.getItem('goingson-plan-nudges') !== 'disabled' ? 'selected' : ''}>Enabled (default)</option>
166 <option value="disabled" ${localStorage.getItem('goingson-plan-nudges') === 'disabled' ? 'selected' : ''}>Disabled</option>
167 </select>
168 </div>
169 <div class="form-group">
170 <label class="form-label">Review nudges</label>
171 <select class="form-select" onchange="localStorage.setItem('goingson-review-nudges', this.value)">
172 <option value="enabled" ${localStorage.getItem('goingson-review-nudges') !== 'disabled' ? 'selected' : ''}>Enabled (default)</option>
173 <option value="disabled" ${localStorage.getItem('goingson-review-nudges') === 'disabled' ? 'selected' : ''}>Disabled</option>
174 </select>
175 </div>
176 </div>
177 `;
178 }
179
180 async function renderPlugins(container) {
181 let allPlugins = [];
182 let enabledPlugins = [];
183
184 try {
185 [allPlugins, enabledPlugins] = await Promise.all([
186 GoingsOn.api.plugins.listImport(),
187 GoingsOn.api.plugins.listEnabled(),
188 ]);
189 } catch (err) {
190 container.innerHTML = `<p class="text-danger">Failed to load plugins: ${esc(GoingsOn.utils.getErrorMessage(err))}</p>`;
191 return;
192 }
193
194 const enabledIds = new Set(enabledPlugins.map(p => p.id));
195 const pluginsList = allPlugins.length === 0
196 ? '<p class="empty-italic">No plugins installed. Drop a plugin file into the GoingsOn plugins directory inside your OS app-data location.</p>'
197 : allPlugins.map(plugin => renderPluginItem(plugin, enabledIds.has(plugin.id))).join('');
198
199 container.innerHTML = `
200 <div class="settings-section">
201 <h3 class="settings-heading">Plugins</h3>
202 <p class="settings-desc">
203 Plugins extend GoingsOn with support for importing data from various formats.
204 </p>
205 <div class="plugin-list" id="plugin-list">
206 ${pluginsList}
207 </div>
208 </div>
209 `;
210 }
211
212 async function renderSync(container) {
213 // Delegate to settings-sync.js which extends GoingsOn.settings
214 if (GoingsOn.settings.renderSyncSection) {
215 await GoingsOn.settings.renderSyncSection(container);
216 } else {
217 container.innerHTML = '<p class="text-secondary">Cloud sync module not loaded.</p>';
218 }
219 }
220
221 async function renderData(container) {
222 // Build backup settings inline
223 let backupHtml = '';
224 try {
225 const settings = await GoingsOn.api.export.getBackupSettings();
226 const lastBackupText = settings.lastBackupAt
227 ? `Last backup: ${new Date(settings.lastBackupAt).toLocaleString()}`
228 : 'No backups yet';
229
230 const frequencyOptions = [
231 { value: 15, label: 'Every 15 minutes (Recommended)' },
232 { value: 30, label: 'Every 30 minutes' },
233 { value: 60, label: 'Every hour' },
234 { value: 360, label: 'Every 6 hours' },
235 { value: 1440, label: 'Daily' },
236 ];
237
238 const retentionOptions = [
239 { value: 1, label: 'Keep 1 backup (Recommended)' },
240 { value: 3, label: 'Keep 3 backups' },
241 { value: 7, label: 'Keep 7 backups' },
242 { value: 14, label: 'Keep 14 backups' },
243 { value: 0, label: 'Keep all backups' },
244 ];
245
246 const renderFormField = GoingsOn.ui.renderFormField;
247 backupHtml = `
248 <div class="settings-section-block">
249 <h4 class="settings-subheading">Backups</h4>
250 <div class="settings-actions-row">
251 <button class="btn btn-secondary" onclick="GoingsOn.export.createBackup()">Create Backup</button>
252 <button class="btn btn-secondary" onclick="GoingsOn.export.openBackupsModal()">Manage Backups</button>
253 </div>
254
255 <h4 class="settings-subheading">Automatic Backups</h4>
256 <p class="settings-desc-block">
257 Automatic backups protect your data by creating compressed snapshots on a schedule.
258 </p>
259
260 <div class="form-group">
261 <label class="form-checkbox-label">
262 <input type="checkbox" id="backup-enabled" ${settings.autoBackupEnabled ? 'checked' : ''}>
263 <span>Enable automatic backups</span>
264 </label>
265 </div>
266 ${renderFormField({
267 kind: 'select',
268 name: 'backup-frequency',
269 id: 'backup-frequency',
270 label: 'Backup Frequency',
271 value: settings.backupFrequencyMinutes,
272 options: frequencyOptions.map(o => ({
273 value: String(o.value),
274 label: o.label,
275 selected: settings.backupFrequencyMinutes === o.value,
276 })),
277 })}
278 ${renderFormField({
279 kind: 'select',
280 name: 'backup-retention',
281 id: 'backup-retention',
282 label: 'Retention Policy',
283 value: settings.maxBackupsToKeep,
284 options: retentionOptions.map(o => ({
285 value: String(o.value),
286 label: o.label,
287 selected: settings.maxBackupsToKeep === o.value,
288 })),
289 hint: 'Older backups are automatically deleted to save space.',
290 })}
291 <div class="settings-actions-row settings-actions-row--center">
292 <button class="btn btn-primary" onclick="GoingsOn.export.saveBackupSettings()">Save Backup Settings</button>
293 <span class="settings-status-text">${esc(lastBackupText)}</span>
294 </div>
295 </div>
296 `;
297 } catch (err) {
298 backupHtml = `
299 <div class="settings-section-block">
300 <h4 class="settings-subheading">Backups</h4>
301 <div class="settings-actions-row">
302 <button class="btn btn-secondary" onclick="GoingsOn.export.createBackup()">Create Backup</button>
303 <button class="btn btn-secondary" onclick="GoingsOn.export.openBackupsModal()">Manage Backups</button>
304 </div>
305 </div>
306 `;
307 }
308
309 container.innerHTML = `
310 <div class="settings-section">
311 <h3 class="settings-heading">Import & Export</h3>
312 <p class="settings-desc">
313 Import data from external sources or export for safekeeping.
314 </p>
315
316 <div class="settings-actions-row">
317 <button class="btn btn-secondary" onclick="GoingsOn.importExternal.openDialog();">Import Contacts / Calendar</button>
318 <button class="btn btn-secondary" onclick="GoingsOn.import.openModal();">Import via Plugin</button>
319 </div>
320
321 <div class="settings-actions-row">
322 <button class="btn btn-secondary" onclick="GoingsOn.export.exportJSON()">Export All (JSON)</button>
323 <button class="btn btn-secondary" onclick="GoingsOn.export.exportTasksCSV()">Export Tasks (CSV)</button>
324 <button class="btn btn-secondary" onclick="GoingsOn.export.exportEventsICS()">Export Calendar (ICS)</button>
325 </div>
326
327 ${backupHtml}
328 </div>
329 `;
330 }
331
332 // ============ About Section ============
333
334 /**
335 * Phase 7 Tier 2 #7 — surface the version + support info in Settings rather
336 * than only in the one-shot welcome modal. Required for support flows.
337 */
338 async function setUpdateCheckOnLaunch(enabled) {
339 try {
340 await GoingsOn.api.preferences.setUpdateCheckOnLaunch(enabled);
341 GoingsOn.ui.showToast(enabled ? 'Update checks enabled' : 'Update checks disabled');
342 } catch (err) {
343 GoingsOn.ui.showToast('Failed to save preference: ' + GoingsOn.utils.getErrorMessage(err), 'error');
344 }
345 }
346
347 async function renderAbout(container) {
348 let appVersion = 'unknown';
349 try { appVersion = await window.__TAURI__.app.getVersion(); } catch (_) {}
350
351 const platform = (navigator.userAgentData?.platform || navigator.platform || 'unknown');
352
353 let updateCheckOnLaunch = true;
354 try {
355 const prefs = await GoingsOn.api.preferences.get();
356 updateCheckOnLaunch = prefs.updateCheckOnLaunch !== false;
357 } catch (_) {}
358
359 container.innerHTML = `
360 <div class="settings-section">
361 <h3 class="settings-heading">About GoingsOn</h3>
362 <p class="settings-desc">Tasks, email, calendar, contacts.</p>
363
364 <dl class="about-info-list">
365 <dt>Version</dt><dd id="about-app-version">${esc(appVersion)}</dd>
366 <dt>Platform</dt><dd>${esc(platform)}</dd>
367 <dt>Publisher</dt><dd>Make Creative, LLC</dd>
368 <dt>License</dt><dd>PolyForm Noncommercial 1.0.0</dd>
369 <dt>Contact</dt><dd><a href="mailto:info@makenot.work">info@makenot.work</a></dd>
370 <dt>Source</dt><dd><a href="https://makenot.work" target="_blank" rel="noopener">makenot.work</a></dd>
371 <dt>Privacy</dt><dd><a href="https://makenot.work/policy" target="_blank" rel="noopener">makenot.work/policy</a></dd>
372 </dl>
373
374 <div class="settings-toggle-row">
375 <div>
376 <p class="settings-subheading">Check for updates on launch</p>
377 <p class="settings-desc">When a new signed release is available, a banner appears in the app. Install is always user-initiated.</p>
378 </div>
379 <label class="toggle-switch">
380 <input type="checkbox" ${updateCheckOnLaunch ? 'checked' : ''}
381 onchange="GoingsOn.settings.setUpdateCheckOnLaunch(this.checked)">
382 <span class="toggle-slider"></span>
383 </label>
384 </div>
385
386 <p class="settings-desc-block about-copyright">&copy; 2026 Make Creative, LLC</p>
387 </div>
388 `;
389 }
390
391 // ============ Plugin Item Renderer ============
392
393 function renderPluginItem(plugin, isEnabled) {
394 const config = plugin.plugin_type?.import || {};
395 const extensions = config.file_extensions || [];
396 const entityTypes = config.entity_types || [];
397
398 return `
399 <div class="plugin-item" data-plugin-id="${escAttr(plugin.id)}">
400 <div class="plugin-info">
401 <span class="plugin-name">${esc(plugin.name)}</span>
402 <span class="plugin-version">v${esc(plugin.version)}</span>
403 <p class="plugin-description">${esc(plugin.description)}</p>
404 <span class="plugin-extensions">
405 Files: .${extensions.join(', .')} | Types: ${entityTypes.join(', ')}
406 </span>
407 </div>
408 <div class="plugin-actions">
409 <label class="toggle-switch">
410 <input type="checkbox" ${isEnabled ? 'checked' : ''}
411 onchange="GoingsOn.settings.togglePlugin('${escAttr(plugin.id)}', this.checked)">
412 <span class="toggle-slider"></span>
413 </label>
414 </div>
415 </div>
416 `;
417 }
418
419 // ============ Plugin Toggle ============
420
421 async function togglePlugin(pluginId, enabled) {
422 try {
423 if (enabled) {
424 await GoingsOn.api.plugins.enable(pluginId);
425 GoingsOn.ui.showToast('Plugin enabled');
426 } else {
427 await GoingsOn.api.plugins.disable(pluginId);
428 GoingsOn.ui.showToast('Plugin disabled');
429 }
430 } catch (err) {
431 GoingsOn.ui.showToast('Failed to update plugin: ' + GoingsOn.utils.getErrorMessage(err), 'error');
432 // Re-render plugins section to show correct state
433 const container = document.getElementById('settings-content');
434 if (container) await renderPlugins(container);
435 }
436 }
437
438 // ============ Helpers ============
439
440 function onEventLeadTimeChange(value) {
441 localStorage.setItem('goingson-event-lead-minutes', value);
442 if (GoingsOn.events && GoingsOn.events.updateEventStatusDot) {
443 GoingsOn.events.updateEventStatusDot();
444 }
445 }
446
447 function buildHourOptions(selected) {
448 let html = '';
449 for (let h = 0; h < 24; h++) {
450 const label = h === 0 ? '12:00 AM' : h < 12 ? `${h}:00 AM` : h === 12 ? '12:00 PM' : `${h - 12}:00 PM`;
451 html += `<option value="${h}" ${h === selected ? 'selected' : ''}>${label}</option>`;
452 }
453 return html;
454 }
455
456 function onWorkHoursChange(which, value) {
457 const key = which === 'start' ? 'goingson-work-start-hour' : 'goingson-work-end-hour';
458 localStorage.setItem(key, value);
459 }
460
461 // ============ Populate GoingsOn Namespace ============
462
463 GoingsOn.settings = {
464 open: openSettings,
465 load: loadSettings,
466 showSection,
467 goBack,
468 openPluginsModal: () => showSection('plugins'),
469 togglePlugin,
470 onEventLeadTimeChange,
471 onWorkHoursChange,
472 setUpdateCheckOnLaunch,
473 };
474
475 })();
476