Skip to main content

max / goingson

Surface the task row actions on hover Snooze, Track Time and Focus all existed and none were visible: snooze was context menu or the s key, track time was an unlabelled icon in the description cell, focus was two levels down in the detail modal. All three now render as icon buttons in the existing actions cell, ahead of the checkbox and kebab. The markup is unconditional and the reveal is opacity on row hover or focus-within, mirroring what the kebab already does, so keyboard and screen-reader users reach the buttons the row never shows them. No new column: the row grid template is shared across the list views and widening it here would move all of them. Touch has no hover, so the buttons are forced visible with 44px targets in the three places the kebab is already un-hidden: the hover: none query, body.is-touch, and mobile UI mode. The swipe-left snooze gesture is untouched. task-started-icon loses its click target to the new Track Time button and stays as a status marker, aria-hidden with no pointer affordance. The resting row is unchanged. A running timer shows elapsed time in the row rather than only a pulsing dot. timerStartedAt was already on TaskResponse, so this needs no backend change, and the ticking reuses the time-tracking widget's existing one-second interval instead of adding an interval per row. fmtElapsed was local to that module and duplicated the subview's formatting; it is now one exported formatter with three callers. Converting an email to a task offers Start Timer in the success toast, through the action option showToast already took. The suite did not load tasks-render or time-tracking before this; both are registered with the two collaborator stubs the row renderer needs.
Co-Authored-By
Claude Opus 5 (1M context) <noreply@anthropic.com>
Author: Max Johnson <me@maxj.phd> · 2026-07-29 02:02 UTC
Signed with PGP, not checked
Commit: 8ab418984d5f6ec0230ac4b4bbef65584c91fe38
Parent: a1f4bed
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">&#x25B6;&#xFE0E;</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">&#x25CE;</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">&#x263E;&#xFE0E;</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('&quot; 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();