max / goingson
7 files changed,
+171 insertions,
-5 deletions
| @@ -139,8 +139,8 @@ Audit run: `/use-fuzz GoingsOn`. Overall grade: B+. Items already tracked elsewh | |||
| 139 | 139 | - [x] Add overflow/kebab icon on hover for item rows — surfaces context menu actions without right-click | |
| 140 | 140 | - [x] Add visible Quick Add button or input in Tasks header — `q` shortcut is invisible | |
| 141 | 141 | - [x] Add "Create Event" to email right-click context menu — parity with existing "Create Task" | |
| 142 | - | - [ ] Add one-time onboarding hints: "Press ? for shortcuts" on first launch, "Shift-click to select range" on first bulk selection | |
| 143 | - | - [ ] Add play/timer icon to task rows for started tasks — time tracking entry point is buried | |
| 142 | + | - [x] Add one-time onboarding hints: "Press ? for shortcuts" on first launch, "Shift-click to select range" on first bulk selection | |
| 143 | + | - [x] Add play/timer icon to task rows for started tasks — time tracking entry point is buried | |
| 144 | 144 | - [ ] Add touch gesture hints on first mobile use (long-press, swipe, pull-to-refresh) | |
| 145 | 145 | ||
| 146 | 146 | ### Learnability (B) | |
| @@ -167,7 +167,7 @@ Audit run: `/use-fuzz GoingsOn`. Overall grade: B+. Items already tracked elsewh | |||
| 167 | 167 | - [ ] Manual time entry — log time retroactively, not just live timer | |
| 168 | 168 | - [ ] Contacts export to vCard — import exists but no export (asymmetric) | |
| 169 | 169 | - [ ] Bulk operations for contacts (tag, delete) and events (delete) | |
| 170 | - | - [ ] Bulk "Set Project" and "Set Priority" in task bulk actions bar | |
| 170 | + | - [x] Bulk "Set Project" and "Set Priority" in task bulk actions bar | |
| 171 | 171 | - [ ] Daily review notes: persist to SQLite + sync (currently localStorage only, lost on reinstall) | |
| 172 | 172 | - [ ] Monthly review: add explicit "Complete Review" action (weekly has it, monthly does not) | |
| 173 | 173 | - [ ] Workload guardrails in day planner — warn when scheduled hours exceed target |
| @@ -1561,6 +1561,30 @@ body { | |||
| 1561 | 1561 | margin-bottom: 1.25rem; | |
| 1562 | 1562 | } | |
| 1563 | 1563 | ||
| 1564 | + | .form-more-toggle { | |
| 1565 | + | display: block; | |
| 1566 | + | background: none; | |
| 1567 | + | border: none; | |
| 1568 | + | cursor: pointer; | |
| 1569 | + | font-size: 0.85rem; | |
| 1570 | + | font-weight: 600; | |
| 1571 | + | color: var(--accent-blue); | |
| 1572 | + | padding: 0.25rem 0; | |
| 1573 | + | margin-bottom: 0.75rem; | |
| 1574 | + | } | |
| 1575 | + | ||
| 1576 | + | .form-more-toggle::before { | |
| 1577 | + | content: '+ '; | |
| 1578 | + | } | |
| 1579 | + | ||
| 1580 | + | .form-more-toggle.expanded::before { | |
| 1581 | + | content: '- '; | |
| 1582 | + | } | |
| 1583 | + | ||
| 1584 | + | .form-extended-fields.hidden { | |
| 1585 | + | display: none; | |
| 1586 | + | } | |
| 1587 | + | ||
| 1564 | 1588 | .form-label { | |
| 1565 | 1589 | display: block; | |
| 1566 | 1590 | font-size: 0.9rem; | |
| @@ -2271,6 +2295,15 @@ kbd { | |||
| 2271 | 2295 | outline-offset: -2px; | |
| 2272 | 2296 | } | |
| 2273 | 2297 | ||
| 2298 | + | .context-menu-header { | |
| 2299 | + | font-size: 0.7rem; | |
| 2300 | + | font-weight: 700; | |
| 2301 | + | color: var(--text-secondary); | |
| 2302 | + | text-transform: uppercase; | |
| 2303 | + | letter-spacing: 0.05em; | |
| 2304 | + | padding: 0.5rem 1rem 0.25rem; | |
| 2305 | + | } | |
| 2306 | + | ||
| 2274 | 2307 | .context-menu-item-icon { | |
| 2275 | 2308 | width: 1.25rem; | |
| 2276 | 2309 | text-align: center; | |
| @@ -2281,6 +2314,13 @@ kbd { | |||
| 2281 | 2314 | flex: 1; | |
| 2282 | 2315 | } | |
| 2283 | 2316 | ||
| 2317 | + | .context-menu-item-subtitle { | |
| 2318 | + | display: block; | |
| 2319 | + | font-size: 0.7rem; | |
| 2320 | + | color: var(--text-secondary); | |
| 2321 | + | font-weight: normal; | |
| 2322 | + | } | |
| 2323 | + | ||
| 2284 | 2324 | .context-menu-item-shortcut { | |
| 2285 | 2325 | font-size: 0.75rem; | |
| 2286 | 2326 | color: var(--text-muted); | |
| @@ -3051,6 +3091,29 @@ kbd { | |||
| 3051 | 3091 | margin-right: 0.5rem; | |
| 3052 | 3092 | } | |
| 3053 | 3093 | ||
| 3094 | + | .task-kebab-btn { | |
| 3095 | + | background: none; | |
| 3096 | + | border: none; | |
| 3097 | + | cursor: pointer; | |
| 3098 | + | font-size: 1.1rem; | |
| 3099 | + | line-height: 1; | |
| 3100 | + | padding: 0.2rem 0.4rem; | |
| 3101 | + | border-radius: var(--radius-sm); | |
| 3102 | + | color: var(--text-secondary); | |
| 3103 | + | opacity: 0; | |
| 3104 | + | transition: opacity 0.15s ease; | |
| 3105 | + | } | |
| 3106 | + | ||
| 3107 | + | .task-row:hover .task-kebab-btn, | |
| 3108 | + | .task-row:focus-within .task-kebab-btn { | |
| 3109 | + | opacity: 1; | |
| 3110 | + | } | |
| 3111 | + | ||
| 3112 | + | .task-kebab-btn:hover { | |
| 3113 | + | background: var(--bg-hover); | |
| 3114 | + | color: var(--text-primary); | |
| 3115 | + | } | |
| 3116 | + | ||
| 3054 | 3117 | .task-recurrence { | |
| 3055 | 3118 | font-size: 0.85rem; | |
| 3056 | 3119 | color: var(--text-secondary); | |
| @@ -6050,6 +6113,10 @@ button.milestone-reorder-btn.btn { | |||
| 6050 | 6113 | display: none; | |
| 6051 | 6114 | } | |
| 6052 | 6115 | ||
| 6116 | + | .task-kebab-btn { | |
| 6117 | + | opacity: 1; | |
| 6118 | + | } | |
| 6119 | + | ||
| 6053 | 6120 | /* --- Mobile Sort & Filter --- */ | |
| 6054 | 6121 | .mobile-sort-bar { | |
| 6055 | 6122 | display: flex; | |
| @@ -6800,6 +6867,24 @@ button.milestone-reorder-btn.btn { | |||
| 6800 | 6867 | border-color: var(--accent-red); | |
| 6801 | 6868 | } | |
| 6802 | 6869 | ||
| 6870 | + | /* Play icon for started tasks — entry point into time tracking */ | |
| 6871 | + | .task-started-icon { | |
| 6872 | + | display: inline-block; | |
| 6873 | + | width: 0; | |
| 6874 | + | height: 0; | |
| 6875 | + | border-style: solid; | |
| 6876 | + | border-width: 5px 0 5px 8px; | |
| 6877 | + | border-color: transparent transparent transparent var(--accent-green, #22c55e); | |
| 6878 | + | margin-right: 0.375rem; | |
| 6879 | + | vertical-align: middle; | |
| 6880 | + | cursor: pointer; | |
| 6881 | + | opacity: 0.8; | |
| 6882 | + | flex-shrink: 0; | |
| 6883 | + | } | |
| 6884 | + | .task-started-icon:hover { | |
| 6885 | + | opacity: 1; | |
| 6886 | + | } | |
| 6887 | + | ||
| 6803 | 6888 | /* Pulsing indicator for running timer */ | |
| 6804 | 6889 | .task-timer-active { | |
| 6805 | 6890 | display: inline-block; |
| @@ -54,12 +54,15 @@ | |||
| 54 | 54 | <button class="view-toggle-btn active" data-mode="list" onclick="GoingsOn.tasks.setViewMode('list')">List</button> | |
| 55 | 55 | <button class="view-toggle-btn" data-mode="board" onclick="GoingsOn.tasks.setViewMode('board')">Board</button> | |
| 56 | 56 | </div> | |
| 57 | + | <button class="btn btn-secondary" onclick="GoingsOn.keyboard.openQuickAddModal()" title="Quick add (q)">Quick Add</button> | |
| 57 | 58 | <button class="btn btn-primary" onclick="GoingsOn.tasks.openNew()">+ New Task</button> | |
| 58 | 59 | </div> | |
| 59 | 60 | </div> | |
| 60 | 61 | <div id="task-bulk-actions" class="bulk-actions-bar hidden" role="toolbar" aria-label="Bulk task actions"> | |
| 61 | 62 | <span id="task-bulk-count" class="bulk-count">0 selected</span> | |
| 62 | 63 | <button class="btn btn-sm" onclick="GoingsOn.bulk.completeTasks()">Complete</button> | |
| 64 | + | <button class="btn btn-sm" onclick="GoingsOn.bulk.setProjectTasks()">Set Project</button> | |
| 65 | + | <button class="btn btn-sm" onclick="GoingsOn.bulk.setPriorityTasks()">Set Priority</button> | |
| 63 | 66 | <button class="btn btn-sm" onclick="GoingsOn.bulk.snoozeTasks()">Snooze</button> | |
| 64 | 67 | <button class="btn btn-sm" onclick="GoingsOn.bulk.deleteTasks()">Delete</button> | |
| 65 | 68 | <button class="btn btn-sm bulk-select-all" onclick="GoingsOn.bulk.selectAllTasks()">Select All</button> | |
| @@ -114,7 +117,7 @@ | |||
| 114 | 117 | Project <span class="sort-arrow"></span> | |
| 115 | 118 | </div> | |
| 116 | 119 | <div class="task-cell sortable" data-sort="priority" onclick="GoingsOn.tasks.sort('priority')" role="columnheader" tabindex="0" aria-label="Priority"> | |
| 117 | - | Pri <span class="sort-arrow"></span> | |
| 120 | + | Priority <span class="sort-arrow"></span> | |
| 118 | 121 | </div> | |
| 119 | 122 | <div class="task-cell sortable" data-sort="due" onclick="GoingsOn.tasks.sort('due')" role="columnheader" tabindex="0"> | |
| 120 | 123 | Due <span class="sort-arrow"></span> | |
| @@ -216,6 +219,7 @@ | |||
| 216 | 219 | <button class="pill active" data-subview="day-plan">Day</button> | |
| 217 | 220 | <button class="pill" data-subview="weekly-review">Week</button> | |
| 218 | 221 | <button class="pill" data-subview="monthly-review">Month</button> | |
| 222 | + | <button class="pill" data-subview="events">Events</button> | |
| 219 | 223 | <button class="pill" data-subview="timer">Timer</button> | |
| 220 | 224 | </div> | |
| 221 | 225 | ||
| @@ -236,6 +240,7 @@ | |||
| 236 | 240 | <div class="day-plan-content"> | |
| 237 | 241 | <div class="day-plan-main" id="day-plan-container"> | |
| 238 | 242 | <div class="timeline-container" id="timeline-container"> | |
| 243 | + | <p class="timeline-hint" style="text-align: center; color: var(--text-secondary); font-size: 0.8rem; margin: 0.25rem 0 0;">Drag across time slots to block time</p> | |
| 239 | 244 | <div class="timeline-scroll-area"> | |
| 240 | 245 | <div class="timeline-current-time" id="timeline-current-time"></div> | |
| 241 | 246 | <div id="timeline-slots"></div> |
| @@ -50,6 +50,9 @@ document.addEventListener('DOMContentLoaded', async () => { | |||
| 50 | 50 | // First-run welcome | |
| 51 | 51 | if (!localStorage.getItem('go-welcomed')) { | |
| 52 | 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); | |
| 53 | 56 | } | |
| 54 | 57 | ||
| 55 | 58 | // Check weekly review nudge on startup | |
| @@ -264,12 +267,22 @@ function showWelcome() { | |||
| 264 | 267 | localStorage.setItem('go-welcomed', '1'); | |
| 265 | 268 | } | |
| 266 | 269 | ||
| 270 | + | /** | |
| 271 | + | * Show a one-time dismissible hint toast. Sets localStorage key so it only shows once. | |
| 272 | + | */ | |
| 273 | + | function showHint(storageKey, message) { | |
| 274 | + | if (localStorage.getItem(storageKey)) return; | |
| 275 | + | localStorage.setItem(storageKey, '1'); | |
| 276 | + | GoingsOn.ui.showToast(message, 'info', { duration: 5000 }); | |
| 277 | + | } | |
| 278 | + | ||
| 267 | 279 | GoingsOn.app = { | |
| 268 | 280 | toggleSidebar, | |
| 269 | 281 | syncAllEmailAccounts, | |
| 270 | 282 | openAboutModal, | |
| 271 | 283 | refreshCurrentViewData, | |
| 272 | 284 | showWelcome, | |
| 285 | + | showHint, | |
| 273 | 286 | }; | |
| 274 | 287 | ||
| 275 | 288 | })(); |
| @@ -145,6 +145,58 @@ | |||
| 145 | 145 | }); | |
| 146 | 146 | } | |
| 147 | 147 | ||
| 148 | + | async function setProjectTasks() { | |
| 149 | + | const selectedTaskIds = GoingsOn.tasks?.getSelected?.() || new Set(); | |
| 150 | + | if (selectedTaskIds.size === 0) return; | |
| 151 | + | ||
| 152 | + | const projects = GoingsOn.projects?.getCache?.() || []; | |
| 153 | + | let optionsHtml = `<p style="margin-bottom: 0.75rem; color: var(--text-secondary);">Set project for ${selectedTaskIds.size} tasks:</p>`; | |
| 154 | + | optionsHtml += `<button class="btn btn-sm btn-ghost" style="width: 100%; text-align: left; margin-bottom: 0.25rem;" onclick="GoingsOn.bulk._applyProject(null)">No Project</button>`; | |
| 155 | + | for (const p of projects) { | |
| 156 | + | optionsHtml += `<button class="btn btn-sm btn-ghost" style="width: 100%; text-align: left; margin-bottom: 0.25rem;" onclick="GoingsOn.bulk._applyProject('${GoingsOn.utils.escapeAttr(p.id)}')">${GoingsOn.utils.escapeHtml(p.name)}</button>`; | |
| 157 | + | } | |
| 158 | + | GoingsOn.ui.openModal('Set Project', `<div style="max-height: 300px; overflow-y: auto;">${optionsHtml}</div>`); | |
| 159 | + | } | |
| 160 | + | ||
| 161 | + | async function setPriorityTasks() { | |
| 162 | + | const selectedTaskIds = GoingsOn.tasks?.getSelected?.() || new Set(); | |
| 163 | + | if (selectedTaskIds.size === 0) return; | |
| 164 | + | ||
| 165 | + | const content = ` | |
| 166 | + | <p style="margin-bottom: 0.75rem; color: var(--text-secondary);">Set priority for ${selectedTaskIds.size} tasks:</p> | |
| 167 | + | <div style="display: flex; gap: 0.5rem;"> | |
| 168 | + | <button class="btn btn-sm" onclick="GoingsOn.bulk._applyPriority('High')">High</button> | |
| 169 | + | <button class="btn btn-sm" onclick="GoingsOn.bulk._applyPriority('Medium')">Medium</button> | |
| 170 | + | <button class="btn btn-sm" onclick="GoingsOn.bulk._applyPriority('Low')">Low</button> | |
| 171 | + | </div> | |
| 172 | + | `; | |
| 173 | + | GoingsOn.ui.openModal('Set Priority', content); | |
| 174 | + | } | |
| 175 | + | ||
| 176 | + | async function _applyProject(projectId) { | |
| 177 | + | const selectedTaskIds = GoingsOn.tasks?.getSelected?.() || new Set(); | |
| 178 | + | await executeBulkAction({ | |
| 179 | + | selectedIds: selectedTaskIds, | |
| 180 | + | apiCall: id => GoingsOn.api.tasks.update(id, { projectId }), | |
| 181 | + | successMessage: 'Updated project for {count} tasks', | |
| 182 | + | errorMessage: 'Failed to update some tasks', | |
| 183 | + | reloadFn: () => GoingsOn.tasks.load(), | |
| 184 | + | closeModalAfter: true | |
| 185 | + | }); | |
| 186 | + | } | |
| 187 | + | ||
| 188 | + | async function _applyPriority(priority) { | |
| 189 | + | const selectedTaskIds = GoingsOn.tasks?.getSelected?.() || new Set(); | |
| 190 | + | await executeBulkAction({ | |
| 191 | + | selectedIds: selectedTaskIds, | |
| 192 | + | apiCall: id => GoingsOn.api.tasks.update(id, { priority }), | |
| 193 | + | successMessage: 'Updated priority for {count} tasks', | |
| 194 | + | errorMessage: 'Failed to update some tasks', | |
| 195 | + | reloadFn: () => GoingsOn.tasks.load(), | |
| 196 | + | closeModalAfter: true | |
| 197 | + | }); | |
| 198 | + | } | |
| 199 | + | ||
| 148 | 200 | // ============ Email Bulk Actions ============ | |
| 149 | 201 | ||
| 150 | 202 | async function archiveEmails() { | |
| @@ -220,6 +272,8 @@ | |||
| 220 | 272 | completeTasks, | |
| 221 | 273 | deleteTasks, | |
| 222 | 274 | snoozeTasks, | |
| 275 | + | setProjectTasks, | |
| 276 | + | setPriorityTasks, | |
| 223 | 277 | selectAllTasks, | |
| 224 | 278 | // Emails | |
| 225 | 279 | archiveEmails, | |
| @@ -229,6 +283,8 @@ | |||
| 229 | 283 | selectAllEmails, | |
| 230 | 284 | // Internal | |
| 231 | 285 | _snoozeCallback: null, | |
| 286 | + | _applyProject, | |
| 287 | + | _applyPriority, | |
| 232 | 288 | }; | |
| 233 | 289 | ||
| 234 | 290 | })(); |
| @@ -100,6 +100,11 @@ class SelectionManager { | |||
| 100 | 100 | this.lastClickedIndex = currentIndex; | |
| 101 | 101 | this.lastClickedId = id; | |
| 102 | 102 | this.updateBulkActionsBar(); | |
| 103 | + | ||
| 104 | + | // One-time hint about shift-click range selection | |
| 105 | + | if (this.selectedIds.size === 1 && GoingsOn.app?.showHint) { | |
| 106 | + | GoingsOn.app.showHint('go-hint-shift-select', 'Shift-click to select a range of items'); | |
| 107 | + | } | |
| 103 | 108 | } | |
| 104 | 109 | ||
| 105 | 110 | /** |
| @@ -84,6 +84,7 @@ | |||
| 84 | 84 | const progress = t.subtaskProgress ?? 0; | |
| 85 | 85 | const displayDesc = t.displayDescription || t.description; | |
| 86 | 86 | const isSelected = GoingsOn.tasks.selection.isSelected(t.id); | |
| 87 | + | const isStarted = t.status === 'Started'; | |
| 87 | 88 | ||
| 88 | 89 | return ` | |
| 89 | 90 | <div class="task-row task-${t.status.toLowerCase()} ${t.isSnoozed ? 'task-snoozed' : ''} ${t.isOverdue ? 'task-overdue' : ''} ${isSelected ? 'selected' : ''}" | |
| @@ -91,6 +92,7 @@ | |||
| 91 | 92 | oncontextmenu="GoingsOn.contextMenus.showTask(event, '${escAttr(t.id)}')" | |
| 92 | 93 | tabindex="0" role="row"> | |
| 93 | 94 | <div class="task-cell task-description" onclick="GoingsOn.tasks.openSubtasks('${escAttr(t.id)}')"> | |
| 95 | + | ${isStarted ? `<span class="task-started-icon" title="Started - click to track time" onclick="event.stopPropagation(); GoingsOn.timeTracking.startTimer('${escAttr(t.id)}')"></span>` : ''} | |
| 94 | 96 | <span class="task-description-text">${esc(displayDesc)}</span> | |
| 95 | 97 | ${renderTimeBadge(t)} | |
| 96 | 98 | ${renderTaskBadges(t)} | |
| @@ -115,7 +117,7 @@ | |||
| 115 | 117 | ${isSelected ? 'checked' : ''} | |
| 116 | 118 | onchange="GoingsOn.tasks.toggleSelection('${escAttr(t.id)}', this, event)" | |
| 117 | 119 | aria-label="Select task"> | |
| 118 | - | <button class="btn btn-sm btn-secondary" onclick="GoingsOn.tasks.openActions('${escAttr(t.id)}')" title="Actions" aria-label="Task actions">...</button> | |
| 120 | + | <button class="task-kebab-btn" onclick="GoingsOn.contextMenus.showTask(event, '${escAttr(t.id)}')" title="Actions" aria-label="Task actions">⋮</button> | |
| 119 | 121 | </div> | |
| 120 | 122 | </div> | |
| 121 | 123 | `; |