Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion docs/guide/05-your-day.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
7 changes: 6 additions & 1 deletion internal/i18n/nl/web-lib.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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)",
Expand Down Expand Up @@ -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…",
Expand All @@ -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",
Expand Down Expand Up @@ -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",
Expand Down
4 changes: 3 additions & 1 deletion internal/web/static/css/issue.css
Original file line number Diff line number Diff line change
Expand Up @@ -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; }
Expand Down
4 changes: 4 additions & 0 deletions internal/web/static/css/work.css
Original file line number Diff line number Diff line change
Expand Up @@ -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; }
Expand Down
4 changes: 2 additions & 2 deletions internal/web/static/js/lib/ctxmenu.js
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -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);
Expand Down
56 changes: 48 additions & 8 deletions internal/web/static/js/lib/timer.js
Original file line number Diff line number Diff line change
Expand Up @@ -31,15 +31,15 @@ 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;
const since = () => (elapsed() < 60000 ? '<1m' : duration(Math.floor(elapsed() / 1000)));
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.
Expand Down Expand Up @@ -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;
Expand All @@ -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;
Expand All @@ -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);
Expand Down
18 changes: 12 additions & 6 deletions internal/web/static/js/lib/worktime.js
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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);
Expand All @@ -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();
Expand Down
2 changes: 1 addition & 1 deletion internal/web/static/js/views/issue.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading