From 556a14314bdbe90d96b224fb06844bf9244e46fe Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Corn=C3=A9=20Dorrestijn?= Date: Tue, 3 Nov 2026 10:24:43 +0100 Subject: [PATCH] feat(LAN-740): timer menu on the timer chips, clearer stop dialog The tray and issue-header chips open a menu (open, stop and log, move, discard). The stop dialog says Stop timer, shows the summary, run time and rounding, and logs instead of saving. Esc on a context menu no longer closes the issue panel. --- docs/guide/05-your-day.md | 4 +- internal/i18n/nl/web-lib.json | 7 +++- internal/web/static/css/issue.css | 4 +- internal/web/static/css/work.css | 4 ++ internal/web/static/js/lib/ctxmenu.js | 4 +- internal/web/static/js/lib/timer.js | 56 ++++++++++++++++++++++---- internal/web/static/js/lib/worktime.js | 18 ++++++--- internal/web/static/js/views/issue.js | 2 +- 8 files changed, 79 insertions(+), 20 deletions(-) diff --git a/docs/guide/05-your-day.md b/docs/guide/05-your-day.md index d229d195..621330d1 100644 --- a/docs/guide/05-your-day.md +++ b/docs/guide/05-your-day.md @@ -50,7 +50,9 @@ starts timing that one. === "Browser" - The running timer sits in the bar on top; a click stops it. + The running timer sits in the bar on top and in the issue's header. A + click on either opens its menu: open the issue, stop and log, move the + timer to the issue you're on, or discard it. > **Try it:** `T` on the card you're about to work on. When you're done, > `T` again, type what you did, `enter`. Your time is logged. diff --git a/internal/i18n/nl/web-lib.json b/internal/i18n/nl/web-lib.json index 9d46f923..6c193e69 100644 --- a/internal/i18n/nl/web-lib.json +++ b/internal/i18n/nl/web-lib.json @@ -44,6 +44,7 @@ "%s · laneway": "%s · laneway", "%s → %s": "%s → %s", "%s → backlog": "%s → backlog", + "%s, rounded to %s": "%s, afgerond op %s", "%s: %s": "%s: %s", "%s: %s %s": "%s: %s %s", "%s: its log": "%s: het log", @@ -235,6 +236,7 @@ "Offline. You see what was loaded; writes wait until the site is back.": "Offline. Je ziet wat geladen was; wijzigingen wachten tot de site terug is.", "Offline: kept for later, sent when the site is back": "Offline: bewaard voor later, verstuurd zodra de site terug is", "Open": "Openen", + "Open %s": "%s openen", "Open draft": "Concept openen", "Open draft pull request for %s": "Concept-pull request voor %s openen", "Open in GitLab (o)": "Openen in GitLab (o)", @@ -296,6 +298,7 @@ "Sign in with glab: ": "Inloggen met glab: ", "Signed in as %s. Opening %s…": "Ingelogd als %s. %s openen…", "Signing in…": "Inloggen…", + "Since %s": "Sinds %s", "Site: %s": "Site: %s", "Site…": "Site…", "Someone else…": "Iemand anders…", @@ -312,6 +315,7 @@ "Status: %s": "Status: %s", "Still offline": "Nog steeds offline", "Stop": "Stoppen", + "Stop and log work…": "Stoppen en werk loggen…", "Stop timer and log %s": "Timer stoppen en %s boeken", "Stop timer on %s": "Timer op %s stoppen", "Story points": "Storypoints", @@ -344,7 +348,8 @@ "Time": "Tijd", "Timer kept in this browser only: %s": "Timer alleen in deze browser bewaard: %s", "Timer moved to %s, %s on it": "Timer verplaatst naar %s, %s erop", - "Timer on %s since %s · T stops it": "Timer op %s sinds %s · T stopt hem", + "Timer on %s since %s": "Timer op %s sinds %s", + "Timer ran from %s to %s": "Timer liep van %s tot %s", "Timer running · T stops it": "Timer loopt · T stopt hem", "Timer started on %s": "Timer gestart op %s", "Today’s date": "Datum van vandaag", diff --git a/internal/web/static/css/issue.css b/internal/web/static/css/issue.css index 2d5e779f..7339e90b 100644 --- a/internal/web/static/css/issue.css +++ b/internal/web/static/css/issue.css @@ -15,7 +15,9 @@ .pane.details.waiting > :not(.fields) { display: none; } /* issue.js: the rest shows with the issue */ .pill-btn { background: none; border: 0; padding: 0; cursor: pointer; font: inherit; } .chip.flag { background: var(--warn); color: var(--bg); } -.chip.timer-mark { color: var(--accent); cursor: pointer; } +button.chip.timer-mark { display: inline-flex; align-items: center; gap: .286rem; border: 0; font: inherit; font-size: .786rem; font-weight: 500; font-variant-numeric: tabular-nums; + background: var(--accent-bg); color: var(--accent); cursor: pointer; } +button.chip.timer-mark:hover { background: color-mix(in srgb, var(--accent) 22%, transparent); } .iss-tabs { display: flex; gap: .143rem; padding: 0 calc(var(--pad) * 2); border-bottom: 1px solid var(--border); flex: none; } .tab { font: inherit; color: var(--fg-2); background: none; border: 0; border-bottom: 2px solid transparent; padding: .429rem .714rem; cursor: pointer; display: flex; gap: .429rem; align-items: baseline; } diff --git a/internal/web/static/css/work.css b/internal/web/static/css/work.css index 5c1c88d8..f62e9332 100644 --- a/internal/web/static/css/work.css +++ b/internal/web/static/css/work.css @@ -42,6 +42,10 @@ .wl-form { display: flex; flex-direction: column; gap: var(--pad); } .wl-row { display: grid; grid-template-columns: 6.429rem 1fr; gap: var(--pad); align-items: start; } .wl-label { color: var(--fg-2); padding-top: .429rem; } .wl-field { display: flex; gap: var(--pad); flex-direction: column; } .wl-err { color: var(--err); } .wl-note { font-size: .857rem; } +.wl-field .wl-note { margin-top: calc(var(--pad) * -.5); } +.wl-sum { color: var(--fg-2); margin-top: calc(var(--pad) * -.5); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.btn.ghost.wl-discard { color: var(--err); } .btn.ghost.wl-discard:hover { background: color-mix(in srgb, var(--err) 12%, transparent); } +.timer-since { margin-left: .5rem; color: var(--accent); font-weight: 500; font-variant-numeric: tabular-nums; } .standup { height: 100%; overflow: auto; padding: calc(var(--pad) * 2); max-width: 70rem; } .sthead { display: flex; flex-wrap: wrap; align-items: center; gap: var(--pad); margin-bottom: calc(var(--pad) * 1.5); } .sthead h2 { margin: 0; } .sthead .btn { white-space: nowrap; } .stsec { margin-bottom: calc(var(--pad) * 2); } .stsec h3 { color: var(--fg-2); text-transform: uppercase; font-size: .786rem; letter-spacing: .05em; border-bottom: 1px solid var(--border); padding-bottom: .214rem; } diff --git a/internal/web/static/js/lib/ctxmenu.js b/internal/web/static/js/lib/ctxmenu.js index 6aa30b7d..d61201d8 100644 --- a/internal/web/static/js/lib/ctxmenu.js +++ b/internal/web/static/js/lib/ctxmenu.js @@ -19,7 +19,7 @@ export function ctxMenu(items, x, y, { head = null } = {}) { if (dead) return; dead = true; shown = null; for (const l of levels) l.el.remove(); - document.removeEventListener('keydown', onKey, true); + window.removeEventListener('keydown', onKey, true); document.removeEventListener('pointerdown', onDown, true); window.removeEventListener('resize', close); window.removeEventListener('blur', close); @@ -111,7 +111,7 @@ export function ctxMenu(items, x, y, { head = null } = {}) { function onScroll(e) { if (!levels.some(l => l.el.contains(e.target))) close(); } open(items, 0, x, y, null, head); - document.addEventListener('keydown', onKey, true); + window.addEventListener('keydown', onKey, true); // before lib/keys.js on document: esc closes the menu, not the panel document.addEventListener('pointerdown', onDown, true); window.addEventListener('resize', close); window.addEventListener('blur', close); diff --git a/internal/web/static/js/lib/timer.js b/internal/web/static/js/lib/timer.js index 86a70456..577dbef0 100644 --- a/internal/web/static/js/lib/timer.js +++ b/internal/web/static/js/lib/timer.js @@ -31,7 +31,7 @@ export function target(app) { export function install(app) { let timer = load(); const serverTimer = () => !!(app.session && app.session.timesheet && app.session.timesheet.serverTimer); - const chip = app.chrome.add(h('button.timer-chip.accent', { hidden: true, onclick: () => toggle('') }), 30); + const chip = app.chrome.add(h('button.timer-chip.accent', { hidden: true, 'aria-haspopup': 'menu' }), 30); let tick = 0; const elapsed = () => Date.now() - timer.start; @@ -39,7 +39,7 @@ export function install(app) { const label = () => [h('span.tk', timer.key), since()]; function paint() { chip.hidden = !timer; - if (timer) { chip.replaceChildren(...label()); chip.title = T('Timer on %s since %s · T stops it', timer.key, hm(new Date(timer.start))); } + if (timer) { chip.replaceChildren(...label()); chip.title = T('Timer on %s since %s', timer.key, hm(new Date(timer.start))); } arm(); } // Redraw while the tab is visible and a timer runs; nothing otherwise. @@ -78,6 +78,13 @@ export function install(app) { document.addEventListener('visibilitychange', () => { if (!document.hidden) sync(); }); setInterval(() => { if (!document.hidden) sync(); }, 30000); + // The timed issue's summary, for the menu and the stop dialog; '' when it can't be had. + const sums = new Map(); + const summary = key => { + if (!sums.has(key)) sums.set(key, app.api.get('/issues/' + encodeURIComponent(key) + '/card').then(c => (c && c.Summary) || '', () => { sums.delete(key); return ''; })); + return sums.get(key); + }; + function round(ms) { const step = parseDuration(String(app.prefs.get('timer_round', (app.session && app.session.ui && app.session.ui.TimerRound) || '')) || ''); const secs = ms / 1000; @@ -96,15 +103,48 @@ export function install(app) { if (serverTimer() && !timer.id) await sync(); if (!timer) return; const t = timer; - const res = await logDialog(app, { key: t.key, seconds: round(Date.now() - t.start), started: new Date(t.start), edit: t.id, discard: true, move: next, note: next && T('Then the timer starts on %s', next) }); + const res = await logDialog(app, { key: t.key, summary: await summary(t.key), seconds: round(Date.now() - t.start), ran: Math.floor((Date.now() - t.start) / 1000), started: new Date(t.start), edit: t.id, timer: true, move: next, note: next && T('Then the timer starts on %s', next) }); if (res === 'move') return move(next); - if (res === 'discard') { - if (Date.now() - t.start >= MIN5 && !(await app.ui.confirm({ title: T('Discard timer'), text: T('%s on %s will not be logged.', duration(Math.floor((Date.now() - t.start) / 1000)), t.key), ok: T('Discard'), danger: true }))) return; - } else if (res !== 'logged') return; - if (timer && timer.start === t.start) set(null, res === 'discard' ? t.key : ''); + if (res === 'discard') return discard(next); + if (res !== 'logged') return; + if (timer && timer.start === t.start) set(null); + if (next) start(next); + } + + // Drop the running timer, nothing logged; past five minutes only once confirmed. `next` starts afterwards. + async function discard(next) { + if (!timer) return; + const t = timer; + if (Date.now() - t.start >= MIN5 && !(await app.ui.confirm({ title: T('Discard timer'), text: T('%s on %s will not be logged.', duration(Math.floor((Date.now() - t.start) / 1000)), t.key), ok: T('Discard'), danger: true }))) return; + if (timer && timer.start === t.start) set(null, t.key); if (next) start(next); } + // The tray chip's menu: the timed issue, then open it, stop it, move it here or drop it. + function menuItems() { + const here = target(app), out = [ + app.panel.key !== timer.key && { label: T('Open %s', timer.key), run: () => app.panel.open(timer.key) }, + { label: T('Stop and log work…'), run: () => stop() }, + ]; + if (here && here !== timer.key) out.push({ label: T('Move timer to %s', here), run: () => move(here) }); + out.push('-', { label: T('Discard timer'), run: () => discard() }); + return out.filter(Boolean); + } + const menuHead = sum => h('div.ctx-head.timer-head', { role: 'none' }, + h('div.ctx-who', h('div.ctx-name', timer.key, h('span.timer-since', since())), sum && h('div.faint', sum), h('div.faint', T('Since %s', hm(new Date(timer.start)))))); + // menu(el): the timer's menu under el (the tray chip, the issue header's chip). A press on el while its + // menu is open only closes it (the menu's own outside press does). + let pressedOpen = null; + addEventListener('pointerdown', e => { pressedOpen = document.querySelector('.ctx .timer-head') ? e.target : null; }, true); + async function menu(el) { + const pressed = pressedOpen; pressedOpen = null; + if (pressed && el.contains(pressed)) return; + if (!timer) return; + const r = el.getBoundingClientRect(), [m, sum] = await Promise.all([import('./ctxmenu.js'), summary(timer.key)]); + if (timer) m.ctxMenu(menuItems(), r.left, r.bottom + 4, { head: menuHead(sum) }); + } + chip.addEventListener('click', () => menu(chip)); + // Move the running timer to key, its time along, nothing logged (the TUI's ctrl+t at the stop prompt). function move(key) { if (!timer || !key || key === timer.key) return; @@ -131,7 +171,7 @@ export function install(app) { // mark(key): "12m" (beside a timer icon) on the timed issue's card, row and panel, '' on any other. Redraw on bus 'timer' and 'timer:tick'. const mark = key => (timer && timer.key === key ? since() : ''); - app.timer = { get current() { return timer; }, toggle, start, stop, move, mark, elapsed: () => (timer ? elapsed() : 0) }; + app.timer = { get current() { return timer; }, toggle, start, stop, move, discard, menu, mark, elapsed: () => (timer ? elapsed() : 0) }; paint(); sync(); installBadge(app); diff --git a/internal/web/static/js/lib/worktime.js b/internal/web/static/js/lib/worktime.js index 950c6940..a2359feb 100644 --- a/internal/web/static/js/lib/worktime.js +++ b/internal/web/static/js/lib/worktime.js @@ -44,7 +44,9 @@ export function workdays(app) { return out.length ? out : [1, 2, 3, 4, 5]; } -// logDialog(app, {key, summary?, seconds?, started?: Date, comment?, edit?: worklog id, discard?: bool, move?: key, note?}) +// logDialog(app, {key, summary?, seconds?, started?: Date, comment?, edit?: worklog id, timer?: bool, ran?: seconds, move?: key, note?}) +// timer: stopping the running timer (edit is then the timesheet's running entry): it logs, and offers Discard; +// ran is how long it ran, said beside seconds when timer_round made them differ. // → Promise<'logged' | 'discard' | 'move' | null>. Posts (or puts, when editing) before it resolves. export function logDialog(app, o) { const { ui, api, bus } = app; @@ -58,6 +60,8 @@ export function logDialog(app, o) { const leftVal = h('input.input', { type: 'text', placeholder: '2h', hidden: true }); left.addEventListener('change', () => { leftVal.hidden = left.value !== 'set'; if (!leftVal.hidden) leftVal.focus(); }); const err = h('div.wl-err', { hidden: true }); + const rounded = o.ran && o.seconds && Math.abs(o.seconds - o.ran) >= 60; + const since = o.timer && o.started && h('div.dim.wl-note', T('Timer ran from %s to %s', hm(o.started), hm(new Date())) + (rounded ? ' · ' + T('%s, rounded to %s', duration(o.ran), duration(o.seconds)) : '')); const row = (label, ...nodes) => h('label.wl-row', h('span.wl-label', label), h('span.wl-field', nodes)); const submit = async () => { const secs = parseDuration(time.value); @@ -80,19 +84,21 @@ export function logDialog(app, o) { if (o.edit) await api.put('/worklog/' + o.key + '/' + o.edit, body); else await api.post('/worklog/' + o.key, body); } catch (e) { save.disabled = false; err.hidden = false; err.textContent = e.message; return; } result = 'logged'; - ui.toast(o.edit ? T('Updated %s on %s', duration(secs), o.key) : T('Logged %s on %s', duration(secs), o.key), { kind: 'ok' }); + ui.toast(editing ? T('Updated %s on %s', duration(secs), o.key) : T('Logged %s on %s', duration(secs), o.key), { kind: 'ok' }); bus.emit('issue:changed', { key: o.key }); m.close(); }; - const save = h('button.btn.primary', { type: 'submit' }, o.edit ? T('Save') : T('Log work'), keyHint('mod+Enter')); + const editing = o.edit && !o.timer; + const save = h('button.btn.primary', { type: 'submit' }, editing ? T('Save') : T('Log work'), keyHint('mod+Enter')); const form = h('form.wl-form', { onsubmit: e => { e.preventDefault(); submit(); } }, + o.summary && h('div.wl-sum', o.summary), o.note && h('div.dim.wl-note', o.note), - row(T('Time'), time), row(T('Date'), date), row(T('Comment'), comment), + row(T('Time'), time, since), row(T('Date'), date), row(T('Comment'), comment), !o.edit && !(app.session && app.session.timesheet && app.session.timesheet.remaining === false) && row(T('Remaining'), left, leftVal), err, - h('div.row.end', o.discard && h('button.btn.ghost', { type: 'button', onclick: () => { result = 'discard'; m.close(); } }, T('Discard timer')), + h('div.row.end', o.timer && h('button.btn.ghost.wl-discard', { type: 'button', onclick: () => { result = 'discard'; m.close(); } }, T('Discard timer')), o.move && h('button.btn.ghost', { type: 'button', title: T('Its time goes along; nothing is logged'), onclick: () => { result = 'move'; m.close(); } }, T('Move timer to %s', o.move)), h('span.spacer'), h('button.btn', { type: 'button', onclick: () => m.close() }, T('Cancel'), keyHint('Escape')), save)); - const title = (o.edit ? T('Edit work on %s', o.key) : T('Log work on %s', o.key)) + (o.summary ? ' · ' + o.summary : ''); + const title = editing ? T('Edit work on %s', o.key) : o.timer ? T('Stop timer on %s', o.key) : T('Log work on %s', o.key); const m = ui.modal(form, { title, onClose: () => resolve(result) }); m.scope.bind('mod+Enter', submit, 'save', { input: true, hidden: true }); time.select(); diff --git a/internal/web/static/js/views/issue.js b/internal/web/static/js/views/issue.js index bd1b15e3..f9d8ef2d 100644 --- a/internal/web/static/js/views/issue.js +++ b/internal/web/static/js/views/issue.js @@ -178,7 +178,7 @@ export function mountIssue(el, key, { app, full, card }) { h('h1.iss-title', { title: T('Edit summary'), onclick: e => edit('summary', e.currentTarget) }, summary || '…'), ...(i || c ? [h('div.iss-sub', h('button.pill-btn', { title: T('Change status'), onclick: () => app.actions.transition(key) }, i ? ui.statusPill(i.Status, i.StatusCategory) : ui.statusPill(c.Status, catOf(c))), ...(c && c.Flagged ? [h('span.chip.flag', T('Flagged'))] : []), - ...(app.timer && app.timer.mark(key) ? [h('button.chip.timer-mark', { title: T('Timer running · T stops it'), onclick: () => app.timer.toggle(key) }, icon('timer'), ' ' + app.timer.mark(key))] : []), + ...(app.timer && app.timer.mark(key) ? [h('button.chip.timer-mark', { title: T('Timer running · T stops it'), 'aria-haspopup': 'menu', onclick: e => app.timer.menu(e.currentTarget) }, icon('timer'), app.timer.mark(key))] : []), ...(app.agents ? [app.agents.chip(key)] : []), h('span.dim', T('updated %s', ago((i || c).Updated))))] // its height held, or the tabs below jump down as it comes