Skip to main content

max / goingson

6.9 KB · 166 lines History Blame Raw
1 /**
2 * GoingsOn - Saved Views Module
3 *
4 * Save, apply, pin, and delete named presets of the task-list filters. Wired to the
5 * "Save View" menu item (Cmd/Ctrl+S). Tasks view only (Run #27 UX remediation: the
6 * backend commands shipped with no frontend). The DOM task filters map onto the
7 * backend `ViewFilters`; the milestone filter is not part of a saved view.
8 */
9 (function() {
10 'use strict';
11 const esc = GoingsOn.utils.escapeHtml;
12 const escAttr = GoingsOn.utils.escapeAttrValue;
13 const escArg = GoingsOn.utils.escapeHandlerArg;
14
15 // DOM single-value filter controls <-> backend ViewFilters (Vec-valued) enums.
16 const STATUS_TO_ENUM = { pending: 'Pending', started: 'Started', completed: 'Completed' };
17 const ENUM_TO_STATUS = { Pending: 'pending', Started: 'started', Completed: 'completed' };
18 const PRIORITY_TO_ENUM = { H: 'High', M: 'Medium', L: 'Low' };
19 const ENUM_TO_PRIORITY = { High: 'H', Medium: 'M', Low: 'L' };
20
21 // Last-listed views, so the inline onclick handlers can resolve by id.
22 let viewCache = [];
23
24 /** Map the current task filter DOM controls to a backend ViewFilters object. */
25 function currentFiltersToViewFilters() {
26 const f = GoingsOn.tasks.getFilters();
27 const vf = {};
28 if (f.status && STATUS_TO_ENUM[f.status]) vf.status = [STATUS_TO_ENUM[f.status]];
29 if (f.projectId) vf.projectId = f.projectId;
30 if (f.priority && PRIORITY_TO_ENUM[f.priority]) vf.priority = [PRIORITY_TO_ENUM[f.priority]];
31 if (f.showSnoozed) vf.isSnoozed = true;
32 if (f.waitingOnly) vf.isWaiting = true;
33 return vf;
34 }
35
36 /** Apply a saved view's filters back onto the task filter DOM, then reload. */
37 function applyView(view) {
38 if (!view) return;
39 const vf = view.filters || {};
40 const setVal = (id, val) => { const el = document.getElementById(id); if (el) el.value = val; };
41 const setChk = (id, val) => { const el = document.getElementById(id); if (el) el.checked = !!val; };
42 const status = Array.isArray(vf.status) && vf.status.length ? (ENUM_TO_STATUS[vf.status[0]] || '') : '';
43 const priority = Array.isArray(vf.priority) && vf.priority.length ? (ENUM_TO_PRIORITY[vf.priority[0]] || '') : '';
44 setVal('filter-status', status);
45 setVal('filter-project', vf.projectId || '');
46 setVal('filter-priority', priority);
47 setVal('filter-milestone', '');
48 setChk('filter-snoozed', vf.isSnoozed);
49 setChk('filter-waiting', vf.isWaiting);
50 GoingsOn.ui.closeModal();
51 GoingsOn.tasks.applyFilters();
52 GoingsOn.ui.showToast(`Applied view "${view.name}"`, 'success');
53 }
54
55 /** Build the modal markup: a save row, then the list of existing views. */
56 function buildModalHtml() {
57 let html = `
58 <div class="saved-views-modal">
59 <div class="saved-views-save">
60 <input type="text" id="saved-view-name" class="form-input" placeholder="Name these filters" maxlength="100">
61 <label class="form-checkbox-label"><input type="checkbox" id="saved-view-pin"> <span>Pin</span></label>
62 <button type="button" class="btn btn-primary" data-act="savedViews._save">Save current filters</button>
63 </div>`;
64 if (viewCache.length) {
65 html += `<div class="saved-views-list">`;
66 for (const v of viewCache) {
67 const pinLabel = v.isPinned ? 'Unpin' : 'Pin';
68 html += `
69 <div class="saved-views-row">
70 <button type="button" class="btn btn-sm saved-views-apply" data-act="savedViews._apply" data-a1="${escAttr(v.id)}">${esc(v.name)}${v.isPinned ? '' : ''}</button>
71 <button type="button" class="btn btn-sm" data-act="savedViews._togglePin" data-a1="${escAttr(v.id)}">${pinLabel}</button>
72 <button type="button" class="btn btn-sm btn-danger" data-act="savedViews._delete" data-a1="${escAttr(v.id)}">Delete</button>
73 </div>`;
74 }
75 html += `</div>`;
76 } else {
77 html += `<p class="saved-views-empty">No saved views yet. Set your task filters, then save them here.</p>`;
78 }
79 html += `</div>`;
80 return html;
81 }
82
83 /** Refresh the view list and (re)render the modal. openModal just resets
84 * #modal-content, so re-calling it is a safe in-place re-render. */
85 async function refresh() {
86 try {
87 viewCache = await GoingsOn.api.tasks.savedViews.list();
88 } catch (err) {
89 viewCache = [];
90 }
91 GoingsOn.ui.openModal('Saved Views', buildModalHtml());
92 }
93
94 /** Entry point: the "Save View" menu item / Cmd+S. Tasks view only. */
95 async function openSaveModal() {
96 const view = GoingsOn.getCurrentView ? GoingsOn.getCurrentView() : 'tasks';
97 if (view !== 'tasks') {
98 GoingsOn.ui.showToast('Saved views are available on the Tasks view', 'info');
99 return;
100 }
101 await refresh();
102 }
103
104 async function _save() {
105 const nameEl = document.getElementById('saved-view-name');
106 const name = (nameEl?.value || '').trim();
107 if (!name) {
108 GoingsOn.ui.showToast('Enter a name for the view', 'warning');
109 return;
110 }
111 const isPinned = document.getElementById('saved-view-pin')?.checked || false;
112 const input = {
113 name,
114 viewType: 'tasks',
115 filters: currentFiltersToViewFilters(),
116 sortBy: null,
117 sortOrder: null,
118 isPinned,
119 };
120 try {
121 await GoingsOn.api.tasks.savedViews.create(input);
122 GoingsOn.ui.showToast(`Saved view "${name}"`, 'success');
123 await refresh();
124 } catch (err) {
125 GoingsOn.ui.showToast(GoingsOn.utils.getErrorMessage(err, 'Failed to save view'), 'error');
126 }
127 }
128
129 function _apply(id) {
130 applyView(viewCache.find(v => v.id === id));
131 }
132
133 async function _togglePin(id) {
134 try {
135 await GoingsOn.api.tasks.savedViews.togglePinned(id);
136 await refresh();
137 } catch (err) {
138 GoingsOn.ui.showToast(GoingsOn.utils.getErrorMessage(err, 'Failed to update view'), 'error');
139 }
140 }
141
142 async function _delete(id) {
143 const view = viewCache.find(v => v.id === id);
144 const confirmed = await GoingsOn.ui.confirmDelete(
145 'Delete Saved View',
146 `Delete "${view ? view.name : 'this view'}"? This cannot be undone.`
147 );
148 if (!confirmed) return;
149 try {
150 await GoingsOn.api.tasks.savedViews.delete(id);
151 await refresh();
152 } catch (err) {
153 GoingsOn.ui.showToast(GoingsOn.utils.getErrorMessage(err, 'Failed to delete view'), 'error');
154 }
155 }
156
157 GoingsOn.savedViews = {
158 openSaveModal,
159 _save,
160 _apply,
161 _togglePin,
162 _delete,
163 };
164
165 })();
166