max / goingson
- Co-Authored-By
- Claude Opus 5 (1M context) <noreply@anthropic.com>
5 files changed,
+233 insertions,
-18 deletions
| @@ -4829,6 +4829,33 @@ | |||
| 4829 | 4829 | background: var(--hover-surface); | |
| 4830 | 4830 | } | |
| 4831 | 4831 | ||
| 4832 | + | /* Row-level actions (track time, focus, snooze) sitting beside the kebab. | |
| 4833 | + | Always in the DOM for keyboard and screen readers; revealed on row hover or | |
| 4834 | + | focus so the resting row stays quiet. Never display: none. */ | |
| 4835 | + | .task-row-action { | |
| 4836 | + | font-size: var(--font-size-base); | |
| 4837 | + | line-height: 1; | |
| 4838 | + | padding: var(--step-tight) var(--step-snug); | |
| 4839 | + | border-radius: var(--radius-sm); | |
| 4840 | + | color: var(--content-secondary); | |
| 4841 | + | opacity: 0; | |
| 4842 | + | } | |
| 4843 | + | ||
| 4844 | + | .task-row:hover .task-row-action, | |
| 4845 | + | .task-row:focus-within .task-row-action, | |
| 4846 | + | .task-row-action:focus { | |
| 4847 | + | opacity: 1; | |
| 4848 | + | } | |
| 4849 | + | ||
| 4850 | + | .task-row-action:hover { | |
| 4851 | + | background: var(--hover-surface); | |
| 4852 | + | color: var(--content); | |
| 4853 | + | } | |
| 4854 | + | ||
| 4855 | + | .task-row-action--track { | |
| 4856 | + | color: var(--success); | |
| 4857 | + | } | |
| 4858 | + | ||
| 4832 | 4859 | .task-recurrence { | |
| 4833 | 4860 | font-size: var(--font-size-base); | |
| 4834 | 4861 | color: var(--content-secondary); | |
| @@ -7448,6 +7475,14 @@ | |||
| 7448 | 7475 | opacity: 1; | |
| 7449 | 7476 | } | |
| 7450 | 7477 | ||
| 7478 | + | /* Mobile rows are cards with their own action strip, so the row actions sit | |
| 7479 | + | next to the kebab at full opacity and full touch-target size. */ | |
| 7480 | + | .ui-mode-mobile .task-row-action { | |
| 7481 | + | opacity: 1; | |
| 7482 | + | min-height: 44px; | |
| 7483 | + | min-width: 44px; | |
| 7484 | + | } | |
| 7485 | + | ||
| 7451 | 7486 | /* --- Mobile Sort & Filter --- */ | |
| 7452 | 7487 | .ui-mode-mobile .mobile-sort-bar { | |
| 7453 | 7488 | display: flex; | |
| @@ -7753,6 +7788,13 @@ | |||
| 7753 | 7788 | opacity: 1; | |
| 7754 | 7789 | } | |
| 7755 | 7790 | ||
| 7791 | + | /* Same for the row action buttons; there is no hover to reveal them */ | |
| 7792 | + | .task-row .task-row-action { | |
| 7793 | + | opacity: 1; | |
| 7794 | + | min-height: 44px; | |
| 7795 | + | min-width: 44px; | |
| 7796 | + | } | |
| 7797 | + | ||
| 7756 | 7798 | /* No physical keyboard on touch, hide the `?` shortcuts entry points */ | |
| 7757 | 7799 | .shortcut-hint-btn { | |
| 7758 | 7800 | display: none; | |
| @@ -7777,6 +7819,11 @@ | |||
| 7777 | 7819 | body.is-touch .event-row-virtual .kebab-btn { | |
| 7778 | 7820 | opacity: 1; | |
| 7779 | 7821 | } | |
| 7822 | + | body.is-touch .task-row .task-row-action { | |
| 7823 | + | opacity: 1; | |
| 7824 | + | min-height: 44px; | |
| 7825 | + | min-width: 44px; | |
| 7826 | + | } | |
| 7780 | 7827 | body.is-touch .shortcut-hint-btn { | |
| 7781 | 7828 | display: none; | |
| 7782 | 7829 | } | |
| @@ -8157,7 +8204,15 @@ | |||
| 8157 | 8204 | border-color: var(--danger); | |
| 8158 | 8205 | } | |
| 8159 | 8206 | ||
| 8160 | - | /* Play icon for started tasks, entry point into time tracking */ | |
| 8207 | + | /* Live elapsed readout next to the running-timer dot */ | |
| 8208 | + | .task-timer-elapsed { | |
| 8209 | + | color: var(--danger); | |
| 8210 | + | border-color: var(--danger); | |
| 8211 | + | font-variant-numeric: tabular-nums; | |
| 8212 | + | } | |
| 8213 | + | ||
| 8214 | + | /* Play icon marking a started task. Status only; the Track Time button in the | |
| 8215 | + | actions cell is the entry point into time tracking. */ | |
| 8161 | 8216 | .task-started-icon { | |
| 8162 | 8217 | display: inline-block; | |
| 8163 | 8218 | width: 0; | |
| @@ -8167,13 +8222,9 @@ | |||
| 8167 | 8222 | border-color: transparent transparent transparent var(--success); | |
| 8168 | 8223 | margin-right: var(--gap-bound); | |
| 8169 | 8224 | vertical-align: middle; | |
| 8170 | - | cursor: pointer; | |
| 8171 | 8225 | opacity: 0.8; | |
| 8172 | 8226 | flex-shrink: 0; | |
| 8173 | 8227 | } | |
| 8174 | - | .task-started-icon:hover { | |
| 8175 | - | opacity: 1; | |
| 8176 | - | } | |
| 8177 | 8228 | ||
| 8178 | 8229 | /* Pulsing indicator for running timer */ | |
| 8179 | 8230 | .task-timer-active { |
| @@ -334,8 +334,14 @@ | |||
| 334 | 334 | */ | |
| 335 | 335 | async function createTaskFromEmail(emailId) { | |
| 336 | 336 | try { | |
| 337 | - | await GoingsOn.api.emails.createTaskFrom(emailId); | |
| 338 | - | GoingsOn.ui.showToast('Task created from email!', 'success'); | |
| 337 | + | const task = await GoingsOn.api.emails.createTaskFrom(emailId); | |
| 338 | + | // Converting an email is usually the moment work starts, so offer | |
| 339 | + | // the timer right in the confirmation instead of making the user | |
| 340 | + | // go find the new task first. | |
| 341 | + | const opts = task && task.id | |
| 342 | + | ? { action: { label: 'Start Timer', fn: () => GoingsOn.timeTracking.startTimer(task.id) } } | |
| 343 | + | : {}; | |
| 344 | + | GoingsOn.ui.showToast('Task created from email', 'success', opts); | |
| 339 | 345 | GoingsOn.ui.closeModal(); | |
| 340 | 346 | GoingsOn.cache.invalidate('tasks'); | |
| 341 | 347 | GoingsOn.tasks.load(); |
| @@ -70,7 +70,15 @@ | |||
| 70 | 70 | */ | |
| 71 | 71 | function renderTimeBadge(t) { | |
| 72 | 72 | if (t.timerActive) { | |
| 73 | - | return '<span class="task-timer-active" title="Timer running"></span>'; | |
| 73 | + | const dot = '<span class="task-timer-active" title="Timer running"></span>'; | |
| 74 | + | // The row shows live elapsed time, ticked by the time-tracking | |
| 75 | + | // widget's own interval (one timer app-wide, not one per row). | |
| 76 | + | if (!t.timerStartedAt) return dot; | |
| 77 | + | const fmt = GoingsOn.timeTracking && GoingsOn.timeTracking.fmtElapsed; | |
| 78 | + | const elapsed = fmt ? fmt(t.timerStartedAt) : ''; | |
| 79 | + | return `${dot}<span class="task-time-badge task-timer-elapsed" role="timer" | |
| 80 | + | data-started="${escAttr(t.timerStartedAt)}" | |
| 81 | + | title="Timer running" aria-label="Timer running, elapsed">${esc(elapsed)}</span>`; | |
| 74 | 82 | } | |
| 75 | 83 | if (t.estimatedMinutes || t.actualMinutes) { | |
| 76 | 84 | const actual = t.actualMinutes || 0; | |
| @@ -104,6 +112,31 @@ | |||
| 104 | 112 | return `<button class="token-dot token-dot--${summary}" data-act="tasks.openStatusTokens" data-a1="${escAttr(t.id)}" title="${escAttr(title)}" aria-label="${escAttr(title)}"></button>`; | |
| 105 | 113 | } | |
| 106 | 114 | ||
| 115 | + | /** | |
| 116 | + | * Render the row-level action buttons that live in the actions cell next to | |
| 117 | + | * the kebab. They are always in the DOM (so keyboard and screen-reader users | |
| 118 | + | * reach them); CSS reveals them on row hover or focus, and shows them | |
| 119 | + | * unconditionally on touch and in mobile UI mode. | |
| 120 | + | * @param {Object} t - Task object | |
| 121 | + | * @param {boolean} isStarted - Whether the task is in the Started state | |
| 122 | + | * @returns {string} HTML string of icon buttons | |
| 123 | + | */ | |
| 124 | + | function renderRowActions(t, isStarted) { | |
| 125 | + | const id = escAttr(t.id); | |
| 126 | + | const track = isStarted | |
| 127 | + | ? `<button class="btn-icon task-row-action task-row-action--track" | |
| 128 | + | data-act="timeTracking.startTimer" data-a1="${id}" | |
| 129 | + | title="Track time" aria-label="Track time on this task">▶︎</button>` | |
| 130 | + | : ''; | |
| 131 | + | return `${track} | |
| 132 | + | <button class="btn-icon task-row-action task-row-action--focus" | |
| 133 | + | data-act="focusTimer.start" data-a1="${id}" | |
| 134 | + | title="Focus session" aria-label="Start a focus session on this task">◎</button> | |
| 135 | + | <button class="btn-icon task-row-action task-row-action--snooze" | |
| 136 | + | data-act="snooze.openModal" data-a1="task" data-a2="${id}" | |
| 137 | + | title="Snooze" aria-label="Snooze this task">☾︎</button>`; | |
| 138 | + | } | |
| 139 | + | ||
| 107 | 140 | function renderTaskRow(t, index) { | |
| 108 | 141 | const progress = t.subtaskProgress ?? 0; | |
| 109 | 142 | const displayDesc = t.displayDescription || t.title; | |
| @@ -116,7 +149,7 @@ | |||
| 116 | 149 | data-contextmenu="contextMenus.showTask" data-a1="@event" data-a2="${escAttr(t.id)}" | |
| 117 | 150 | tabindex="0" role="row"> | |
| 118 | 151 | <div class="task-cell task-description" data-act="taskOverview.open" data-a1="${escAttr(t.id)}"> | |
| 119 | - | ${isStarted ? `<span class="task-started-icon" title="Started - click to track time" data-act="timeTracking.startTimer" data-a1="${escAttr(t.id)}"></span>` : ''} | |
| 152 | + | ${isStarted ? '<span class="task-started-icon" title="Started" aria-hidden="true"></span>' : ''} | |
| 120 | 153 | <span class="task-description-text">${esc(displayDesc)}</span> | |
| 121 | 154 | ${renderTokenDot(t)} | |
| 122 | 155 | ${renderTimeBadge(t)} | |
| @@ -138,6 +171,7 @@ | |||
| 138 | 171 | ` : '<span class="no-subtasks">-</span>'} | |
| 139 | 172 | </div> | |
| 140 | 173 | <div class="task-cell task-actions-cell"> | |
| 174 | + | ${renderRowActions(t, isStarted)} | |
| 141 | 175 | <input type="checkbox" class="bulk-checkbox" data-id="${escAttr(t.id)}" | |
| 142 | 176 | ${isSelected ? 'checked' : ''} | |
| 143 | 177 | data-change="tasks.toggleSelection" data-a1="${escAttr(t.id)}" data-a2="@el" data-a3="@event" | |
| @@ -283,6 +317,8 @@ | |||
| 283 | 317 | GoingsOn.tasksRender = { | |
| 284 | 318 | renderTaskBadges, | |
| 285 | 319 | renderTokenDot, | |
| 320 | + | renderTimeBadge, | |
| 321 | + | renderRowActions, | |
| 286 | 322 | renderTaskRow, | |
| 287 | 323 | renderSubtasksModal, | |
| 288 | 324 | renderStatusTokensModal, |
| @@ -59,18 +59,24 @@ | |||
| 59 | 59 | activeTimer = null; | |
| 60 | 60 | } | |
| 61 | 61 | ||
| 62 | + | /** | |
| 63 | + | * Update every in-row elapsed readout. Task rows render their own | |
| 64 | + | * `.task-timer-elapsed` span carrying the start time; they ride this one | |
| 65 | + | * interval rather than each starting a timer of their own. | |
| 66 | + | */ | |
| 67 | + | function updateRowElapsed() { | |
| 68 | + | const nodes = document.querySelectorAll('.task-timer-elapsed[data-started]'); | |
| 69 | + | for (const el of nodes) { | |
| 70 | + | el.textContent = fmtElapsed(el.dataset.started); | |
| 71 | + | } | |
| 72 | + | } | |
| 73 | + | ||
| 62 | 74 | function updateElapsed() { | |
| 75 | + | updateRowElapsed(); | |
| 63 | 76 | if (!activeTimer) return; | |
| 64 | 77 | const widget = document.getElementById('timer-widget'); | |
| 65 | 78 | if (!widget) return; | |
| 66 | - | const diff = Math.floor((Date.now() - activeTimer.startedAt.getTime()) / 1000); | |
| 67 | - | const h = Math.floor(diff / 3600); | |
| 68 | - | const m = Math.floor((diff % 3600) / 60); | |
| 69 | - | const s = diff % 60; | |
| 70 | - | const display = h > 0 | |
| 71 | - | ? `${h}:${String(m).padStart(2, '0')}:${String(s).padStart(2, '0')}` | |
| 72 | - | : `${m}:${String(s).padStart(2, '0')}`; | |
| 73 | - | widget.querySelector('.timer-elapsed').textContent = display; | |
| 79 | + | widget.querySelector('.timer-elapsed').textContent = fmtElapsed(activeTimer.startedAt); | |
| 74 | 80 | } | |
| 75 | 81 | ||
| 76 | 82 | // Actions | |
| @@ -150,7 +156,7 @@ | |||
| 150 | 156 | ||
| 151 | 157 | /** | |
| 152 | 158 | * Format elapsed time since a start timestamp as h:mm:ss or m:ss. | |
| 153 | - | * @param {string} startedAt - ISO 8601 start timestamp | |
| 159 | + | * @param {string|Date} startedAt - ISO 8601 start timestamp or Date | |
| 154 | 160 | * @returns {string} Formatted elapsed time | |
| 155 | 161 | */ | |
| 156 | 162 | function fmtElapsed(startedAt) { | |
| @@ -407,6 +413,8 @@ | |||
| 407 | 413 | stopActive, | |
| 408 | 414 | discardActive, | |
| 409 | 415 | checkActive, | |
| 416 | + | fmtElapsed, | |
| 417 | + | updateRowElapsed, | |
| 410 | 418 | loadTimerView, | |
| 411 | 419 | trackFromTimerView, | |
| 412 | 420 | focusFromTimerView, |
| @@ -97,6 +97,15 @@ | |||
| 97 | 97 | require('../virtual-scroller'); // GoingsOn.VirtualScroller | |
| 98 | 98 | require('../emails-threads'); // GoingsOn.emailsThreads (thread-list mutations) | |
| 99 | 99 | ||
| 100 | + | // tasks-render builds row markup from two collaborators it does not own. Both | |
| 101 | + | // are stubbed here so a row render is a pure string call. | |
| 102 | + | GoingsOn.tasks = GoingsOn.tasks || {}; | |
| 103 | + | GoingsOn.tasks.selection = { isSelected: () => false }; | |
| 104 | + | GoingsOn.groups = { taskSharedBadge: () => '' }; | |
| 105 | + | ||
| 106 | + | require('../time-tracking'); // GoingsOn.timeTracking (fmtElapsed, used by the row renderer) | |
| 107 | + | require('../tasks-render'); // GoingsOn.tasksRender (task row markup) | |
| 108 | + | ||
| 100 | 109 | // Test: AppStateManager / GoingsOn.state | |
| 101 | 110 | ||
| 102 | 111 | describe('GoingsOn.state', () => { | |
| @@ -797,6 +806,111 @@ | |||
| 797 | 806 | }); | |
| 798 | 807 | }); | |
| 799 | 808 | ||
| 809 | + | // Test: tasks-render row actions and time badge | |
| 810 | + | ||
| 811 | + | describe('GoingsOn.tasksRender row actions', () => { | |
| 812 | + | const baseTask = () => ({ | |
| 813 | + | id: 'task-1', | |
| 814 | + | title: 'Write the thing', | |
| 815 | + | status: 'Pending', | |
| 816 | + | priority: 'M', | |
| 817 | + | subtaskCount: 0, | |
| 818 | + | annotations: [], | |
| 819 | + | }); | |
| 820 | + | ||
| 821 | + | test('snooze and focus buttons are in the markup of every row', () => { | |
| 822 | + | const html = GoingsOn.tasksRender.renderTaskRow(baseTask(), 0); | |
| 823 | + | assert(html.includes('data-act="snooze.openModal"'), 'snooze button present'); | |
| 824 | + | assert(html.includes('data-act="focusTimer.start"'), 'focus button present'); | |
| 825 | + | }); | |
| 826 | + | ||
| 827 | + | test('row actions live in the actions cell, not the description cell', () => { | |
| 828 | + | const html = GoingsOn.tasksRender.renderTaskRow(baseTask(), 0); | |
| 829 | + | const cellIdx = html.indexOf('task-actions-cell'); | |
| 830 | + | assert(cellIdx !== -1, 'actions cell rendered'); | |
| 831 | + | assert(html.indexOf('task-row-action') > cellIdx, 'actions come after the cell opens'); | |
| 832 | + | }); | |
| 833 | + | ||
| 834 | + | test('snooze dispatches with the task item type then the id', () => { | |
| 835 | + | const html = GoingsOn.tasksRender.renderRowActions({ id: 'abc' }, false); | |
| 836 | + | assert(html.includes('data-a1="task" data-a2="abc"'), 'item type then id'); | |
| 837 | + | }); | |
| 838 | + | ||
| 839 | + | test('track time button only renders on Started rows', () => { | |
| 840 | + | const pending = GoingsOn.tasksRender.renderTaskRow(baseTask(), 0); | |
| 841 | + | assert(!pending.includes('task-row-action--track'), 'no track button when pending'); | |
| 842 | + | const started = Object.assign(baseTask(), { status: 'Started' }); | |
| 843 | + | assert(GoingsOn.tasksRender.renderTaskRow(started, 0).includes('task-row-action--track'), | |
| 844 | + | 'track button when started'); | |
| 845 | + | }); | |
| 846 | + | ||
| 847 | + | test('every row action carries an aria-label and a title', () => { | |
| 848 | + | const html = GoingsOn.tasksRender.renderRowActions({ id: 'abc' }, true); | |
| 849 | + | const buttons = html.split('<button').slice(1); | |
| 850 | + | assertEqual(buttons.length, 3); | |
| 851 | + | for (const b of buttons) { | |
| 852 | + | assert(b.includes('aria-label="'), 'has aria-label'); | |
| 853 | + | assert(b.includes('title="'), 'has title'); | |
| 854 | + | } | |
| 855 | + | }); | |
| 856 | + | ||
| 857 | + | test('the started marker no longer carries a click action', () => { | |
| 858 | + | const started = Object.assign(baseTask(), { status: 'Started' }); | |
| 859 | + | const html = GoingsOn.tasksRender.renderTaskRow(started, 0); | |
| 860 | + | assert(html.includes('class="task-started-icon"'), 'marker still shown'); | |
| 861 | + | const marker = html.slice(html.indexOf('task-started-icon')); | |
| 862 | + | assert(!marker.slice(0, 120).includes('data-act'), 'marker is status only'); | |
| 863 | + | }); | |
| 864 | + | ||
| 865 | + | test('ids are attribute-escaped in row actions', () => { | |
| 866 | + | const html = GoingsOn.tasksRender.renderRowActions({ id: 'a" onfocus=alert(1) x="' }, true); | |
| 867 | + | assert(html.includes('" onfocus=alert(1)'), 'the quote is entity-encoded, not closing the attribute'); | |
| 868 | + | assert(!/data-a1="a" /.test(html), 'no attribute breakout'); | |
| 869 | + | }); | |
| 870 | + | }); | |
| 871 | + | ||
| 872 | + | describe('GoingsOn.tasksRender.renderTimeBadge', () => { | |
| 873 | + | test('running timer renders the dot plus a live elapsed readout', () => { | |
| 874 | + | const startedAt = new Date(Date.now() - 90 * 1000).toISOString(); | |
| 875 | + | const html = GoingsOn.tasksRender.renderTimeBadge({ timerActive: true, timerStartedAt: startedAt }); | |
| 876 | + | assert(html.includes('task-timer-active'), 'dot still present'); | |
| 877 | + | assert(html.includes('task-timer-elapsed'), 'elapsed span present'); | |
| 878 | + | assert(html.includes(`data-started="${startedAt}"`), 'carries its start time for the tick'); | |
| 879 | + | assert(html.includes('1:30'), 'shows elapsed m:ss'); | |
| 880 | + | }); | |
| 881 | + | ||
| 882 | + | test('running timer without a start time falls back to the bare dot', () => { | |
| 883 | + | const html = GoingsOn.tasksRender.renderTimeBadge({ timerActive: true }); | |
| 884 | + | assert(html.includes('task-timer-active'), 'dot present'); | |
| 885 | + | assert(!html.includes('task-timer-elapsed'), 'no elapsed span'); | |
| 886 | + | }); | |
| 887 | + | ||
| 888 | + | test('idle task with tracked time renders the tracked/estimated badge', () => { | |
| 889 | + | const html = GoingsOn.tasksRender.renderTimeBadge({ actualMinutes: 90, estimatedMinutes: 60 }); | |
| 890 | + | assert(html.includes('1h 30m / 1h 0m'), 'formats both values'); | |
| 891 | + | assert(html.includes('over-estimate'), 'flags over estimate'); | |
| 892 | + | }); | |
| 893 | + | ||
| 894 | + | test('idle task with no time renders nothing', () => { | |
| 895 | + | assertEqual(GoingsOn.tasksRender.renderTimeBadge({}), ''); | |
| 896 | + | }); | |
| 897 | + | }); | |
| 898 | + | ||
| 899 | + | describe('GoingsOn.timeTracking.fmtElapsed', () => { | |
| 900 | + | test('formats under an hour as m:ss', () => { | |
| 901 | + | assertEqual(GoingsOn.timeTracking.fmtElapsed(new Date(Date.now() - 65 * 1000).toISOString()), '1:05'); | |
| 902 | + | }); | |
| 903 | + | ||
| 904 | + | test('formats over an hour as h:mm:ss', () => { | |
| 905 | + | const ms = (2 * 3600 + 3 * 60 + 4) * 1000; | |
| 906 | + | assertEqual(GoingsOn.timeTracking.fmtElapsed(new Date(Date.now() - ms).toISOString()), '2:03:04'); | |
| 907 | + | }); | |
| 908 | + | ||
| 909 | + | test('clamps a future start time to zero', () => { | |
| 910 | + | assertEqual(GoingsOn.timeTracking.fmtElapsed(new Date(Date.now() + 60000).toISOString()), '0:00'); | |
| 911 | + | }); | |
| 912 | + | }); | |
| 913 | + | ||
| 800 | 914 | // Report | |
| 801 | 915 | ||
| 802 | 916 | const success = report(); |