diff --git a/browser-extension/README.md b/browser-extension/README.md
index bc4b95f8d..1240d2f6c 100644
--- a/browser-extension/README.md
+++ b/browser-extension/README.md
@@ -1,17 +1,32 @@
# TimeTracker Browser Extension
-Chromium (Chrome, Edge, Brave, etc.) Manifest V3 extension for starting and stopping timers against a self-hosted TimeTracker instance. Implements [issue #700](https://github.com/DRYTRIX/TimeTracker/issues/700).
+Chromium (Chrome, Edge, Brave, etc.) Manifest V3 extension for starting and stopping timers against a self-hosted TimeTracker instance. Apple-inspired redesign (v5.19): a three-tab popup (Timer / Insights / Recents), keyboard shortcuts, right-click tracking, and a redesigned settings page. Same `/api/v1` surface as the [desktop](../desktop/) and [mobile](../mobile/) apps — session cookies and Socket.IO are not used.
## Features
-- Connect with username/password (`POST /api/v1/auth/login`) or paste an existing `tt_…` API token
-- Start / pause / resume / stop the active project timer
-- Toolbar badge with elapsed time and a red clock icon while running
-- Searchable project list (favorites first when available; paginated beyond 100)
-- Optional task + notes on start
-- Quick-create task or project (client optional)
-
-Uses the same `/api/v1` surface as the [desktop](../desktop/) and [mobile](../mobile/) apps. It does **not** use session cookies or Socket.IO.
+### Timer
+- Connect with username/password (supports 2FA) or paste an existing `tt_…` API token
+- One search field for projects, tasks, and clients (keyboard-first: `/` to focus, arrows + Enter to pick)
+- Recent and favorites chips for one-click starts; last-used project preselected
+- Optional task + notes, with note suggestions pulled from your recent entries
+- Inline create for projects and tasks
+- Running view: large live clock, recording indicator, pause / resume / stop, quick note (updates the running entry)
+- Toolbar badge with elapsed time (red while recording, gray while paused; toggleable)
+
+### Insights (new)
+- Today: tracked total (including the running timer), billable, entry count
+- Weekly goal progress from `/api/v1/weekly-goals`
+- Last-7-days bar chart and top projects (from `/api/v1/reports/summary` + `/api/v1/time-entries`)
+
+### Recents (new)
+- Entries of the last 7 days grouped by day, with one-click "track again" (restarts the same project/task)
+- Link into the full history on your server
+
+### Everywhere
+- Global keyboard shortcuts: `Ctrl/Cmd+Shift+S` start/stop timer, `Ctrl/Cmd+Shift+T` open popup (customizable at `chrome://extensions/shortcuts`)
+- Right-click any selected text → *Track time: "…"* — starts the last-used project with the selection as note
+- Server-authoritative idle flow (Issue #722): the server arms the "Still working?" prompt, all devices share one check (first answer wins), and the extension never stops the timer itself. The idle timeout and unanswered action are **read from the server** and shown read-only in Settings — they are admin-managed, not per-user.
+- Auto light/dark design system (Apple-style tokens in `styles/tokens.css`)
## Load unpacked (development)
@@ -19,11 +34,19 @@ Uses the same `/api/v1` surface as the [desktop](../desktop/) and [mobile](../mo
2. Click **Load unpacked** and select this `browser-extension/` directory.
3. Open the extension **Options** (or right-click the toolbar icon → Options).
4. Enter your TimeTracker **base URL** (e.g. `https://timetracker.example.com` or `http://localhost:8080`).
-5. Either:
- - Sign in with username and password, or
- - Paste an API token from **Admin → API tokens** (or a token from a previous app login).
+5. Either sign in with username + password (2FA code prompt included) or paste an API token from **Admin → API tokens**.
6. Allow host access when the browser prompts you.
-7. Use the toolbar popup to start/stop timers.
+
+### Preview the UI without the extension runtime
+
+The popup and options pages run in a plain browser with demo data via `dev/mock.js` (a no-op inside the real extension):
+
+```bash
+cd browser-extension
+python -m http.server 8090
+# open http://localhost:8090/popup/popup.html
+# hash options: #idle (idle view), #paused (paused timer)
+```
## Package for distribution
@@ -42,43 +65,58 @@ If you create a token manually, include at least:
| Scope | Purpose |
|-------|---------|
-| `read:time_entries` | Timer status |
-| `write:time_entries` | Start / stop |
-| `read:projects` | Project list + favorites |
-| `write:projects` | Quick-create project |
+| `read:time_entries` / `write:time_entries` | Timer status, start/stop, recents, quick note |
+| `read:projects` / `write:projects` | Project list, favorites, quick-create |
| `read:tasks` / `write:tasks` | Task list + quick-create |
-| `read:clients` / `write:clients` | Client filter + inline create |
-| `read:users` | Preferred for `GET /api/v1/users/me` session check |
+| `read:clients` / `write:clients` | Client names, inline create |
+| `read:users` | Session check (`users/me`) + weekly goals |
+| `read:reports` | Insights tab (summary report) |
+
+Without `read:reports` / `read:users` the Insights tab degrades gracefully (zeros, no goal card).
## How it works
```
-Options → request host permission → GET /api/v1/info → login or validate token
+Options → request host permission → GET /api/v1/info → login (+2FA) or validate token
Background alarm (~15s unpacked; Chrome may throttle packed builds to ~1 min)
- → GET /api/v1/timer/status → badge text + idle/running icons
-Popup → start/stop/create via /api/v1/timer|projects|tasks|clients
+ → GET /api/v1/timer/status → badge text + icon (also returns the server-managed
+ idle_timeout_minutes / idle_unanswered_action applied locally)
+Popup → timer controls, unified project search, insights, recents
+Commands / context menu → start/stop via chrome.commands + contextMenus
```
Credentials are stored in `chrome.storage.local` (`server_url`, `api_token`).
-## OIDC-only servers
+## Idle detection (server-authoritative)
-`POST /api/v1/auth/login` requires local username/password. If your install uses OIDC only, create an API token in the admin UI and paste it in the extension settings.
+`GET /api/v1/timer/status` returns the install-wide policy: `idle_timeout_minutes`, `idle_unanswered_action` (`review` or `auto_stop`). The extension applies the timeout to `chrome.idle` and renders the server-armed prompt (`idle_notified`) — answers go through `POST /api/v1/timer/idle-response`, the server sweep resolves unanswered checks, and unanswered timers in `review` mode keep running and are flagged (`needs_review`) with a local notification. Heartbeats are only sent while the OS reports user activity, so closing the browser never stops the timer — time keeps accruing server-side.
## Layout
```
browser-extension/
- manifest.json
- background.js # alarm poll, badge, icon
- popup.html|js|css # timer UI + quick create
- options.html|js # connect / disconnect
- lib/api.js # /api/v1 client
- icons/ # idle + running PNGs
+ manifest.json # MV3, commands, contextMenus
+ background.js # poll, badge, icons, idle grace, heartbeat, shortcuts, context menu
+ popup/ # popup.html/css + main.js, timer-tab.js, insights-tab.js,
+ # recents-tab.js, search.js
+ options/ # options.html/css/js — connect (password+2FA / token),
+ # read-only server policy, preferences
+ styles/tokens.css # shared Apple-style design tokens (light/dark)
+ lib/api.js # /api/v1 client
+ lib/format.js # formatting/grouping helpers (tested)
+ lib/icons.js # inline SVG icon set
+ dev/mock.js # browser-preview shim with demo data (no-op in production)
+ icons/ # toolbar icons (idle + running)
+ build/zip.js # packager
+ test/ # node --test suite (api + helpers)
```
+## OIDC-only servers
+
+`POST /api/v1/auth/login` requires local username/password. If your install uses OIDC only, create an API token in the admin UI and paste it in the extension settings.
+
## Not in this version
- Firefox-specific packaging
-- Page content scripts / automatic project detection
+- Page content scripts / automatic project detection from the active tab
- OIDC device flow inside the extension
diff --git a/browser-extension/background.js b/browser-extension/background.js
index 824f15b19..2114e3a94 100644
--- a/browser-extension/background.js
+++ b/browser-extension/background.js
@@ -13,6 +13,7 @@ const ALARM_NAME = 'tt-timer-poll';
const IDLE_STOP_ALARM = 'tt-idle-stop';
const IDLE_NOTIFICATION_ID = 'tt-still-working';
const NEEDS_REVIEW_NOTIFICATION_ID = 'tt-needs-review';
+const CONTEXT_MENU_ID = 'tt-track-selection';
const POLL_MINUTES = 0.25; // ~15s
const GRACE_MINUTES = 5;
const DEFAULT_IDLE_TIMEOUT_MINUTES = 30;
@@ -51,15 +52,17 @@ function setIdleUi() {
chrome.action.setTitle({ title: 'TimeTracker — idle' });
}
-function setRunningUi(timer) {
+async function setRunningUi(timer) {
const seconds = elapsedSecondsFromTimer(timer);
- const badge = formatBadgeText(seconds);
- chrome.action.setBadgeBackgroundColor({ color: '#DC2626' });
- chrome.action.setBadgeText({ text: badge });
+ const paused = Boolean(timer.paused_at);
+ const { badge_show_elapsed = true } = await chrome.storage.local.get('badge_show_elapsed');
+ chrome.action.setBadgeBackgroundColor({ color: paused ? '#8E8E93' : '#DC2626' });
+ chrome.action.setBadgeText({ text: badge_show_elapsed ? formatBadgeText(seconds) : '' });
chrome.action.setIcon({ path: RUNNING_ICONS });
const project = timer.project || 'Timer';
const task = timer.task ? ` / ${timer.task}` : '';
- chrome.action.setTitle({ title: `TimeTracker — ${project}${task}` });
+ const state = paused ? ' — paused' : '';
+ chrome.action.setTitle({ title: `TimeTracker — ${project}${task}${state}` });
}
function clampIdleTimeoutMinutes(value) {
@@ -219,7 +222,7 @@ async function refreshTimerStatus({ force = false } = {}) {
await chrome.storage.local.set({ idle_unanswered_action: idleUnansweredAction });
if (active) {
- setRunningUi(status.timer);
+ await setRunningUi(status.timer);
// Server already marked this timer idle (#722) — enter the same grace
// window the local chrome.idle path uses, even if OS idle has not fired.
const idleNotified = Boolean(
@@ -295,9 +298,121 @@ async function ensureAlarm() {
}
}
+/** Small transient notification for command/context-menu feedback. */
+async function localNotify(title, message) {
+ try {
+ await chrome.notifications.create('tt-feedback', {
+ type: 'basic',
+ iconUrl: 'icons/idle-128.png',
+ title,
+ message,
+ priority: 1,
+ });
+ } catch (error) {
+ console.debug('[TimeTracker] notify failed:', error);
+ }
+}
+
+/** Resolve an authenticated ApiClient, or notify the user why we can't. */
+async function getClientOrNotify() {
+ const { server_url, api_token, logged_out } = await chrome.storage.local.get([
+ 'server_url',
+ 'api_token',
+ 'logged_out',
+ ]);
+ if (!server_url || !api_token || logged_out) {
+ await localNotify('TimeTracker', 'Connect the extension in Settings first.');
+ return null;
+ }
+ return new ApiClient(server_url, api_token);
+}
+
+/**
+ * Global shortcut: toggle the timer. Starts the most recently used project
+ * (remembered by the popup) or stops the running timer.
+ */
+async function toggleTimerFromCommand() {
+ const client = await getClientOrNotify();
+ if (!client) return;
+ const { last_project } = await chrome.storage.local.get('last_project');
+ try {
+ const status = await client.getTimerStatus();
+ if (status?.active && status?.timer) {
+ await client.stopTimer();
+ await localNotify('Timer stopped', status.timer.project || '');
+ } else if (last_project?.project_id) {
+ const result = await client.startTimer({
+ projectId: last_project.project_id,
+ taskId: last_project.task_id || null,
+ });
+ await localNotify('Timer started', result?.timer?.project || last_project.name || '');
+ } else {
+ await localNotify(
+ 'No recent project',
+ 'Start a timer once from the popup — its project will be remembered.'
+ );
+ }
+ } catch (error) {
+ await localNotify('TimeTracker', error.message || 'Command failed.');
+ }
+ await refreshTimerStatus({ force: true });
+}
+
+/** Context menu: track the selected text as the note of a new timer. */
+async function trackSelection(selectionText) {
+ const client = await getClientOrNotify();
+ if (!client) return;
+ const note = String(selectionText || '').slice(0, 500);
+ try {
+ const status = await client.getTimerStatus();
+ if (status?.active && status?.timer) {
+ await localNotify('Already tracking', 'Stop the current timer before starting a new one.');
+ return;
+ }
+ const { last_project } = await chrome.storage.local.get('last_project');
+ if (!last_project?.project_id) {
+ await localNotify(
+ 'No recent project',
+ 'Start a timer once from the popup — its project will be remembered.'
+ );
+ return;
+ }
+ const result = await client.startTimer({
+ projectId: last_project.project_id,
+ taskId: last_project.task_id || null,
+ notes: note,
+ });
+ await localNotify('Timer started', result?.timer?.project || last_project.name || '');
+ } catch (error) {
+ await localNotify('TimeTracker', error.message || 'Could not start timer.');
+ }
+ await refreshTimerStatus({ force: true });
+}
+
+chrome.commands.onCommand.addListener((command) => {
+ if (command === 'toggle-timer') {
+ void toggleTimerFromCommand();
+ }
+});
+
+chrome.contextMenus.onClicked.addListener((info) => {
+ if (info.menuItemId === CONTEXT_MENU_ID) {
+ void trackSelection(info.selectionText);
+ }
+});
+
chrome.runtime.onInstalled.addListener(() => {
ensureAlarm();
refreshTimerStatus({ force: true });
+ try {
+ chrome.contextMenus.create({
+ id: CONTEXT_MENU_ID,
+ title: 'Track time: “%s”',
+ contexts: ['selection'],
+ });
+ } catch (error) {
+ console.debug('[TimeTracker] context menu failed:', error);
+ }
});
chrome.runtime.onStartup.addListener(() => {
diff --git a/browser-extension/build/zip.js b/browser-extension/build/zip.js
index 04c3b5c86..a81611869 100644
--- a/browser-extension/build/zip.js
+++ b/browser-extension/build/zip.js
@@ -19,13 +19,13 @@ const DIST = join(ROOT, 'dist');
const INCLUDE = [
'manifest.json',
'background.js',
- 'popup.html',
- 'popup.js',
- 'popup.css',
- 'options.html',
- 'options.js',
+ 'popup',
+ 'options',
+ 'styles',
'lib/api.js',
- 'lib/picker.js',
+ 'lib/format.js',
+ 'lib/icons.js',
+ 'dev/mock.js',
'icons',
];
diff --git a/browser-extension/dev/generate-icons.mjs b/browser-extension/dev/generate-icons.mjs
new file mode 100644
index 000000000..721bd8a94
--- /dev/null
+++ b/browser-extension/dev/generate-icons.mjs
@@ -0,0 +1,86 @@
+/**
+ * Regenerates the toolbar icons (icons/idle-*.png and icons/running-*.png)
+ * from the header glyph design: rounded square, blue→indigo gradient,
+ * white timer glyph. The running variant carries a small red recording dot.
+ *
+ * Requires a Chromium/Brave instance with a CDP port (renders via the browser):
+ * brave.exe --remote-debugging-port=9333
+ * node dev/generate-icons.mjs 9333
+ */
+import fs from 'node:fs';
+import path from 'node:path';
+import { fileURLToPath } from 'node:url';
+import { chromium } from 'playwright';
+
+const port = process.argv[2] || '9333';
+const __dirname = path.dirname(fileURLToPath(import.meta.url));
+const OUT = path.join(__dirname, '..', 'icons');
+
+const SIZES = [128, 48, 32, 16];
+const RUNNING_DOT = { size: 0.32, ring: 0.035 }; // fraction of tile size
+
+function tileHtml(size, variant) {
+ const glyph = Math.max(8, Math.round(size * 0.58));
+ const radius = Math.max(3, Math.round(size * 0.225));
+ const dotSize = Math.round(size * RUNNING_DOT.size);
+ const ring = Math.max(1, Math.round(size * RUNNING_DOT.ring));
+ const dot =
+ variant === 'running'
+ ? ``
+ : '';
+ return `
`;
+}
+
+const html = ``;
+
+const browser = await chromium.connectOverCDP(`http://127.0.0.1:${port}`);
+try {
+ const ctx = browser.contexts()[0];
+ const page = await ctx.newPage();
+
+ for (const variant of ['idle', 'running']) {
+ await page.setContent(html);
+ await page.evaluate((cfg) => {
+ document.getElementById('wrap').innerHTML = cfg.sizes
+ .map((s) => `${'{{TILE}}'}
`)
+ .join('');
+ }, { sizes: SIZES });
+ // tiles are built per-variant with exact markup
+ for (const size of SIZES) {
+ await page.evaluate(
+ ({ size, variant, tileHtml }) => {
+ const host = document.querySelector(`[data-size="${size}"]`);
+ const template = document.createElement('div');
+ template.innerHTML = tileHtml;
+ host.replaceWith(template.firstElementChild);
+ },
+ { size, variant, tileHtml: tileHtml(size, variant) }
+ );
+ }
+ await page.setViewportSize({ width: 220, height: 460 });
+ for (const size of SIZES) {
+ const tile = page.locator(`.tile[style*="width:${size}px"]`);
+ const out = path.join(OUT, `${variant}-${size}.png`);
+ await tile.screenshot({ path: out, omitBackground: true });
+ const bytes = fs.statSync(out).size;
+ console.log(`${variant}-${size}.png ${bytes} bytes`);
+ }
+ }
+ await page.close();
+ console.log('done →', OUT);
+} finally {
+ await browser.close();
+}
diff --git a/browser-extension/dev/mock.js b/browser-extension/dev/mock.js
new file mode 100644
index 000000000..45bc88c4e
--- /dev/null
+++ b/browser-extension/dev/mock.js
@@ -0,0 +1,297 @@
+/**
+ * Dev preview shim: lets popup.html and options.html run in a plain browser
+ * (http://localhost) with realistic demo data — no extension runtime needed.
+ *
+ * It is a no-op inside the real extension (chrome.* already exists) and is
+ * safe to ship. To preview: cd browser-extension && python3 -m http.server,
+ * then open http://localhost:8000/popup/popup.html
+ * Hash options: #idle (idle view), #paused (paused timer).
+ */
+
+(() => {
+ if (typeof chrome !== 'undefined' && chrome.storage && chrome.runtime) return;
+
+ const pad2 = (n) => String(n).padStart(2, '0');
+ const isoLocal = (d) =>
+ `${d.getFullYear()}-${pad2(d.getMonth() + 1)}-${pad2(d.getDate())}` +
+ `T${pad2(d.getHours())}:${pad2(d.getMinutes())}:00`;
+ const dateKey = (d) =>
+ `${d.getFullYear()}-${pad2(d.getMonth() + 1)}-${pad2(d.getDate())}`;
+ const at = (offsetDays, h, m) => {
+ const d = new Date();
+ d.setDate(d.getDate() + offsetDays);
+ d.setHours(h, m, 0, 0);
+ return d;
+ };
+
+ const params = new URLSearchParams(window.location.search || '');
+ const mode = window.location.hash || (params.get('view') ? `#${params.get('view')}` : '');
+ const wantIdle = mode === '#idle';
+ const wantPaused = mode === '#paused';
+
+ const runningStart = new Date(Date.now() - 5077 * 1000);
+ const activeEntry = {
+ id: 501,
+ project_id: 1,
+ client_id: 11,
+ task_id: 101,
+ project: 'Homepage redesign',
+ client: 'Acme Corp',
+ task: 'Design review',
+ start_time: isoLocal(runningStart),
+ end_time: null,
+ paused_at: wantPaused ? isoLocal(new Date(Date.now() - 60 * 1000)) : null,
+ break_seconds: 720,
+ duration_seconds: null,
+ duration_hours: null,
+ notes: '',
+ billable: true,
+ is_active: true,
+ idle_notified: false,
+ needs_review: false,
+ };
+
+ const finishedEntries = [
+ { id: 481, project_id: 3, project: 'Q3 invoices', client_id: 11, client: 'Acme Corp', task_id: null, task: null, start: at(0, 8, 30), end: at(0, 9, 2), notes: 'Batch send', billable: true },
+ { id: 488, project_id: 2, project: 'Client portal API', client_id: 11, client: 'Acme Corp', task_id: null, task: null, start: at(0, 9, 15), end: at(0, 10, 40), notes: 'Pairing on auth', billable: true },
+ { id: 452, project_id: 1, project: 'Homepage redesign', client_id: 11, client: 'Acme Corp', task_id: 102, task: 'Sprint 42 planning', start: at(-1, 10, 0), end: at(-1, 11, 30), notes: '', billable: true },
+ { id: 455, project_id: 4, project: 'Internal R&D', client_id: null, client: null, task_id: null, task: null, start: at(-1, 14, 0), end: at(-1, 16, 12), notes: 'Landing page & Pricing Page Design', billable: false },
+ { id: 430, project_id: 1, project: 'Homepage redesign', client_id: 11, client: 'Acme Corp', task_id: null, task: null, start: at(-2, 9, 0), end: at(-2, 12, 10), notes: 'Design review call', billable: true },
+ { id: 428, project_id: 2, project: 'Client portal API', client_id: 11, client: 'Acme Corp', task_id: null, task: null, start: at(-3, 11, 0), end: at(-3, 12, 30), notes: '', billable: true },
+ ];
+
+ const toDict = (e) => {
+ const seconds = Math.round((e.end - e.start) / 1000);
+ return {
+ id: e.id,
+ project_id: e.project_id,
+ client_id: e.client_id,
+ task_id: e.task_id,
+ project: e.project,
+ client: e.client,
+ task: e.task,
+ start_time: isoLocal(e.start),
+ end_time: isoLocal(e.end),
+ duration_seconds: seconds,
+ duration_hours: seconds / 3600,
+ break_seconds: 0,
+ paused_at: null,
+ notes: e.notes,
+ billable: e.billable,
+ is_active: false,
+ idle_notified: false,
+ needs_review: false,
+ };
+ };
+
+ const allEntries = () => [
+ ...finishedEntries.map(toDict),
+ ...(wantIdle ? [] : [{ ...activeEntry }]),
+ ];
+
+ const projects = [
+ { id: 1, name: 'Homepage redesign', client_id: 11, last_used_at: new Date().toISOString() },
+ { id: 2, name: 'Client portal API', client_id: 11, last_used_at: new Date(Date.now() - 86400000).toISOString() },
+ { id: 4, name: 'Internal R&D', client_id: null, last_used_at: new Date(Date.now() - 2 * 86400000).toISOString() },
+ { id: 3, name: 'Q3 invoices', client_id: 11, last_used_at: new Date(Date.now() - 3 * 86400000).toISOString() },
+ ];
+ const tasksByProject = {
+ 1: [
+ { id: 101, name: 'Design review', status: 'in_progress' },
+ { id: 102, name: 'Sprint 42 planning', status: 'todo' },
+ { id: 103, name: 'Handoff to engineering', status: 'todo' },
+ { id: 104, name: 'Error Handling & Security Improvements', status: 'todo' },
+ ],
+ 2: [{ id: 201, name: 'Auth endpoints', status: 'in_progress' }],
+ };
+
+ const listeners = { changed: [] };
+ const store = new Map();
+ const put = (key, value) => {
+ store.set(key, value);
+ for (const fn of listeners.changed) {
+ fn({ [key]: { oldValue: undefined, newValue: value } }, 'local');
+ }
+ };
+ put('server_url', 'https://demo.local');
+ put('api_token', 'tt_demo');
+ put('logged_out', false);
+ put('badge_show_elapsed', true);
+ put('last_project', { project_id: 1, task_id: null, name: 'Homepage redesign' });
+ put('last_timer_status', {
+ active: !wantIdle,
+ timer: wantIdle ? null : activeEntry,
+ error: null,
+ });
+
+ const mondayOfThisWeek = () => {
+ const d = new Date();
+ const day = (d.getDay() + 6) % 7;
+ d.setDate(d.getDate() - day);
+ return d;
+ };
+ const weekStart = mondayOfThisWeek();
+ const weekEnd = new Date(weekStart);
+ weekEnd.setDate(weekEnd.getDate() + 6);
+
+ globalThis.chrome = {
+ storage: {
+ local: {
+ async get(keys) {
+ const out = {};
+ if (keys == null) {
+ for (const [k, v] of store) out[k] = v;
+ return out;
+ }
+ const list = Array.isArray(keys) ? keys : typeof keys === 'string' ? [keys] : Object.keys(keys);
+ for (const k of list) {
+ out[k] = store.has(k) ? store.get(k) : (Array.isArray(keys) ? undefined : keys[k]);
+ }
+ return out;
+ },
+ async set(obj) {
+ for (const [k, v] of Object.entries(obj)) put(k, v);
+ },
+ async remove(keys) {
+ for (const k of Array.isArray(keys) ? keys : [keys]) store.delete(k);
+ },
+ },
+ onChanged: {
+ addListener(fn) {
+ listeners.changed.push(fn);
+ },
+ },
+ },
+ runtime: {
+ sendMessage: async () => ({}),
+ connect: () => ({ onDisconnect: { addListener() {} } }),
+ openOptionsPage: () => {
+ window.location.href = '../options/options.html';
+ },
+ },
+ commands: {
+ getAll: async () => [
+ { name: 'toggle-timer', shortcut: 'Ctrl+Shift+S' },
+ { name: '_execute_action', shortcut: 'Ctrl+Shift+T' },
+ ],
+ },
+ permissions: {
+ contains: async () => true,
+ request: async () => true,
+ },
+ };
+
+ // ── fetch mock ─────────────────────────────────────────
+ const json = (body) => ({
+ ok: true,
+ status: 200,
+ text: async () => JSON.stringify(body),
+ });
+
+ function summaryFor(startKey, endKey) {
+ const entries = finishedEntries.map(toDict).filter((e) => {
+ const key = dateKey(new Date(e.start_time));
+ return (!startKey || key >= startKey) && (!endKey || key <= endKey);
+ });
+ const byProject = new Map();
+ let total = 0;
+ let billable = 0;
+ for (const e of entries) {
+ total += e.duration_hours;
+ if (e.billable) billable += e.duration_hours;
+ const key = e.project_id || 0;
+ const item = byProject.get(key) || { project_id: e.project_id, project_name: e.project, hours: 0, entries: 0 };
+ item.hours += e.duration_hours;
+ item.entries += 1;
+ byProject.set(key, item);
+ }
+ return {
+ summary: {
+ start_date: startKey,
+ end_date: endKey,
+ total_hours: Math.round(total * 100) / 100,
+ billable_hours: Math.round(billable * 100) / 100,
+ total_entries: entries.length,
+ by_project: Array.from(byProject.values()),
+ },
+ };
+ }
+
+ globalThis.fetch = async (url, init) => {
+ const parsed = new URL(url, window.location.origin);
+ const path = parsed.pathname.replace(/\/api\/v1$/, '/api/v1');
+ const method = (init?.method || 'GET').toUpperCase();
+
+ if (path === '/api/v1/timer/status') {
+ return json({
+ active: !wantIdle,
+ timer: wantIdle ? null : activeEntry,
+ idle_timeout_minutes: 30,
+ idle_unanswered_action: 'review',
+ idle_notified: false,
+ });
+ }
+ if (path === '/api/v1/timer/start') {
+ return json({ timer: { ...activeEntry } });
+ }
+ if (path === '/api/v1/timer/pause') {
+ return json({ time_entry: { ...activeEntry, paused_at: isoLocal(new Date()) } });
+ }
+ if (path === '/api/v1/timer/resume') {
+ return json({ time_entry: { ...activeEntry, paused_at: null } });
+ }
+ if (path === '/api/v1/timer/stop' || path === '/api/v1/timer/heartbeat') {
+ return json({ ok: true });
+ }
+ if (path.startsWith('/api/v1/time-entries/') && method === 'PATCH') {
+ return json({ time_entry: { ...activeEntry } });
+ }
+ if (path === '/api/v1/time-entries') {
+ const startKey = parsed.searchParams.get('start_date');
+ const endKey = parsed.searchParams.get('end_date');
+ const entries = allEntries().filter((e) => {
+ const key = dateKey(new Date(e.start_time));
+ return (!startKey || key >= startKey) && (!endKey || key <= endKey);
+ });
+ return json({ time_entries: entries, pagination: { page: 1, pages: 1 } });
+ }
+ if (path === '/api/v1/reports/summary') {
+ return json(
+ summaryFor(parsed.searchParams.get('start_date'), parsed.searchParams.get('end_date'))
+ );
+ }
+ if (path === '/api/v1/weekly-goals') {
+ return json({
+ weekly_goals: [
+ {
+ id: 1,
+ week_start_date: dateKey(weekStart),
+ week_end_date: dateKey(weekEnd),
+ target_hours: 40,
+ actual_hours: 26.4,
+ progress_percentage: 66,
+ remaining_hours: 13.6,
+ status: 'active',
+ },
+ ],
+ });
+ }
+ if (path === '/api/v1/projects') {
+ return json({ projects, pagination: { page: 1, pages: 1 } });
+ }
+ if (path === '/api/v1/users/me/favorites/projects') {
+ return json({ favorites: [{ project_id: 4 }] });
+ }
+ if (path === '/api/v1/clients') {
+ return json({ clients: [{ id: 11, name: 'Acme Corp' }] });
+ }
+ if (path === '/api/v1/tasks') {
+ const projectId = Number(parsed.searchParams.get('project_id'));
+ return json({ tasks: tasksByProject[projectId] || [] });
+ }
+ if (path === '/api/v1/users/me') {
+ return json({ user: { username: 'giorgio', email: 'giorgio@example.com' } });
+ }
+ return json({ error: 'mock: no route' });
+ };
+})();
diff --git a/browser-extension/icons/idle-128.png b/browser-extension/icons/idle-128.png
index ec5cabed2..caa7c06bb 100644
Binary files a/browser-extension/icons/idle-128.png and b/browser-extension/icons/idle-128.png differ
diff --git a/browser-extension/icons/idle-16.png b/browser-extension/icons/idle-16.png
index d01606b53..a2fddafc3 100644
Binary files a/browser-extension/icons/idle-16.png and b/browser-extension/icons/idle-16.png differ
diff --git a/browser-extension/icons/idle-32.png b/browser-extension/icons/idle-32.png
index f505cd42c..2f2bfb314 100644
Binary files a/browser-extension/icons/idle-32.png and b/browser-extension/icons/idle-32.png differ
diff --git a/browser-extension/icons/idle-48.png b/browser-extension/icons/idle-48.png
index ef99a2dfb..385238efe 100644
Binary files a/browser-extension/icons/idle-48.png and b/browser-extension/icons/idle-48.png differ
diff --git a/browser-extension/icons/running-128.png b/browser-extension/icons/running-128.png
index 1ad80fd4f..5cdd21b93 100644
Binary files a/browser-extension/icons/running-128.png and b/browser-extension/icons/running-128.png differ
diff --git a/browser-extension/icons/running-16.png b/browser-extension/icons/running-16.png
index d669f67af..a7320f6a8 100644
Binary files a/browser-extension/icons/running-16.png and b/browser-extension/icons/running-16.png differ
diff --git a/browser-extension/icons/running-32.png b/browser-extension/icons/running-32.png
index 541faf192..fa18851f2 100644
Binary files a/browser-extension/icons/running-32.png and b/browser-extension/icons/running-32.png differ
diff --git a/browser-extension/icons/running-48.png b/browser-extension/icons/running-48.png
index 6e0be1d1e..6752a8d0c 100644
Binary files a/browser-extension/icons/running-48.png and b/browser-extension/icons/running-48.png differ
diff --git a/browser-extension/lib/api.js b/browser-extension/lib/api.js
index 576cd8e59..ebfd7ce30 100644
--- a/browser-extension/lib/api.js
+++ b/browser-extension/lib/api.js
@@ -368,6 +368,50 @@ export class ApiClient {
getUsersMe() {
return this.request('GET', '/api/v1/users/me');
}
+
+ /**
+ * Summary report (total/billable hours, entries, per-project totals).
+ * startDate/endDate: local 'YYYY-MM-DD'.
+ */
+ getSummary({ startDate = null, endDate = null } = {}) {
+ const qs = new URLSearchParams();
+ if (startDate) qs.set('start_date', startDate);
+ if (endDate) qs.set('end_date', endDate);
+ const q = qs.toString();
+ return this.request('GET', `/api/v1/reports/summary${q ? `?${q}` : ''}`);
+ }
+
+ /**
+ * Time entries for a date range (used by the Recents and Insights tabs).
+ * Entries carry project/client/task display names.
+ */
+ getTimeEntries(params = {}) {
+ const qs = new URLSearchParams();
+ if (params.startDate) qs.set('start_date', params.startDate);
+ if (params.endDate) qs.set('end_date', params.endDate);
+ if (params.projectId) qs.set('project_id', String(params.projectId));
+ if (params.billable === true || params.billable === false) {
+ qs.set('billable', params.billable ? 'true' : 'false');
+ }
+ if (params.includeActive) qs.set('include_active', 'true');
+ if (params.page) qs.set('page', String(params.page));
+ if (params.perPage) qs.set('per_page', String(params.perPage));
+ const q = qs.toString();
+ return this.request('GET', `/api/v1/time-entries${q ? `?${q}` : ''}`);
+ }
+
+ /** Weekly time goals for the authenticated user (read:users scope). */
+ getWeeklyGoals(params = {}) {
+ const qs = new URLSearchParams();
+ if (params.status) qs.set('status', params.status);
+ const q = qs.toString();
+ return this.request('GET', `/api/v1/weekly-goals${q ? `?${q}` : ''}`);
+ }
+
+ /** Partial update — used for quick notes on the running entry. */
+ updateTimeEntry(entryId, fields) {
+ return this.request('PATCH', `/api/v1/time-entries/${Number(entryId)}`, fields);
+ }
}
/** Elapsed work seconds from a timer payload (respects break_seconds and paused_at). */
diff --git a/browser-extension/lib/format.js b/browser-extension/lib/format.js
new file mode 100644
index 000000000..002adb466
--- /dev/null
+++ b/browser-extension/lib/format.js
@@ -0,0 +1,149 @@
+/**
+ * Pure formatting/grouping helpers shared by popup, options and tests.
+ * No DOM or chrome.* dependencies.
+ */
+
+/** Apple-style palette assigned deterministically per project id. */
+const PROJECT_PALETTE = [
+ '#5856d6', // indigo
+ '#ff2d55', // pink
+ '#30b0c7', // teal
+ '#ff9f0a', // orange
+ '#34c759', // green
+ '#bf5af2', // purple
+ '#64d2ff', // cyan
+ '#ff9500', // amber
+];
+
+export function colorForProject(projectId) {
+ const id = Number(projectId);
+ if (!Number.isFinite(id)) return PROJECT_PALETTE[0];
+ return PROJECT_PALETTE[Math.abs(id) % PROJECT_PALETTE.length];
+}
+
+function pad2(n) {
+ return String(n).padStart(2, '0');
+}
+
+/** h:mm:ss when hours > 0, otherwise m:ss (mockup style). */
+export function formatClock(totalSeconds) {
+ const s = Math.max(0, Math.floor(totalSeconds || 0));
+ const h = Math.floor(s / 3600);
+ const m = Math.floor((s % 3600) / 60);
+ const sec = s % 60;
+ return h > 0 ? `${h}:${pad2(m)}:${pad2(sec)}` : `${m}:${pad2(sec)}`;
+}
+
+/** "5:12" style hours:minutes for stats. */
+export function formatHM(totalSeconds) {
+ const s = Math.max(0, Math.floor(totalSeconds || 0));
+ const h = Math.floor(s / 3600);
+ const m = Math.floor((s % 3600) / 60);
+ return `${h}:${pad2(m)}`;
+}
+
+/** Local YYYY-MM-DD for API date filters. */
+export function localDateKey(date) {
+ const d = date instanceof Date ? date : new Date(date);
+ return `${d.getFullYear()}-${pad2(d.getMonth() + 1)}-${pad2(d.getDate())}`;
+}
+
+export function addDays(date, days) {
+ const d = new Date(date.getTime());
+ d.setDate(d.getDate() + days);
+ return d;
+}
+
+const WEEKDAY_LETTERS = ['S', 'M', 'T', 'W', 'T', 'F', 'S'];
+
+export function weekdayLetter(date) {
+ const d = date instanceof Date ? date : new Date(date);
+ return WEEKDAY_LETTERS[d.getDay()];
+}
+
+const WEEKDAY_LONG = ['Sunday', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday'];
+
+/** "13:02" from an ISO-ish datetime string, rendered in local time. */
+export function formatTimeOf(iso) {
+ const d = new Date(iso);
+ if (Number.isNaN(d.getTime())) return '';
+ return `${pad2(d.getHours())}:${pad2(d.getMinutes())}`;
+}
+
+/** Elapsed work seconds for a time-entry dict (running or finished). */
+export function entryDurationSeconds(entry, now = Date.now()) {
+ if (!entry) return 0;
+ if (entry.duration_seconds != null && !entry.is_active) return Number(entry.duration_seconds) || 0;
+ const start = Date.parse(entry.start_time || '');
+ if (Number.isNaN(start)) return 0;
+ const endRef = entry.paused_at ? Date.parse(entry.paused_at) : now;
+ if (Number.isNaN(endRef)) return 0;
+ const breakSec = Number(entry.break_seconds) || 0;
+ return Math.max(0, Math.floor((endRef - start) / 1000) - breakSec);
+}
+
+/**
+ * Group time entries into display buckets, newest first.
+ * Returns [{ key, label, entries: [...] }] with Today/Yesterday/weekday labels.
+ */
+export function groupEntriesByDay(entries, now = new Date()) {
+ const todayKey = localDateKey(now);
+ const yesterdayKey = localDateKey(addDays(now, -1));
+ const buckets = new Map();
+
+ const sorted = [...(entries || [])].sort((a, b) => {
+ const at = Date.parse(a?.start_time || '') || 0;
+ const bt = Date.parse(b?.start_time || '') || 0;
+ return bt - at;
+ });
+
+ for (const entry of sorted) {
+ const start = new Date(entry?.start_time || '');
+ if (Number.isNaN(start.getTime())) continue;
+ const key = localDateKey(start);
+ if (!buckets.has(key)) {
+ let label;
+ if (key === todayKey) label = 'Today';
+ else if (key === yesterdayKey) label = 'Yesterday';
+ else label = WEEKDAY_LONG[start.getDay()];
+ buckets.set(key, { key, label, entries: [] });
+ }
+ buckets.get(key).entries.push(entry);
+ }
+
+ return Array.from(buckets.values());
+}
+
+/** Sum worked hours per local day for the last `days` days, oldest first. */
+export function dailyTotals(entries, days = 7, now = new Date()) {
+ const out = [];
+ const byDay = new Map();
+ for (const entry of entries || []) {
+ const start = new Date(entry?.start_time || '');
+ if (Number.isNaN(start.getTime())) continue;
+ const key = localDateKey(start);
+ const hours = entryDurationSeconds(entry, now.getTime()) / 3600;
+ byDay.set(key, (byDay.get(key) || 0) + hours);
+ }
+ for (let i = days - 1; i >= 0; i--) {
+ const date = addDays(now, -i);
+ const key = localDateKey(date);
+ out.push({ date, key, letter: weekdayLetter(date), hours: byDay.get(key) || 0 });
+ }
+ return out;
+}
+
+/** Unique, non-trivial note strings from entries, preserving recency order. */
+export function noteSuggestions(entries, limit = 3) {
+ const seen = new Set();
+ const out = [];
+ for (const entry of entries || []) {
+ const note = String(entry?.notes || '').trim();
+ const key = note.toLowerCase();
+ if (note.length < 3 || seen.has(key)) continue;
+ seen.add(key);
+ out.push(note);
+ if (out.length >= limit) break;
+ }
+ return out;
+}
diff --git a/browser-extension/lib/icons.js b/browser-extension/lib/icons.js
new file mode 100644
index 000000000..3b916eeaa
--- /dev/null
+++ b/browser-extension/lib/icons.js
@@ -0,0 +1,39 @@
+/**
+ * Minimal inline SVG icon set (stroke, currentColor) — matches SF Symbols
+ * aesthetics without shipping an icon font.
+ */
+
+const svg = (paths, viewBox = '0 0 24 24') =>
+ ``;
+
+export const icons = {
+ search: svg(''),
+ settings: svg(
+ '' +
+ ''
+ ),
+ play: svg(''),
+ pause: svg(''),
+ stop: svg(''),
+ plus: svg(''),
+ pencil: svg(''),
+ 'chevron-right': svg(''),
+ 'chevron-down': svg(''),
+ x: svg(''),
+ 'rotate-ccw': svg(''),
+ 'arrow-up-right': svg(''),
+ star: svg(
+ ''
+ ),
+ timer: svg(''),
+ 'check-circle': svg(''),
+ lock: svg(''),
+ 'log-out': svg(''),
+ refresh: svg(''),
+};
+
+/** Replace the contents of `el` with an icon by name. */
+export function setIcon(el, name) {
+ if (el && icons[name]) el.innerHTML = icons[name];
+}
diff --git a/browser-extension/lib/picker.js b/browser-extension/lib/picker.js
deleted file mode 100644
index 1ce956d97..000000000
--- a/browser-extension/lib/picker.js
+++ /dev/null
@@ -1,196 +0,0 @@
-/**
- * Searchable combobox with optional inline "Create …" row for the extension popup.
- * Mirrors app/static/searchable-select.js behaviour without depending on the web app.
- */
-
-/**
- * @param {HTMLSelectElement} select
- * @param {{
- * kind?: string,
- * canCreate?: boolean,
- * placeholder?: string,
- * onCreate?: (typedName: string) => void | Promise,
- * canCreateGuard?: () => boolean,
- * canCreateGuardHint?: string,
- * }} options
- */
-export function enhanceSelect(select, options = {}) {
- if (!select || select.tagName !== 'SELECT') return null;
- if (select.dataset.pickerEnhanced === '1') {
- return select._ttPicker || null;
- }
-
- const kind = options.kind || select.getAttribute('data-searchable-select') || 'option';
- const canCreate =
- options.canCreate != null
- ? options.canCreate
- : select.getAttribute('data-can-create') === '1';
- const createLabels = { client: 'Create client', project: 'Create project', task: 'Create task' };
-
- select.dataset.pickerEnhanced = '1';
- select.classList.add('sr-only');
- select.setAttribute('aria-hidden', 'true');
- select.tabIndex = -1;
-
- const wrapper = document.createElement('div');
- wrapper.className = 'tt-picker relative';
-
- const input = document.createElement('input');
- input.type = 'text';
- input.className = 'tt-picker-input';
- input.setAttribute('autocomplete', 'off');
- input.setAttribute('role', 'combobox');
- input.setAttribute('aria-expanded', 'false');
- input.placeholder = options.placeholder || select.getAttribute('data-search-placeholder') || 'Type to search…';
-
- const list = document.createElement('ul');
- list.className = 'tt-picker-list hidden';
- list.setAttribute('role', 'listbox');
-
- select.parentNode.insertBefore(wrapper, select);
- wrapper.appendChild(input);
- wrapper.appendChild(list);
- wrapper.appendChild(select);
-
- function selectedLabel() {
- const opt = select.options[select.selectedIndex];
- return opt ? (opt.textContent || '').trim() : '';
- }
-
- function readOptions() {
- const opts = [];
- Array.prototype.forEach.call(select.options, (opt) => {
- opts.push({
- value: opt.value,
- label: (opt.textContent || '').trim(),
- disabled: opt.disabled,
- });
- });
- return opts;
- }
-
- function closeList() {
- list.classList.add('hidden');
- input.setAttribute('aria-expanded', 'false');
- }
-
- function setValue(value, label) {
- select.value = value;
- input.value = label || selectedLabel();
- try {
- select.dispatchEvent(new Event('change', { bubbles: true }));
- } catch (_) {}
- closeList();
- }
-
- function renderList(query) {
- const q = (query || '').trim().toLowerCase();
- const optionsList = readOptions();
- const filtered = optionsList.filter((o) => {
- if (!q) return true;
- return (o.label || '').toLowerCase().includes(q);
- });
-
- list.innerHTML = '';
- if (!filtered.length && !(canCreate && q)) {
- const empty = document.createElement('li');
- empty.className = 'tt-picker-empty';
- empty.textContent = 'No matches';
- list.appendChild(empty);
- }
-
- filtered.forEach((o) => {
- const li = document.createElement('li');
- li.className = 'tt-picker-option';
- li.setAttribute('role', 'option');
- li.textContent = o.label || '(empty)';
- if (o.value === select.value) li.classList.add('is-selected');
- li.addEventListener('mousedown', (e) => {
- e.preventDefault();
- setValue(o.value, o.label);
- });
- list.appendChild(li);
- });
-
- if (canCreate && q) {
- const exact = optionsList.some((o) => (o.label || '').toLowerCase() === q);
- if (!exact) {
- const guardOk =
- typeof options.canCreateGuard !== 'function' || options.canCreateGuard();
- if (!guardOk) {
- const hint = document.createElement('li');
- hint.className = 'tt-picker-hint';
- hint.textContent = options.canCreateGuardHint || 'Not available';
- list.appendChild(hint);
- } else {
- const createLi = document.createElement('li');
- createLi.className = 'tt-picker-create';
- createLi.setAttribute('role', 'option');
- createLi.textContent = `${createLabels[kind] || 'Create'} “${query.trim()}”`;
- createLi.addEventListener('mousedown', (e) => {
- e.preventDefault();
- closeList();
- if (typeof options.onCreate === 'function') {
- options.onCreate(query.trim());
- }
- });
- list.appendChild(createLi);
- }
- }
- }
-
- list.classList.remove('hidden');
- input.setAttribute('aria-expanded', 'true');
- }
-
- input.value = selectedLabel();
-
- input.addEventListener('focus', () => {
- renderList(input.value === selectedLabel() ? '' : input.value);
- });
- input.addEventListener('input', () => renderList(input.value));
- input.addEventListener('keydown', (e) => {
- if (e.key === 'Escape') {
- closeList();
- input.value = selectedLabel();
- } else if (e.key === 'Enter') {
- const createRow = list.querySelector('.tt-picker-create');
- const first = list.querySelector('.tt-picker-option');
- if (!list.classList.contains('hidden')) {
- e.preventDefault();
- if (createRow && !first) {
- createRow.dispatchEvent(new Event('mousedown'));
- } else if (first) {
- first.dispatchEvent(new Event('mousedown'));
- }
- }
- }
- });
-
- // Reset visible input when user types but does not commit a selection (Issue #728)
- input.addEventListener('blur', () => {
- setTimeout(() => {
- input.value = selectedLabel();
- closeList();
- }, 150);
- });
-
- document.addEventListener('click', (e) => {
- if (!wrapper.contains(e.target)) closeList();
- });
-
- select.addEventListener('change', () => {
- input.value = selectedLabel();
- });
-
- const api = {
- refresh() {
- input.value = selectedLabel();
- },
- setCanCreate(v) {
- options.canCreate = !!v;
- },
- };
- select._ttPicker = api;
- return api;
-}
diff --git a/browser-extension/manifest.json b/browser-extension/manifest.json
index 468564d3f..25012bd0f 100644
--- a/browser-extension/manifest.json
+++ b/browser-extension/manifest.json
@@ -1,13 +1,14 @@
{
"manifest_version": 3,
"name": "TimeTracker Timer",
- "version": "5.18.2",
+ "version": "5.19.0",
"description": "Start and stop TimeTracker timers from your browser toolbar.",
"permissions": [
"storage",
"alarms",
"idle",
- "notifications"
+ "notifications",
+ "contextMenus"
],
"optional_host_permissions": [
"http://*/*",
@@ -18,7 +19,7 @@
"type": "module"
},
"action": {
- "default_popup": "popup.html",
+ "default_popup": "popup/popup.html",
"default_title": "TimeTracker",
"default_icon": {
"16": "icons/idle-16.png",
@@ -27,8 +28,24 @@
"128": "icons/idle-128.png"
}
},
+ "commands": {
+ "toggle-timer": {
+ "suggested_key": {
+ "default": "Ctrl+Shift+S",
+ "mac": "Command+Shift+S"
+ },
+ "description": "Start or stop the TimeTracker timer"
+ },
+ "_execute_action": {
+ "suggested_key": {
+ "default": "Ctrl+Shift+T",
+ "mac": "Command+Shift+T"
+ },
+ "description": "Open TimeTracker popup"
+ }
+ },
"options_ui": {
- "page": "options.html",
+ "page": "options/options.html",
"open_in_tab": true
},
"icons": {
diff --git a/browser-extension/options.html b/browser-extension/options.html
deleted file mode 100644
index 2160c2d11..000000000
--- a/browser-extension/options.html
+++ /dev/null
@@ -1,55 +0,0 @@
-
-
-
-
-
- TimeTracker Extension Settings
-
-
-
-
-
TimeTracker extension
-
Connect to your self-hosted TimeTracker instance. Same API tokens as the desktop and mobile apps.
-
-
-
-
-
-
-
-
-
Base URL only — no path. Example: http://localhost:8080
-
-
Sign in with password
-
-
-
-
-
-
-
-
-
-
-
-
-
Or paste an API token
-
- Use this for OIDC-only installs, or a token from Admin → API tokens. Token must start with
- tt_.
-
-
-
-
-
-
-
-
-
-
Not connected.
-
-
-
-
-
-
diff --git a/browser-extension/options.js b/browser-extension/options.js
deleted file mode 100644
index 5d9a69605..000000000
--- a/browser-extension/options.js
+++ /dev/null
@@ -1,186 +0,0 @@
-/**
- * Options page: server URL + password login or pasted API token.
- */
-
-import { ApiClient, normalizeServerUrl, originFromServerUrl } from './lib/api.js';
-
-const els = {
- serverUrl: document.getElementById('server-url'),
- username: document.getElementById('username'),
- password: document.getElementById('password'),
- apiToken: document.getElementById('api-token'),
- loginBtn: document.getElementById('login-btn'),
- tokenBtn: document.getElementById('token-btn'),
- disconnectBtn: document.getElementById('disconnect-btn'),
- message: document.getElementById('message'),
- statusLine: document.getElementById('status-line'),
-};
-
-function showMessage(text, kind = 'error') {
- els.message.textContent = text;
- els.message.className = kind === 'success' ? 'success' : 'error';
- els.message.classList.remove('hidden');
-}
-
-function clearMessage() {
- els.message.classList.add('hidden');
- els.message.textContent = '';
-}
-
-async function requestHostAccess(serverUrl) {
- const originPattern = originFromServerUrl(serverUrl);
- if (!originPattern) {
- return { ok: false, message: 'Server URL is not valid.' };
- }
- const already = await chrome.permissions.contains({ origins: [originPattern] });
- if (already) return { ok: true };
- const granted = await chrome.permissions.request({ origins: [originPattern] });
- if (!granted) {
- return {
- ok: false,
- message: 'Permission to reach this server was denied. Allow access when prompted.',
- };
- }
- return { ok: true };
-}
-
-async function persistSession(serverUrl, token) {
- await chrome.storage.local.set({
- server_url: normalizeServerUrl(serverUrl),
- api_token: token,
- logged_out: false,
- });
- chrome.runtime.sendMessage({ type: 'refresh_timer' }).catch(() => {});
-}
-
-async function updateStatus() {
- const { server_url, api_token, logged_out } = await chrome.storage.local.get([
- 'server_url',
- 'api_token',
- 'logged_out',
- ]);
- if (server_url) els.serverUrl.value = server_url;
- if (!server_url || !api_token || logged_out) {
- els.statusLine.textContent = 'Not connected.';
- return;
- }
- const client = new ApiClient(server_url, api_token);
- const result = await client.validateSession();
- if (result.ok) {
- const name = result.user?.username || result.user?.email || 'signed in';
- els.statusLine.textContent = `Connected to ${server_url} as ${name}.`;
- } else {
- els.statusLine.textContent = `Saved credentials for ${server_url}, but validation failed: ${result.message}`;
- }
-}
-
-async function connectWithPassword() {
- clearMessage();
- const serverUrl = normalizeServerUrl(els.serverUrl.value);
- const username = els.username.value.trim();
- const password = els.password.value;
- if (!serverUrl || !username || !password) {
- showMessage('Server URL, username, and password are required.');
- return;
- }
-
- els.loginBtn.disabled = true;
- try {
- const access = await requestHostAccess(serverUrl);
- if (!access.ok) {
- showMessage(access.message);
- return;
- }
-
- const info = await ApiClient.testPublicServerInfo(serverUrl);
- if (!info.ok) {
- showMessage(info.message);
- return;
- }
-
- const login = await ApiClient.loginWithPassword(serverUrl, username, password);
- if (!login.ok) {
- showMessage(login.message);
- return;
- }
-
- const client = new ApiClient(serverUrl, login.token);
- const session = await client.validateSession();
- if (!session.ok) {
- showMessage(session.message);
- return;
- }
-
- await persistSession(serverUrl, login.token);
- els.password.value = '';
- showMessage('Connected successfully.', 'success');
- await updateStatus();
- } catch (error) {
- showMessage(error?.message || 'Connect failed. Check the URL and try again.');
- } finally {
- els.loginBtn.disabled = false;
- }
-}
-
-async function connectWithToken() {
- clearMessage();
- const serverUrl = normalizeServerUrl(els.serverUrl.value);
- const token = els.apiToken.value.trim();
- if (!serverUrl || !token) {
- showMessage('Server URL and API token are required.');
- return;
- }
- if (!token.startsWith('tt_')) {
- showMessage('Token must start with tt_.');
- return;
- }
-
- els.tokenBtn.disabled = true;
- try {
- const access = await requestHostAccess(serverUrl);
- if (!access.ok) {
- showMessage(access.message);
- return;
- }
-
- const info = await ApiClient.testPublicServerInfo(serverUrl);
- if (!info.ok) {
- showMessage(info.message);
- return;
- }
-
- const client = new ApiClient(serverUrl, token);
- const session = await client.validateSession();
- if (!session.ok) {
- showMessage(session.message);
- return;
- }
-
- await persistSession(serverUrl, token);
- els.apiToken.value = '';
- showMessage('Token saved successfully.', 'success');
- await updateStatus();
- } catch (error) {
- showMessage(error?.message || 'Token save failed. Check the URL and try again.');
- } finally {
- els.tokenBtn.disabled = false;
- }
-}
-
-async function disconnect() {
- clearMessage();
- await chrome.storage.local.set({
- api_token: null,
- logged_out: true,
- last_timer_status: { active: false, timer: null },
- });
- chrome.runtime.sendMessage({ type: 'refresh_timer' }).catch(() => {});
- showMessage('Disconnected.', 'success');
- await updateStatus();
-}
-
-els.loginBtn.addEventListener('click', connectWithPassword);
-els.tokenBtn.addEventListener('click', connectWithToken);
-els.disconnectBtn.addEventListener('click', disconnect);
-
-updateStatus();
diff --git a/browser-extension/options/options.css b/browser-extension/options/options.css
new file mode 100644
index 000000000..0bd019c9a
--- /dev/null
+++ b/browser-extension/options/options.css
@@ -0,0 +1,209 @@
+/* Options page layout — uses shared tokens from styles/tokens.css */
+
+.page {
+ max-width: 640px;
+ margin: 40px auto 48px;
+ padding: 0 20px;
+}
+
+.opt-title {
+ margin: 0;
+ font-size: 26px;
+ font-weight: 700;
+ letter-spacing: -0.02em;
+}
+
+.opt-sub {
+ margin: 5px 0 24px;
+ font-size: 13.5px;
+ color: var(--tt-ink-2);
+}
+
+.opt-h {
+ margin: 20px 6px 7px;
+ font-size: 12px;
+ font-weight: 600;
+ text-transform: uppercase;
+ letter-spacing: 0.05em;
+ color: var(--tt-ink-2);
+}
+
+.opt-card {
+ background: var(--tt-surface);
+ border: 1px solid var(--tt-line);
+ border-radius: 14px;
+ overflow: hidden;
+}
+
+.form-card {
+ padding: 14px;
+ display: flex;
+ flex-direction: column;
+ gap: 10px;
+}
+
+.form-card .cta,
+.form-card .btn {
+ width: 100%;
+ flex: none;
+}
+
+.divider {
+ display: flex;
+ align-items: center;
+ gap: 12px;
+ margin: 8px 0 2px;
+ color: var(--tt-ink-2);
+ font-size: 11.5px;
+ font-weight: 600;
+ text-transform: uppercase;
+ letter-spacing: 0.04em;
+}
+
+.divider::before,
+.divider::after {
+ content: "";
+ flex: 1;
+ height: 1px;
+ background: var(--tt-line);
+}
+
+.opt-row {
+ display: flex;
+ align-items: center;
+ gap: 12px;
+ padding: 12px 16px;
+ min-height: 50px;
+}
+
+.opt-row + .opt-row {
+ border-top: 1px solid var(--tt-line);
+}
+
+.opt-row-main {
+ flex: 1;
+ min-width: 0;
+}
+
+.opt-row-t {
+ font-size: 13.5px;
+ font-weight: 500;
+}
+
+.opt-row-s {
+ font-size: 11.5px;
+ color: var(--tt-ink-2);
+ margin-top: 1px;
+}
+
+.opt-side {
+ display: flex;
+ flex-direction: column;
+ align-items: flex-end;
+ gap: 5px;
+ flex: none;
+}
+
+.opt-val {
+ font-size: 12.5px;
+ font-weight: 500;
+ color: var(--tt-ink-2);
+ white-space: nowrap;
+}
+
+.opt-ok {
+ display: inline-flex;
+ align-items: center;
+ gap: 5px;
+ color: var(--tt-green);
+ font-size: 12px;
+ font-weight: 600;
+ white-space: nowrap;
+}
+
+.opt-ok svg {
+ width: 14px;
+ height: 14px;
+}
+
+.opt-managed {
+ display: inline-flex;
+ align-items: center;
+ gap: 4px;
+ font-size: 10.5px;
+ font-weight: 600;
+ letter-spacing: 0.03em;
+ color: var(--tt-ink-2);
+ background: var(--tt-surface-2);
+ border-radius: 999px;
+ padding: 3px 8px;
+ white-space: nowrap;
+}
+
+.opt-managed svg {
+ width: 11px;
+ height: 11px;
+}
+
+.avatar {
+ width: 34px;
+ height: 34px;
+ border-radius: 50%;
+ flex: none;
+ background: linear-gradient(135deg, #5e5ce6, #bf5af2);
+ color: #fff;
+ display: grid;
+ place-items: center;
+ font-weight: 700;
+ font-size: 14px;
+}
+
+.opt-danger {
+ display: inline-flex;
+ align-items: center;
+ gap: 8px;
+ color: var(--tt-red);
+ font-size: 13.5px;
+ font-weight: 500;
+}
+
+.opt-danger svg {
+ width: 15px;
+ height: 15px;
+}
+
+.opt-foot {
+ margin: 18px 0 0;
+ text-align: center;
+ font-size: 11.5px;
+ color: var(--tt-ink-3);
+}
+
+kbd,
+.kbd {
+ font-family: ui-monospace, "SF Mono", Menlo, Consolas, monospace;
+ font-size: 11px;
+ background: var(--tt-surface-2);
+ color: var(--tt-ink-2);
+ border-radius: 6px;
+ padding: 3px 7px;
+ box-shadow: inset 0 -1px 0 var(--tt-line);
+ white-space: nowrap;
+}
+
+code {
+ font-family: ui-monospace, "SF Mono", Menlo, Consolas, monospace;
+ font-size: 11.5px;
+ background: var(--tt-surface-2);
+ border-radius: 5px;
+ padding: 1px 5px;
+}
+
+.hint.left {
+ text-align: left;
+ margin: -4px 2px 0;
+}
+
+#disconnect-btn {
+ cursor: pointer;
+}
diff --git a/browser-extension/options/options.html b/browser-extension/options/options.html
new file mode 100644
index 000000000..4881e2a3d
--- /dev/null
+++ b/browser-extension/options/options.html
@@ -0,0 +1,147 @@
+
+
+
+
+
+ TimeTracker Settings
+
+
+
+
+
+
Settings
+
TimeTracker extension · same account and tokens as desktop and mobile
+
+
+
+
+
+
+
+
+
Connection
+
+
+
+
+
+
Two-factor authentication
+
Code requested at sign-in when your account requires it
+
+
Server-managed
+
+
+
+
Timer
+
+
+
+
Show elapsed time in toolbar badge
+
Live mm:ss while a timer is running
+
+
+
+
+
+
Idle prompt
+
Server asks “Still working?” when input activity stops
+
+
+ —
+ Managed by server
+
+
+
+
+
Unanswered idle checks
+
What happens when the prompt goes unanswered
+
+
+ —
+ Managed by server
+
+
+
+
+
+
Keyboard
+
+
+
+
+
+
Track selected text
+
Right-click any selection → start with it as the note
+
+
right-click
+
+
+
Customize shortcuts at chrome://extensions/shortcuts
+
+
+
+
+
Account
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/browser-extension/options/options.js b/browser-extension/options/options.js
new file mode 100644
index 000000000..d486fce18
--- /dev/null
+++ b/browser-extension/options/options.js
@@ -0,0 +1,308 @@
+/**
+ * Options page: connect (password + 2FA or API token), read-only server
+ * policy display, local preferences, disconnect.
+ */
+
+import {
+ ApiClient,
+ normalizeServerUrl,
+ originFromServerUrl,
+} from '../lib/api.js';
+import { icons } from '../lib/icons.js';
+
+const el = (id) => document.getElementById(id);
+
+const els = {
+ message: el('message'),
+ connectCard: el('connect-card'),
+ connection: el('connection'),
+ accountSection: el('account-section'),
+ serverUrl: el('server-url'),
+ username: el('username'),
+ password: el('password'),
+ twofaRow: el('twofa-row'),
+ twofaCode: el('twofa-code'),
+ loginBtn: el('login-btn'),
+ apiToken: el('api-token'),
+ tokenBtn: el('token-btn'),
+ serverValue: el('server-value'),
+ serverStatusText: el('server-status-text'),
+ serverStatusIcon: el('server-status-icon'),
+ avatar: el('avatar'),
+ userName: el('user-name'),
+ userSub: el('user-sub'),
+ badgeToggle: el('badge-toggle'),
+ idleValue: el('idle-value'),
+ idleActionValue: el('idle-action-value'),
+ shortcutToggle: el('shortcut-toggle'),
+ shortcutPopup: el('shortcut-popup'),
+ disconnectBtn: el('disconnect-btn'),
+};
+
+el('managed-1').querySelector('.managed-icon').innerHTML = icons.lock;
+el('managed-2').querySelector('.managed-icon').innerHTML = icons.lock;
+els.serverStatusIcon.innerHTML = icons['check-circle'];
+els.disconnectBtn.querySelector('.danger-icon').innerHTML = icons['log-out'];
+
+let pending2fa = null; // { baseUrl, tempToken }
+
+function showMessage(text, kind = 'error') {
+ els.message.textContent = text;
+ els.message.className = `toast ${kind}`;
+ els.message.classList.remove('hidden');
+}
+
+function clearMessage() {
+ els.message.classList.add('hidden');
+ els.message.textContent = '';
+}
+
+function setBusy(button, busy) {
+ button.disabled = busy;
+}
+
+async function requestHostAccess(serverUrl) {
+ const originPattern = originFromServerUrl(serverUrl);
+ if (!originPattern) {
+ return { ok: false, message: 'Server URL is not valid.' };
+ }
+ const already = await chrome.permissions.contains({ origins: [originPattern] });
+ if (already) return { ok: true };
+ const granted = await chrome.permissions.request({ origins: [originPattern] });
+ if (!granted) {
+ return {
+ ok: false,
+ message: 'Permission to reach this server was denied. Allow access when prompted.',
+ };
+ }
+ return { ok: true };
+}
+
+async function persistSession(serverUrl, token) {
+ await chrome.storage.local.set({
+ server_url: normalizeServerUrl(serverUrl),
+ api_token: token,
+ logged_out: false,
+ });
+ chrome.runtime.sendMessage({ type: 'refresh_timer' }).catch(() => {});
+}
+
+async function connectWithPassword() {
+ clearMessage();
+ const serverUrl = normalizeServerUrl(els.serverUrl.value);
+ const username = els.username.value.trim();
+ const password = els.password.value;
+ if (!serverUrl || !username || !password) {
+ showMessage('Server URL, username, and password are required.');
+ return;
+ }
+
+ setBusy(els.loginBtn, true);
+ try {
+ const access = await requestHostAccess(serverUrl);
+ if (!access.ok) {
+ showMessage(access.message);
+ return;
+ }
+
+ const info = await ApiClient.testPublicServerInfo(serverUrl);
+ if (!info.ok) {
+ showMessage(info.message);
+ return;
+ }
+
+ if (!pending2fa) {
+ const login = await ApiClient.loginWithPassword(serverUrl, username, password);
+ if (!login.ok && login.requires_2fa && login.temp_token) {
+ pending2fa = { baseUrl: serverUrl, tempToken: login.temp_token };
+ els.twofaRow.classList.remove('hidden');
+ els.twofaCode.focus();
+ showMessage('Enter the 6-digit code from your authenticator app.', 'success');
+ return;
+ }
+ if (!login.ok) {
+ showMessage(login.message);
+ return;
+ }
+ await finishLogin(serverUrl, login.token, password);
+ return;
+ }
+
+ const code = els.twofaCode.value.trim();
+ if (!code) {
+ showMessage('Enter your two-factor code.');
+ return;
+ }
+ const verify = await ApiClient.verifyLogin2fa(serverUrl, pending2fa.tempToken, code);
+ if (!verify.ok) {
+ showMessage(verify.message);
+ return;
+ }
+ await finishLogin(serverUrl, verify.token, password);
+ } catch (error) {
+ showMessage(error?.message || 'Connect failed. Check the URL and try again.');
+ } finally {
+ setBusy(els.loginBtn, false);
+ }
+}
+
+async function finishLogin(serverUrl, token, password) {
+ const client = new ApiClient(serverUrl, token);
+ const session = await client.validateSession();
+ if (!session.ok) {
+ showMessage(session.message);
+ return;
+ }
+ pending2fa = null;
+ els.twofaRow.classList.add('hidden');
+ els.twofaCode.value = '';
+ els.password.value = '';
+ await persistSession(serverUrl, token);
+ showMessage('Connected successfully.', 'success');
+ await renderStatus();
+}
+
+async function connectWithToken() {
+ clearMessage();
+ const serverUrl = normalizeServerUrl(els.serverUrl.value);
+ const token = els.apiToken.value.trim();
+ if (!serverUrl || !token) {
+ showMessage('Server URL and API token are required.');
+ return;
+ }
+ if (!token.startsWith('tt_')) {
+ showMessage('Token must start with tt_.');
+ return;
+ }
+
+ setBusy(els.tokenBtn, true);
+ try {
+ const access = await requestHostAccess(serverUrl);
+ if (!access.ok) {
+ showMessage(access.message);
+ return;
+ }
+
+ const info = await ApiClient.testPublicServerInfo(serverUrl);
+ if (!info.ok) {
+ showMessage(info.message);
+ return;
+ }
+
+ const client = new ApiClient(serverUrl, token);
+ const session = await client.validateSession();
+ if (!session.ok) {
+ showMessage(session.message);
+ return;
+ }
+
+ await persistSession(serverUrl, token);
+ els.apiToken.value = '';
+ showMessage('Token saved successfully.', 'success');
+ await renderStatus();
+ } catch (error) {
+ showMessage(error?.message || 'Token save failed. Check the URL and try again.');
+ } finally {
+ setBusy(els.tokenBtn, false);
+ }
+}
+
+async function disconnect() {
+ clearMessage();
+ pending2fa = null;
+ await chrome.storage.local.set({
+ api_token: null,
+ logged_out: true,
+ last_timer_status: { active: false, timer: null },
+ });
+ chrome.runtime.sendMessage({ type: 'refresh_timer' }).catch(() => {});
+ showMessage('Disconnected.', 'success');
+ await renderStatus();
+}
+
+function showConnected(visible) {
+ els.connection.classList.toggle('hidden', !visible);
+ els.accountSection.classList.toggle('hidden', !visible);
+ els.connectCard.classList.toggle('hidden', visible);
+}
+
+async function renderStatus() {
+ const { server_url, api_token, logged_out } = await chrome.storage.local.get([
+ 'server_url',
+ 'api_token',
+ 'logged_out',
+ ]);
+ const configured = Boolean(server_url && api_token && !logged_out);
+ showConnected(configured);
+ if (server_url) els.serverUrl.value = server_url;
+ if (!configured) return;
+
+ els.serverValue.textContent = server_url;
+
+ const client = new ApiClient(server_url, api_token);
+ const session = await client.validateSession();
+ if (session.ok && session.user) {
+ const name = session.user.username || session.user.email || 'Signed in';
+ els.avatar.textContent = (session.user.username || session.user.email || '?')
+ .charAt(0)
+ .toUpperCase();
+ els.userName.textContent = name;
+ els.userSub.textContent = session.user.email
+ ? `${session.user.email} · signed in`
+ : 'Signed in';
+ els.serverStatusText.textContent = 'Connected';
+ } else {
+ els.serverStatusText.textContent = 'Token invalid — sign in again';
+ els.serverStatusIcon.style.color = 'var(--tt-red)';
+ }
+
+ // Server-managed idle policy — read-only display (Issue #722 semantics)
+ try {
+ const status = await client.getTimerStatus();
+ const minutes = Number(status?.idle_timeout_minutes) || 30;
+ els.idleValue.textContent = `After ${minutes} min`;
+ const action = String(status?.idle_unanswered_action || 'review');
+ els.idleActionValue.textContent =
+ action === 'auto_stop'
+ ? 'Auto-stop credited to last activity'
+ : 'Keep running · flag for review';
+ } catch {
+ els.idleValue.textContent = '—';
+ els.idleActionValue.textContent = '—';
+ }
+}
+
+async function renderShortcuts() {
+ try {
+ const commands = await chrome.commands.getAll();
+ const toggle = commands.find((c) => c.name === 'toggle-timer');
+ const popup = commands.find((c) => c.name === '_execute_action');
+ if (toggle?.shortcut) els.shortcutToggle.textContent = toggle.shortcut;
+ if (popup?.shortcut) els.shortcutPopup.textContent = popup.shortcut;
+ } catch {
+ /* keep suggested defaults */
+ }
+}
+
+async function renderBadgePref() {
+ const { badge_show_elapsed = true } = await chrome.storage.local.get('badge_show_elapsed');
+ els.badgeToggle.checked = Boolean(badge_show_elapsed);
+}
+
+els.badgeToggle.addEventListener('change', async () => {
+ await chrome.storage.local.set({ badge_show_elapsed: els.badgeToggle.checked });
+});
+
+els.loginBtn.addEventListener('click', connectWithPassword);
+els.tokenBtn.addEventListener('click', connectWithToken);
+els.disconnectBtn.addEventListener('click', disconnect);
+els.password.addEventListener('keydown', (event) => {
+ if (event.key === 'Enter') connectWithPassword();
+});
+els.twofaCode.addEventListener('keydown', (event) => {
+ if (event.key === 'Enter') connectWithPassword();
+});
+
+renderBadgePref();
+renderShortcuts();
+renderStatus();
diff --git a/browser-extension/package.json b/browser-extension/package.json
index 6717f1d0f..fbe313f5d 100644
--- a/browser-extension/package.json
+++ b/browser-extension/package.json
@@ -1,7 +1,7 @@
{
"name": "timetracker-browser-extension",
"private": true,
- "version": "5.18.2",
+ "version": "5.19.0",
"description": "TimeTracker Chromium extension (Manifest V3)",
"type": "module",
"scripts": {
diff --git a/browser-extension/popup.css b/browser-extension/popup.css
deleted file mode 100644
index ef700477c..000000000
--- a/browser-extension/popup.css
+++ /dev/null
@@ -1,440 +0,0 @@
-:root {
- --bg: #f8fafc;
- --surface: #ffffff;
- --text: #0f172a;
- --muted: #64748b;
- --border: #e2e8f0;
- --accent: #0f766e;
- --accent-hover: #0d9488;
- --danger: #dc2626;
- --danger-hover: #b91c1c;
- --radius: 8px;
- --font: "Segoe UI", system-ui, -apple-system, sans-serif;
- --field-bg: #fff;
- --btn-secondary-bg: #fff;
- --btn-secondary-hover: #f1f5f9;
- --error-bg: #fef2f2;
- --error-border: #fecaca;
- --success-bg: #f0fdf4;
- --success-border: #bbf7d0;
- --success-text: #166534;
- --paused-badge-bg: #fef3c7;
- --paused-badge-border: #fcd34d;
- --paused-badge-text: #92400e;
- --picker-hover: #f0fdfa;
- --picker-selected: #ccfbf1;
- --shadow: rgba(15, 23, 42, 0.12);
-}
-
-@media (prefers-color-scheme: dark) {
- :root {
- --bg: #0f172a;
- --surface: #1e293b;
- --text: #f1f5f9;
- --muted: #94a3b8;
- --border: #334155;
- --accent: #2dd4bf;
- --accent-hover: #5eead4;
- --field-bg: #0f172a;
- --btn-secondary-bg: #1e293b;
- --btn-secondary-hover: #334155;
- --error-bg: #450a0a;
- --error-border: #7f1d1d;
- --success-bg: #052e16;
- --success-border: #166534;
- --success-text: #86efac;
- --paused-badge-bg: #422006;
- --paused-badge-border: #854d0e;
- --paused-badge-text: #fcd34d;
- --picker-hover: #134e4a;
- --picker-selected: #115e59;
- --shadow: rgba(0, 0, 0, 0.45);
- }
-}
-
-* {
- box-sizing: border-box;
-}
-
-body {
- margin: 0;
- font-family: var(--font);
- font-size: 13px;
- color: var(--text);
- background: var(--bg);
-}
-
-.wrap {
- width: 340px;
- min-height: 120px;
- padding: 12px;
-}
-
-.header {
- display: flex;
- align-items: center;
- justify-content: space-between;
- gap: 8px;
- margin-bottom: 12px;
-}
-
-.header h1 {
- margin: 0;
- font-size: 14px;
- font-weight: 650;
- letter-spacing: -0.01em;
-}
-
-.header a,
-.linkish {
- color: var(--accent);
- text-decoration: none;
- font-size: 12px;
- background: none;
- border: none;
- padding: 0;
- cursor: pointer;
- font-family: inherit;
-}
-
-.header a:hover,
-.linkish:hover {
- text-decoration: underline;
-}
-
-.card {
- background: var(--surface);
- border: 1px solid var(--border);
- border-radius: var(--radius);
- padding: 12px;
-}
-
-.muted {
- color: var(--muted);
-}
-
-.error {
- color: var(--danger);
- background: var(--error-bg);
- border: 1px solid var(--error-border);
- border-radius: var(--radius);
- padding: 8px 10px;
- margin-bottom: 10px;
- font-size: 12px;
-}
-
-.success {
- color: var(--success-text);
- background: var(--success-bg);
- border: 1px solid var(--success-border);
- border-radius: var(--radius);
- padding: 8px 10px;
- margin-bottom: 10px;
- font-size: 12px;
-}
-
-.timer-display {
- text-align: center;
- padding: 8px 0 12px;
-}
-
-.timer-display .elapsed {
- font-size: 28px;
- font-weight: 700;
- font-variant-numeric: tabular-nums;
- letter-spacing: -0.02em;
- color: var(--danger);
-}
-
-.timer-display .elapsed.is-paused {
- color: var(--muted);
-}
-
-.paused-badge {
- display: inline-block;
- margin-top: 4px;
- padding: 2px 8px;
- border-radius: 999px;
- font-size: 11px;
- font-weight: 600;
- text-transform: uppercase;
- letter-spacing: 0.04em;
- color: var(--paused-badge-text);
- background: var(--paused-badge-bg);
- border: 1px solid var(--paused-badge-border);
-}
-
-.running-actions {
- margin-top: 4px;
-}
-
-.running-actions .btn-danger {
- width: auto;
- flex: 1;
-}
-
-.timer-display .meta {
- margin-top: 4px;
- font-size: 12px;
- color: var(--muted);
-}
-
-.timer-display .meta strong {
- color: var(--text);
- font-weight: 600;
-}
-
-.field {
- display: flex;
- flex-direction: column;
- gap: 4px;
- margin-bottom: 10px;
-}
-
-.field label {
- font-size: 11px;
- font-weight: 600;
- color: var(--muted);
- text-transform: uppercase;
- letter-spacing: 0.04em;
-}
-
-.field input,
-.field select,
-.field textarea {
- width: 100%;
- border: 1px solid var(--border);
- border-radius: 6px;
- padding: 7px 9px;
- font: inherit;
- color: var(--text);
- background: var(--field-bg);
-}
-
-.field input:focus,
-.field select:focus,
-.field textarea:focus {
- outline: 2px solid color-mix(in srgb, var(--accent) 35%, transparent);
- border-color: var(--accent);
-}
-
-.row {
- display: flex;
- gap: 8px;
-}
-
-.row > * {
- flex: 1;
-}
-
-.btn {
- display: inline-flex;
- align-items: center;
- justify-content: center;
- gap: 6px;
- border: none;
- border-radius: 6px;
- padding: 8px 12px;
- font: inherit;
- font-weight: 600;
- cursor: pointer;
-}
-
-.btn:disabled {
- opacity: 0.55;
- cursor: not-allowed;
-}
-
-.btn-primary {
- background: var(--accent);
- color: #fff;
-}
-
-.btn-primary:hover:not(:disabled) {
- background: var(--accent-hover);
-}
-
-.btn-danger {
- background: var(--danger);
- color: #fff;
- width: 100%;
-}
-
-.btn-danger:hover:not(:disabled) {
- background: var(--danger-hover);
-}
-
-.btn-secondary {
- background: var(--btn-secondary-bg);
- color: var(--text);
- border: 1px solid var(--border);
-}
-
-.btn-secondary:hover:not(:disabled) {
- background: var(--btn-secondary-hover);
-}
-
-.btn-block {
- width: 100%;
-}
-
-.section-title {
- margin: 14px 0 8px;
- font-size: 11px;
- font-weight: 700;
- text-transform: uppercase;
- letter-spacing: 0.05em;
- color: var(--muted);
-}
-
-.tabs {
- display: flex;
- gap: 4px;
- margin-bottom: 10px;
-}
-
-.tabs button {
- flex: 1;
- border: 1px solid var(--border);
- background: var(--btn-secondary-bg);
- border-radius: 6px;
- padding: 6px;
- font: inherit;
- font-size: 12px;
- cursor: pointer;
- color: var(--muted);
-}
-
-.tabs button.active {
- border-color: var(--accent);
- color: var(--accent);
- font-weight: 600;
- background: color-mix(in srgb, var(--accent) 12%, var(--surface));
-}
-
-.hidden {
- display: none !important;
-}
-
-.stack {
- display: flex;
- flex-direction: column;
- gap: 8px;
-}
-
-.options-wrap {
- max-width: 480px;
- margin: 24px auto;
- padding: 0 16px 32px;
-}
-
-.options-wrap h1 {
- font-size: 20px;
- margin: 0 0 4px;
-}
-
-.options-wrap .lead {
- color: var(--muted);
- margin: 0 0 16px;
-}
-
-.hint {
- font-size: 12px;
- color: var(--muted);
- margin-top: -4px;
- margin-bottom: 10px;
-}
-
-hr.sep {
- border: none;
- border-top: 1px solid var(--border);
- margin: 16px 0;
-}
-
-/* Searchable picker (Issue #728) */
-.sr-only {
- position: absolute !important;
- width: 1px !important;
- height: 1px !important;
- padding: 0 !important;
- margin: -1px !important;
- overflow: hidden !important;
- clip: rect(0, 0, 0, 0) !important;
- white-space: nowrap !important;
- border: 0 !important;
-}
-
-.tt-picker {
- position: relative;
-}
-
-.tt-picker-input {
- width: 100%;
- border: 1px solid var(--border);
- border-radius: 6px;
- padding: 7px 9px;
- font: inherit;
- background: var(--field-bg);
- color: var(--text);
-}
-
-.tt-picker-input:focus {
- outline: 2px solid color-mix(in srgb, var(--accent) 35%, transparent);
- border-color: var(--accent);
-}
-
-.tt-picker-list {
- position: absolute;
- z-index: 40;
- left: 0;
- right: 0;
- margin: 4px 0 0;
- padding: 4px 0;
- max-height: 180px;
- overflow: auto;
- list-style: none;
- background: var(--surface);
- border: 1px solid var(--border);
- border-radius: 6px;
- box-shadow: 0 8px 20px var(--shadow);
-}
-
-.tt-picker-list.hidden {
- display: none;
-}
-
-.tt-picker-option,
-.tt-picker-create,
-.tt-picker-empty,
-.tt-picker-hint {
- padding: 7px 10px;
- cursor: pointer;
-}
-
-.tt-picker-option:hover,
-.tt-picker-create:hover {
- background: var(--picker-hover);
-}
-
-.tt-picker-option.is-selected {
- background: var(--picker-selected);
- font-weight: 600;
-}
-
-.tt-picker-create {
- border-top: 1px solid var(--border);
- color: var(--accent);
- font-weight: 600;
-}
-
-.tt-picker-empty,
-.tt-picker-hint {
- color: var(--muted);
- cursor: default;
-}
-
-.hint {
- margin: 4px 0 0;
- font-size: 11px;
- color: var(--muted);
-}
diff --git a/browser-extension/popup.html b/browser-extension/popup.html
deleted file mode 100644
index 8aef40bd0..000000000
--- a/browser-extension/popup.html
+++ /dev/null
@@ -1,71 +0,0 @@
-
-
-
-
-
- TimeTracker
-
-
-
-
-
-
-
-
-
-
- Connect to your TimeTracker server to start and stop timers from the toolbar.
-
-
-
-
-
-
-
00h:00m
-
Paused
-
- —
- ·
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
Filters projects. Type to create a new client.
-
-
-
-
-
Required to start a timer. Type to create.
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
diff --git a/browser-extension/popup.js b/browser-extension/popup.js
deleted file mode 100644
index 6a2ce9d6f..000000000
--- a/browser-extension/popup.js
+++ /dev/null
@@ -1,552 +0,0 @@
-/**
- * Popup: running timer view + cascading client → project → task pickers
- * with inline create (Issue #728 follow-up).
- */
-
-import {
- ApiClient,
- elapsedSecondsFromTimer,
- formatElapsedHhMm,
-} from './lib/api.js';
-import { enhanceSelect } from './lib/picker.js';
-
-const els = {
- message: document.getElementById('message'),
- needSetup: document.getElementById('need-setup'),
- goSettings: document.getElementById('go-settings'),
- openOptions: document.getElementById('open-options'),
- runningView: document.getElementById('running-view'),
- idleView: document.getElementById('idle-view'),
- elapsed: document.getElementById('elapsed'),
- pausedBadge: document.getElementById('paused-badge'),
- runningProject: document.getElementById('running-project'),
- runningTaskWrap: document.getElementById('running-task-wrap'),
- runningTask: document.getElementById('running-task'),
- pauseBtn: document.getElementById('pause-btn'),
- resumeBtn: document.getElementById('resume-btn'),
- stopBtn: document.getElementById('stop-btn'),
- clientSelect: document.getElementById('client-select'),
- projectSelect: document.getElementById('project-select'),
- taskSelect: document.getElementById('task-select'),
- notes: document.getElementById('notes'),
- startBtn: document.getElementById('start-btn'),
-};
-
-/** Closed statuses — matches Task.is_active (anything else is selectable). */
-const CLOSED_TASK_STATUSES = new Set(['done', 'cancelled']);
-
-/** @type {ApiClient|null} */
-let client = null;
-/** @type {Array<{id:number,name:string,client_id?:number|null,favorite?:boolean,last_used_at?:string|null}>} */
-let projects = [];
-/** @type {Array<{id:number,name:string}>} */
-let clients = [];
-/** @type {object|null} */
-let activeTimer = null;
-let tickHandle = null;
-/** Keep service worker alive while the popup is open (MV3). */
-let keepAlivePort = null;
-/** Monotonic counter so concurrent loadTasksForProject calls don't duplicate options (#700). */
-let loadTasksGeneration = 0;
-
-let clientPicker = null;
-let projectPicker = null;
-let taskPicker = null;
-
-function showMessage(text, kind = 'error') {
- els.message.textContent = text;
- els.message.className = kind === 'success' ? 'success' : 'error';
- els.message.classList.remove('hidden');
-}
-
-function clearMessage() {
- els.message.classList.add('hidden');
- els.message.textContent = '';
-}
-
-function openSettings() {
- chrome.runtime.openOptionsPage();
-}
-
-els.goSettings.addEventListener('click', openSettings);
-els.openOptions.addEventListener('click', (event) => {
- event.preventDefault();
- openSettings();
-});
-
-function stopTick() {
- if (tickHandle) {
- clearInterval(tickHandle);
- tickHandle = null;
- }
-}
-
-function renderElapsed() {
- if (!activeTimer) return;
- els.elapsed.textContent = formatElapsedHhMm(elapsedSecondsFromTimer(activeTimer));
-}
-
-function updatePauseResumeUi(timer) {
- const paused = Boolean(timer?.paused_at);
- els.pausedBadge.classList.toggle('hidden', !paused);
- els.pauseBtn.classList.toggle('hidden', paused);
- els.resumeBtn.classList.toggle('hidden', !paused);
- els.elapsed.classList.toggle('is-paused', paused);
-}
-
-function showRunning(timer) {
- activeTimer = timer;
- els.needSetup.classList.add('hidden');
- els.idleView.classList.add('hidden');
- els.runningView.classList.remove('hidden');
- els.runningProject.textContent =
- timer.project ||
- timer.client ||
- (timer.project_id ? `Project #${timer.project_id}` : null) ||
- (timer.client_id ? `Client #${timer.client_id}` : 'Timer');
- if (timer.task) {
- els.runningTask.textContent = timer.task;
- els.runningTaskWrap.classList.remove('hidden');
- } else {
- els.runningTaskWrap.classList.add('hidden');
- }
- updatePauseResumeUi(timer);
- renderElapsed();
- stopTick();
- if (!timer.paused_at) {
- tickHandle = setInterval(renderElapsed, 1000);
- }
-}
-
-function showIdle() {
- activeTimer = null;
- stopTick();
- els.needSetup.classList.add('hidden');
- els.runningView.classList.add('hidden');
- els.idleView.classList.remove('hidden');
-}
-
-function showSetup() {
- activeTimer = null;
- stopTick();
- els.runningView.classList.add('hidden');
- els.idleView.classList.add('hidden');
- els.needSetup.classList.remove('hidden');
-}
-
-function fillClientSelect(selectedId = null) {
- const current =
- selectedId != null
- ? String(selectedId)
- : els.clientSelect.value || '';
- els.clientSelect.innerHTML = '';
- const none = document.createElement('option');
- none.value = '';
- none.textContent = '— All clients —';
- els.clientSelect.appendChild(none);
- for (const c of clients) {
- const opt = document.createElement('option');
- opt.value = String(c.id);
- opt.textContent = c.name;
- if (String(c.id) === current) opt.selected = true;
- els.clientSelect.appendChild(opt);
- }
- if (clientPicker) clientPicker.refresh();
-}
-
-function fillProjectSelect(selectedId = null) {
- const clientId = els.clientSelect.value ? Number(els.clientSelect.value) : null;
- let list = projects;
- if (clientId) {
- list = projects.filter((p) => !p.client_id || Number(p.client_id) === clientId);
- }
- const current =
- selectedId != null
- ? String(selectedId)
- : els.projectSelect.value || '';
-
- els.projectSelect.innerHTML = '';
- if (!list.length) {
- const opt = document.createElement('option');
- opt.value = '';
- opt.textContent = clientId ? 'No projects for this client' : 'No projects found';
- els.projectSelect.appendChild(opt);
- } else {
- const empty = document.createElement('option');
- empty.value = '';
- empty.textContent = '— Select a project —';
- els.projectSelect.appendChild(empty);
- for (const p of list) {
- const opt = document.createElement('option');
- opt.value = String(p.id);
- opt.textContent = p.favorite ? `★ ${p.name}` : p.name;
- if (p.client_id) {
- opt.setAttribute('data-client-id', String(p.client_id));
- opt.setAttribute('data-parent-id', String(p.client_id));
- }
- if (String(p.id) === current) opt.selected = true;
- els.projectSelect.appendChild(opt);
- }
- }
- if (projectPicker) projectPicker.refresh();
-}
-
-async function loadTasksForProject(projectId, selectedTaskId = null) {
- const gen = ++loadTasksGeneration;
- if (!client || !projectId) {
- els.taskSelect.innerHTML = '';
- if (taskPicker) taskPicker.refresh();
- return;
- }
- try {
- const data = await client.getTasks({
- project_id: projectId,
- status: 'open',
- per_page: 200,
- });
- if (gen !== loadTasksGeneration) return;
- const byId = new Map();
- for (const t of data?.tasks || []) {
- if (!t || t.id == null) continue;
- if (!t.status || CLOSED_TASK_STATUSES.has(t.status)) continue;
- if (!byId.has(t.id)) byId.set(t.id, t);
- }
- const tasks = Array.from(byId.values()).sort((a, b) => a.name.localeCompare(b.name));
- const selectedId = selectedTaskId != null ? String(selectedTaskId) : '';
- els.taskSelect.innerHTML = '';
- for (const t of tasks) {
- const opt = document.createElement('option');
- opt.value = String(t.id);
- opt.textContent = t.status && t.status !== 'todo' ? `${t.name} (${t.status})` : t.name;
- opt.setAttribute('data-parent-id', String(projectId));
- if (selectedId && String(t.id) === selectedId) opt.selected = true;
- els.taskSelect.appendChild(opt);
- }
- if (taskPicker) taskPicker.refresh();
- } catch (error) {
- if (gen !== loadTasksGeneration) return;
- console.warn('Failed to load tasks', error);
- els.taskSelect.innerHTML = '';
- const opt = document.createElement('option');
- opt.value = '';
- opt.textContent = 'Could not load tasks';
- els.taskSelect.appendChild(opt);
- if (taskPicker) taskPicker.refresh();
- showMessage(error.message || 'Could not load tasks.');
- }
-}
-
-async function loadProjects(preferredProjectId = null) {
- if (!client) return;
- const clientId = els.clientSelect.value ? Number(els.clientSelect.value) : null;
- const params = { status: 'active', per_page: 100 };
- // Always load all projects so switching client filter is instant; filter client-side.
- // (API also supports client_id — used as a hint when creating.)
- const [projectsResp, favResp] = await Promise.all([
- client.getAllProjects(params),
- client.getFavoriteProjects().catch(() => ({ favorites: [] })),
- ]);
-
- const favIds = new Set((favResp?.favorites || []).map((f) => f.project_id));
- const raw = projectsResp?.projects || [];
- projects = raw
- .map((p) => ({
- id: p.id,
- name: p.name,
- client_id: p.client_id ?? null,
- favorite: favIds.has(p.id),
- last_used_at: p.last_used_at ?? null,
- }))
- .sort((a, b) => {
- if (a.favorite !== b.favorite) return a.favorite ? -1 : 1;
- const aUsed = a.last_used_at ? Date.parse(a.last_used_at) : 0;
- const bUsed = b.last_used_at ? Date.parse(b.last_used_at) : 0;
- if (aUsed !== bUsed) return bUsed - aUsed;
- return a.name.localeCompare(b.name);
- });
-
- fillProjectSelect(preferredProjectId);
- const selected = Number(els.projectSelect.value);
- if (selected) await loadTasksForProject(selected);
- else await loadTasksForProject(null);
- void clientId;
-}
-
-async function loadClients(preferredClientId = null) {
- if (!client) return;
- try {
- const data = await client.getClients({ per_page: 100 });
- clients = (data?.clients || []).map((c) => ({ id: c.id, name: c.name }));
- } catch (error) {
- clients = [];
- console.warn(error);
- }
- fillClientSelect(preferredClientId);
-}
-
-async function notifyBackground() {
- try {
- await chrome.runtime.sendMessage({ type: 'refresh_timer' });
- } catch {
- /* ignore */
- }
-}
-
-function connectKeepAlive() {
- try {
- keepAlivePort = chrome.runtime.connect({ name: 'popup-keepalive' });
- } catch {
- keepAlivePort = null;
- }
-}
-
-/** When start conflicts with an existing timer, sync to that running timer. */
-async function syncToActiveTimerOnConflict() {
- try {
- const status = await client.getTimerStatus();
- if (status?.active && status?.timer) {
- showRunning(status.timer);
- await notifyBackground();
- showMessage('A timer is already running.', 'success');
- return true;
- }
- } catch {
- /* fall through */
- }
- return false;
-}
-
-async function createClientInline(name) {
- clearMessage();
- try {
- const result = await client.createClient({ name });
- const created = result?.client || result;
- showMessage('Client created.', 'success');
- await loadClients(created?.id);
- fillProjectSelect(els.projectSelect.value || null);
- } catch (error) {
- showMessage(error.message || 'Could not create client.');
- }
-}
-
-async function createProjectInline(name) {
- clearMessage();
- const clientId = els.clientSelect.value ? Number(els.clientSelect.value) : null;
- if (!clientId) {
- showMessage('Select a client before creating a project.');
- return;
- }
- try {
- const result = await client.createProject({ name, clientId });
- const created = result?.project || result;
- showMessage('Project created.', 'success');
- if (created?.client_id && !els.clientSelect.value) {
- await loadClients(created.client_id);
- }
- await loadProjects(created?.id);
- if (created?.id) await loadTasksForProject(created.id);
- } catch (error) {
- showMessage(error.message || 'Could not create project.');
- }
-}
-
-async function createTaskInline(name) {
- clearMessage();
- const projectId = Number(els.projectSelect.value);
- if (!projectId) {
- showMessage('Select a project before creating a task.');
- return;
- }
- try {
- const result = await client.createTask({ name, projectId });
- const taskId = result?.task?.id || result?.id;
- showMessage('Task created.', 'success');
- await loadTasksForProject(projectId, taskId);
- } catch (error) {
- showMessage(error.message || 'Could not create task.');
- }
-}
-
-function initPickers() {
- clientPicker = enhanceSelect(els.clientSelect, {
- kind: 'client',
- canCreate: true,
- placeholder: 'Type to search clients…',
- onCreate: createClientInline,
- });
- projectPicker = enhanceSelect(els.projectSelect, {
- kind: 'project',
- canCreate: true,
- placeholder: 'Type to search projects…',
- onCreate: createProjectInline,
- canCreateGuard: () => !!els.clientSelect.value,
- canCreateGuardHint: 'Select a client first',
- });
- taskPicker = enhanceSelect(els.taskSelect, {
- kind: 'task',
- canCreate: true,
- placeholder: 'Type to search tasks…',
- onCreate: createTaskInline,
- });
-}
-
-els.clientSelect.addEventListener('change', () => {
- fillProjectSelect(null);
- const id = Number(els.projectSelect.value);
- loadTasksForProject(id || null);
-});
-
-els.projectSelect.addEventListener('change', () => {
- const id = Number(els.projectSelect.value);
- if (id) {
- const opt = els.projectSelect.options[els.projectSelect.selectedIndex];
- const cid = opt?.getAttribute('data-client-id') || '';
- if (cid && els.clientSelect.value !== cid) {
- els.clientSelect.value = cid;
- if (clientPicker) clientPicker.refresh();
- // Re-filter project list but keep selection
- fillProjectSelect(id);
- }
- }
- loadTasksForProject(id || null);
-});
-
-els.startBtn.addEventListener('click', async () => {
- clearMessage();
- const projectId = els.projectSelect.value ? Number(els.projectSelect.value) : null;
- const clientId = els.clientSelect.value ? Number(els.clientSelect.value) : null;
- if (!projectId && !clientId) {
- showMessage('Select a client or project first.');
- return;
- }
- const taskId = projectId && els.taskSelect.value ? Number(els.taskSelect.value) : null;
- const notes = els.notes.value.trim();
- els.startBtn.disabled = true;
- try {
- const result = await client.startTimer({ projectId, clientId, taskId, notes });
- const timer = result?.timer;
- if (timer) showRunning(timer);
- else await bootstrap();
- await notifyBackground();
- } catch (error) {
- const isConflict =
- error.status === 409 ||
- error.code === 'CONFLICT' ||
- error.code === 'timer_already_running';
- if (isConflict) {
- if (error.data?.timer) {
- showRunning(error.data.timer);
- await notifyBackground();
- showMessage('A timer is already running.', 'success');
- } else if (!(await syncToActiveTimerOnConflict())) {
- showMessage(error.message || 'A timer is already running.');
- }
- } else {
- showMessage(error.message || 'Could not start timer.');
- }
- } finally {
- els.startBtn.disabled = false;
- }
-});
-
-els.pauseBtn.addEventListener('click', async () => {
- clearMessage();
- els.pauseBtn.disabled = true;
- try {
- const result = await client.pauseTimer();
- const timer = result?.time_entry || result?.timer;
- if (timer) showRunning(timer);
- else await bootstrap();
- await notifyBackground();
- } catch (error) {
- showMessage(error.message || 'Could not pause timer.');
- } finally {
- els.pauseBtn.disabled = false;
- }
-});
-
-els.resumeBtn.addEventListener('click', async () => {
- clearMessage();
- els.resumeBtn.disabled = true;
- try {
- const result = await client.resumeTimer();
- const timer = result?.time_entry || result?.timer;
- if (timer) showRunning(timer);
- else await bootstrap();
- await notifyBackground();
- } catch (error) {
- showMessage(error.message || 'Could not resume timer.');
- } finally {
- els.resumeBtn.disabled = false;
- }
-});
-
-els.stopBtn.addEventListener('click', async () => {
- clearMessage();
- els.stopBtn.disabled = true;
- try {
- await client.stopTimer();
- showIdle();
- await Promise.all([loadClients(), loadProjects()]);
- await notifyBackground();
- } catch (error) {
- showMessage(error.message || 'Could not stop timer.');
- } finally {
- els.stopBtn.disabled = false;
- }
-});
-
-async function bootstrap() {
- clearMessage();
- connectKeepAlive();
- initPickers();
-
- const { server_url, api_token, logged_out, last_timer_status } = await chrome.storage.local.get([
- 'server_url',
- 'api_token',
- 'logged_out',
- 'last_timer_status',
- ]);
-
- if (!server_url || !api_token || logged_out) {
- client = null;
- showSetup();
- return;
- }
-
- client = new ApiClient(server_url, api_token);
-
- try {
- const status = await client.getTimerStatus();
- if (status?.active && status?.timer) {
- showRunning(status.timer);
- } else {
- showIdle();
- await loadClients();
- await loadProjects();
- }
- await notifyBackground();
- } catch (error) {
- if (error.status === 401 || error.code === 'UNAUTHORIZED') {
- await chrome.storage.local.set({ logged_out: true });
- showSetup();
- showMessage('Session expired. Sign in again in Settings.');
- return;
- }
- if (last_timer_status?.active && last_timer_status?.timer) {
- showRunning(last_timer_status.timer);
- showMessage(error.message || 'Could not refresh timer; showing last known state.');
- } else {
- showIdle();
- showMessage(error.message || 'Could not reach TimeTracker.');
- try {
- await loadClients();
- await loadProjects();
- } catch {
- /* ignore */
- }
- }
- }
-}
-
-bootstrap();
diff --git a/browser-extension/popup/insights-tab.js b/browser-extension/popup/insights-tab.js
new file mode 100644
index 000000000..12cb0d514
--- /dev/null
+++ b/browser-extension/popup/insights-tab.js
@@ -0,0 +1,139 @@
+/**
+ * Insights tab: today stats, weekly goal, 7-day chart, top projects.
+ * Data comes from /reports/summary, /time-entries and /weekly-goals —
+ * no server changes required.
+ */
+
+import { dailyTotals, formatHM, colorForProject } from '../lib/format.js';
+import { decodeEntities } from './text.js';
+
+const el = (id) => document.getElementById(id);
+
+function round1(n) {
+ return Math.round(n * 10) / 10;
+}
+
+function formatHours(n) {
+ return `${round1(n).toFixed(1).replace(/\.0$/, '')} h`;
+}
+
+export function initInsightsTab(ctx) {
+ const els = {
+ statToday: el('stat-today'),
+ statBillable: el('stat-billable'),
+ statEntries: el('stat-entries'),
+ goalCard: el('goal-card'),
+ goalLabel: el('goal-label'),
+ goalFill: el('goal-fill'),
+ goalSub: el('goal-sub'),
+ weekAvg: el('week-avg'),
+ weekChart: el('week-chart'),
+ topProjects: el('top-projects'),
+ };
+
+ function renderStats() {
+ const activeSeconds = ctx.activeElapsedSeconds();
+ const activeBillable = ctx.state.timer?.billable ? activeSeconds : 0;
+ els.statToday.textContent = formatHM((ctx.state.todaySeconds || 0) + activeSeconds);
+ els.statBillable.textContent = formatHM((ctx.state.billableSecondsToday || 0) + activeBillable);
+ els.statEntries.textContent = String(ctx.state.todayEntries ?? 0);
+ }
+
+ function renderGoal(goal) {
+ if (!goal || !Number(goal.target_hours)) {
+ els.goalCard.hidden = true;
+ return;
+ }
+ const target = Number(goal.target_hours);
+ const actual = Number(goal.actual_hours) || 0;
+ const progress = Math.min(100, Math.round((Number(goal.progress_percentage) || 0)));
+ els.goalCard.hidden = false;
+ els.goalLabel.textContent = `${round1(actual)} / ${round1(target)} h`;
+ els.goalFill.style.width = `${progress}%`;
+ const remaining = Math.max(0, target - actual);
+ els.goalSub.textContent =
+ progress >= 100 ? 'Weekly goal reached' : `${round1(remaining)} h left this week`;
+ }
+
+ function renderChart(entries, now) {
+ const daily = dailyTotals(entries, 7, now);
+ const W = 316;
+ const BASE = 78;
+ const MAX_H = 56;
+ const LABEL_Y = 95;
+ const barW = 30;
+ const gap = (W - 16 - barW * 7) / 6;
+ const max = Math.max(4, ...daily.map((d) => d.hours));
+
+ const rects = [];
+ const labels = [];
+ daily.forEach((d, i) => {
+ const x = 8 + i * (barW + gap);
+ const h = d.hours > 0 ? Math.max(6, (d.hours / max) * MAX_H) : 3;
+ const y = BASE - h;
+ const isToday = i === daily.length - 1;
+ const rx = h >= 12 ? 8 : h / 2;
+ rects.push(
+ ``
+ );
+ if (isToday && d.hours > 0) {
+ rects.push(
+ `${round1(d.hours)}`
+ );
+ }
+ labels.push(
+ `${d.letter}`
+ );
+ });
+
+ const total = daily.reduce((sum, d) => sum + d.hours, 0);
+ els.weekAvg.textContent = `avg ${round1(total / 7)} h`;
+ els.weekChart.innerHTML =
+ `';
+ }
+
+ function renderTopProjects(byProject) {
+ const top = [...(byProject || [])]
+ .sort((a, b) => (b.hours || 0) - (a.hours || 0))
+ .slice(0, 3);
+ if (!top.length) {
+ els.topProjects.innerHTML = 'No time tracked in the last 7 days.
';
+ return;
+ }
+ const max = top[0].hours || 1;
+ els.topProjects.innerHTML = '';
+ for (const p of top) {
+ const row = document.createElement('div');
+ row.className = 'top-row';
+ const name = p.project_name || `Project #${p.project_id ?? ''}`;
+ row.innerHTML =
+ `` +
+ `${formatHours(p.hours || 0)}`;
+ row.querySelector('.top-name').textContent = decodeEntities(name);
+ els.topProjects.appendChild(row);
+
+ const track = document.createElement('div');
+ track.className = 'top-track';
+ const fill = document.createElement('div');
+ fill.style.width = `${Math.max(3, Math.round(((p.hours || 0) / max) * 100))}%`;
+ fill.style.background = colorForProject(p.project_id);
+ track.appendChild(fill);
+ els.topProjects.appendChild(track);
+ }
+ }
+
+ function renderAll() {
+ renderStats();
+ renderGoal(ctx.state.weeklyGoal);
+ renderChart(ctx.state.weekEntries || [], new Date());
+ renderTopProjects(ctx.state.weekByProject || []);
+ }
+
+ return { renderAll };
+}
diff --git a/browser-extension/popup/main.js b/browser-extension/popup/main.js
new file mode 100644
index 000000000..3812f08fd
--- /dev/null
+++ b/browser-extension/popup/main.js
@@ -0,0 +1,324 @@
+/**
+ * Popup controller: bootstrap, tab switching, data loading and shared state.
+ * Tabs are small modules that receive a `ctx` with everything they need.
+ */
+
+import { ApiClient, elapsedSecondsFromTimer } from '../lib/api.js';
+import {
+ addDays,
+ entryDurationSeconds,
+ localDateKey,
+} from '../lib/format.js';
+import { icons } from '../lib/icons.js';
+import { initTimerTab } from './timer-tab.js';
+import { initInsightsTab } from './insights-tab.js';
+import { initRecentsTab } from './recents-tab.js';
+
+const el = (id) => document.getElementById(id);
+
+const els = {
+ conn: el('conn'),
+ connText: el('conn-text'),
+ settings: el('btn-settings'),
+ glyph: el('glyph'),
+ setupGlyph: el('setup-glyph'),
+ setup: el('setup'),
+ setupText: el('setup-text'),
+ main: el('main'),
+ message: el('message'),
+ tabs: {
+ timer: el('tab-timer'),
+ insights: el('tab-insights'),
+ recents: el('tab-recents'),
+ },
+ panels: {
+ timer: el('panel-timer'),
+ insights: el('panel-insights'),
+ recents: el('panel-recents'),
+ },
+};
+
+els.glyph.innerHTML = icons.timer;
+els.setupGlyph.innerHTML = icons.timer;
+els.settings.innerHTML = icons.settings;
+els.settings.addEventListener('click', () => chrome.runtime.openOptionsPage());
+el('btn-open-settings').addEventListener('click', () => chrome.runtime.openOptionsPage());
+el('search-icon').innerHTML = icons.search;
+
+/** Shared mutable state handed to the tab modules. */
+const state = {
+ serverUrl: null,
+ timer: null,
+ projects: [],
+ recentEntries: [],
+ weeklyGoal: null,
+ weekByProject: [],
+ weekEntries: [],
+ todaySeconds: 0,
+ billableSecondsToday: 0,
+ todayEntries: 0,
+ selected: { projectId: null, taskId: null },
+};
+
+let client = null;
+let keepAlivePort = null;
+let timerApi = null;
+let insightsApi = null;
+let recentsApi = null;
+
+// ── messaging + UI helpers ───────────────────────────────
+
+let messageTimer = null;
+function showMessage(text, kind = 'error') {
+ els.message.textContent = text;
+ els.message.className = `toast ${kind}`;
+ els.message.classList.remove('hidden');
+ clearTimeout(messageTimer);
+ messageTimer = setTimeout(() => {
+ els.message.classList.add('hidden');
+ }, 3000);
+}
+
+function clearMessage() {
+ clearTimeout(messageTimer);
+ els.message.classList.add('hidden');
+}
+
+function notifyBackground() {
+ try {
+ chrome.runtime.sendMessage({ type: 'refresh_timer' }).catch(() => {});
+ } catch {
+ /* extension context gone */
+ }
+}
+
+function setConn({ offline = false } = {}) {
+ els.conn.hidden = false;
+ els.conn.classList.toggle('is-offline', offline);
+ els.connText.textContent = offline ? 'Offline' : 'Connected';
+}
+
+function activeElapsedSeconds() {
+ return state.timer ? elapsedSecondsFromTimer(state.timer) : 0;
+}
+
+function switchTab(name) {
+ for (const key of Object.keys(els.tabs)) {
+ const active = key === name;
+ els.tabs[key].classList.toggle('is-active', active);
+ els.tabs[key].setAttribute('aria-selected', String(active));
+ els.panels[key].hidden = !active;
+ }
+}
+
+for (const [name, button] of Object.entries(els.tabs)) {
+ button.addEventListener('click', () => switchTab(name));
+}
+
+// '/' focuses search from anywhere (when not typing)
+document.addEventListener('keydown', (event) => {
+ if (event.key !== '/' || event.defaultPrevented) return;
+ const target = event.target;
+ if (target instanceof HTMLElement && target.matches('input, textarea, select')) return;
+ if (els.panels.timer.hidden || state.timer) return;
+ event.preventDefault();
+ el('search-input').focus();
+});
+
+// ── data loading ─────────────────────────────────────────
+
+function sortProjects(a, b) {
+ if (a.favorite !== b.favorite) return a.favorite ? -1 : 1;
+ const aUsed = a.last_used_at ? Date.parse(a.last_used_at) : 0;
+ const bUsed = b.last_used_at ? Date.parse(b.last_used_at) : 0;
+ if (aUsed !== bUsed) return bUsed - aUsed;
+ return String(a.name).localeCompare(String(b.name));
+}
+
+async function loadDirectory() {
+ try {
+ const [projectsResp, favResp, clientsResp] = await Promise.all([
+ client.getAllProjects({ status: 'active', per_page: 100 }),
+ client.getFavoriteProjects().catch(() => ({ favorites: [] })),
+ client.getClients({ per_page: 100 }).catch(() => ({ clients: [] })),
+ ]);
+ const favIds = new Set((favResp?.favorites || []).map((f) => f.project_id));
+ const clientNames = new Map((clientsResp?.clients || []).map((c) => [c.id, c.name]));
+ const raw = projectsResp?.projects || [];
+ state.projects = raw
+ .map((p) => ({
+ id: p.id,
+ name: p.name,
+ client_id: p.client_id ?? null,
+ clientName: clientNames.get(p.client_id) || null,
+ favorite: favIds.has(p.id),
+ last_used_at: p.last_used_at ?? null,
+ }))
+ .sort(sortProjects);
+
+ if (!state.selected.projectId) {
+ const { last_project } = await chrome.storage.local.get('last_project');
+ const last = state.projects.find((p) => p.id === last_project?.project_id);
+ if (last) state.selected.projectId = last.id;
+ }
+ timerApi?.renderIdle();
+ } catch (error) {
+ console.warn('Failed to load projects', error);
+ }
+}
+
+async function loadWeekData() {
+ const now = new Date();
+ const today = localDateKey(now);
+ const weekAgo = localDateKey(addDays(now, -6));
+
+ try {
+ const [todaySum, weekSum, weekEntriesResp, goalsResp] = await Promise.all([
+ client.getSummary({ startDate: today, endDate: today }).catch(() => null),
+ client.getSummary({ startDate: weekAgo, endDate: today }).catch(() => null),
+ client
+ .getTimeEntries({ startDate: weekAgo, endDate: today, perPage: 200, includeActive: true })
+ .catch(() => ({ time_entries: [] })),
+ client.getWeeklyGoals().catch(() => ({ weekly_goals: [] })),
+ ]);
+
+ const summary = todaySum?.summary;
+ state.todaySeconds = Math.round((Number(summary?.total_hours) || 0) * 3600);
+ state.billableSecondsToday = Math.round((Number(summary?.billable_hours) || 0) * 3600);
+ state.todayEntries = Number(summary?.total_entries) || 0;
+ state.weekByProject = weekSum?.summary?.by_project || [];
+
+ const entries = weekEntriesResp?.time_entries || [];
+ state.weekEntries = entries;
+ state.recentEntries = entries;
+
+ const goals = goalsResp?.weekly_goals || [];
+ state.weeklyGoal =
+ goals.find((g) => {
+ const start = localDateKey(new Date(g.week_start_date || ''));
+ const end = localDateKey(new Date(g.week_end_date || ''));
+ return start <= today && today <= end;
+ }) || goals[0] || null;
+
+ insightsApi?.renderAll();
+ recentsApi?.render();
+ timerApi?.renderNoteSuggestions();
+ } catch (error) {
+ console.warn('Failed to load insights', error);
+ insightsApi?.renderAll();
+ recentsApi?.render();
+ }
+}
+
+// ── timer status sync ────────────────────────────────────
+
+function applyStatus(status) {
+ const active = Boolean(status?.active && status?.timer);
+ state.timer = active ? status.timer : null;
+ timerApi?.render();
+}
+
+chrome.storage.onChanged.addListener((changes, area) => {
+ if (area !== 'local') return;
+ if (changes.last_timer_status) {
+ const value = changes.last_timer_status.newValue;
+ if (value && !value.error) setConn({});
+ else if (value?.error && value.error !== 'not_configured') setConn({ offline: true });
+ if (value && 'active' in value) applyStatus(value);
+ }
+});
+
+function connectKeepAlive() {
+ try {
+ keepAlivePort = chrome.runtime.connect({ name: 'popup-keepalive' });
+ } catch {
+ keepAlivePort = null;
+ }
+}
+
+// ── bootstrap ────────────────────────────────────────────
+
+function showSetup(message = null) {
+ els.main.classList.add('hidden');
+ els.setup.classList.remove('hidden');
+ els.conn.hidden = true;
+ if (message) els.setupText.textContent = message;
+}
+
+async function bootstrap() {
+ const cfg = await chrome.storage.local.get([
+ 'server_url',
+ 'api_token',
+ 'logged_out',
+ 'last_timer_status',
+ ]);
+
+ if (!cfg.server_url || !cfg.api_token || cfg.logged_out) {
+ const expired = Boolean(cfg.server_url && cfg.logged_out);
+ showSetup(
+ expired
+ ? 'Session expired. Sign in again in Settings.'
+ : 'Sign in to your TimeTracker server to start and stop timers from the toolbar.'
+ );
+ return;
+ }
+
+ state.serverUrl = cfg.server_url;
+ client = new ApiClient(cfg.server_url, cfg.api_token);
+
+ timerApi = initTimerTab(ctx);
+ insightsApi = initInsightsTab(ctx);
+ recentsApi = initRecentsTab(ctx);
+
+ try {
+ const status = await client.getTimerStatus();
+ setConn({});
+ applyStatus(status);
+ } catch (error) {
+ if (error.status === 401 || error.code === 'UNAUTHORIZED') {
+ await chrome.storage.local.set({ logged_out: true });
+ showSetup('Session expired. Sign in again in Settings.');
+ return;
+ }
+ setConn({ offline: true });
+ const cached = cfg.last_timer_status;
+ if (cached?.active && cached?.timer) {
+ state.timer = cached.timer;
+ showMessage(error.message || 'Could not refresh timer; showing last known state.');
+ } else {
+ showMessage(error.message || 'Could not reach TimeTracker.');
+ }
+ }
+
+ els.main.classList.remove('hidden');
+ els.setup.classList.add('hidden');
+ timerApi.render();
+ connectKeepAlive();
+ await Promise.all([loadDirectory(), loadWeekData()]);
+}
+
+const ctx = {
+ get client() {
+ return client;
+ },
+ state,
+ showMessage,
+ clearMessage,
+ notifyBackground,
+ setConn,
+ switchTab,
+ activeElapsedSeconds,
+ loadDirectory,
+ loadWeekData,
+ refreshInsights: () => void loadWeekData(),
+ refreshRecents: () => void loadWeekData(),
+ refreshAll: () => {
+ void loadDirectory();
+ void loadWeekData();
+ },
+ startTracking: (args) => timerApi?.startTracking(args),
+ /** Recents "track again" needs entry duration helpers too. */
+ entryDurationFor: (entry) => entryDurationSeconds(entry),
+};
+
+void bootstrap();
diff --git a/browser-extension/popup/popup.css b/browser-extension/popup/popup.css
new file mode 100644
index 000000000..5a3f8d251
--- /dev/null
+++ b/browser-extension/popup/popup.css
@@ -0,0 +1,592 @@
+/* Popup layout — uses shared tokens from styles/tokens.css */
+
+body {
+ width: 380px;
+ max-height: 600px;
+ overflow-y: auto;
+ overflow-x: hidden;
+}
+
+body::-webkit-scrollbar {
+ width: 8px;
+}
+
+body::-webkit-scrollbar-thumb {
+ background: var(--tt-surface-2);
+ border-radius: 999px;
+}
+
+.popup {
+ padding: 14px;
+ display: flex;
+ flex-direction: column;
+ gap: 12px;
+}
+
+/* ── header ───────────────────────────────────────────── */
+
+.pop-head {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 8px;
+}
+
+.app-id {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ font-size: 14px;
+ font-weight: 600;
+ letter-spacing: -0.01em;
+}
+
+.glyph {
+ width: 26px;
+ height: 26px;
+ border-radius: 8px;
+ display: inline-grid;
+ place-items: center;
+ color: #fff;
+ background: linear-gradient(135deg, #0a84ff, #5e5ce6);
+ flex: none;
+}
+
+.glyph svg {
+ width: 15px;
+ height: 15px;
+}
+
+.glyph-lg {
+ width: 40px;
+ height: 40px;
+ border-radius: 12px;
+}
+
+.glyph-lg svg {
+ width: 22px;
+ height: 22px;
+}
+
+.head-right {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+}
+
+.conn {
+ display: inline-flex;
+ align-items: center;
+ gap: 6px;
+ font-size: 11.5px;
+ color: var(--tt-ink-2);
+}
+
+.conn .dot {
+ width: 7px;
+ height: 7px;
+ border-radius: 50%;
+ background: var(--tt-green);
+}
+
+.conn.is-offline .dot { background: var(--tt-orange); }
+
+/* ── setup card ───────────────────────────────────────── */
+
+.setup {
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ text-align: center;
+ gap: 10px;
+ padding: 22px 16px;
+ font-size: 14px;
+}
+
+.setup p {
+ margin: 0;
+ font-size: 12.5px;
+ line-height: 1.45;
+}
+
+.muted { color: var(--tt-ink-2); }
+
+/* ── segmented control ────────────────────────────────── */
+
+.seg {
+ display: flex;
+ background: var(--tt-surface-2);
+ border-radius: 10px;
+ padding: 2px;
+}
+
+.seg button {
+ flex: 1;
+ padding: 7px 0;
+ font-size: 12.5px;
+ font-weight: 500;
+ color: var(--tt-ink-2);
+ border-radius: 8px;
+ transition: all 0.18s ease;
+}
+
+.seg button.is-active {
+ background: var(--tt-surface);
+ color: var(--tt-ink);
+ font-weight: 600;
+ box-shadow: 0 1px 3px rgba(0, 0, 0, 0.12), 0 0 0 0.5px var(--tt-line);
+}
+
+.panel,
+.stack {
+ display: flex;
+ flex-direction: column;
+ gap: 12px;
+}
+
+#main {
+ display: flex;
+ flex-direction: column;
+ gap: 12px;
+}
+
+/* ── search + results dropdown ────────────────────────── */
+
+.search-wrap {
+ position: relative;
+}
+
+.field-icon {
+ display: inline-grid;
+ place-items: center;
+}
+
+.field-icon svg {
+ width: 16px;
+ height: 16px;
+}
+
+.results {
+ position: absolute;
+ z-index: 30;
+ top: calc(100% + 6px);
+ left: 0;
+ right: 0;
+ background: var(--tt-surface);
+ border: 1px solid var(--tt-line);
+ border-radius: 12px;
+ box-shadow: var(--tt-shadow);
+ padding: 4px;
+ max-height: 260px;
+ overflow-y: auto;
+}
+
+.result {
+ display: flex;
+ align-items: center;
+ gap: 9px;
+ width: 100%;
+ padding: 8px 9px;
+ border-radius: 8px;
+ text-align: left;
+ font-size: 13px;
+}
+
+.result svg {
+ width: 14px;
+ height: 14px;
+ flex: none;
+}
+
+.result .r-client {
+ margin-left: auto;
+ font-size: 11px;
+ color: var(--tt-ink-2);
+ white-space: nowrap;
+}
+
+.result .r-star {
+ color: var(--tt-orange);
+ display: inline-grid;
+ place-items: center;
+}
+
+.result .r-star svg { width: 12px; height: 12px; }
+
+.result:hover,
+.result.is-active {
+ background: var(--tt-surface-2);
+}
+
+.result-create {
+ color: var(--tt-accent);
+ font-weight: 600;
+ border-top: 1px solid var(--tt-line);
+ border-radius: 0 0 8px 8px;
+ margin-top: 2px;
+}
+
+.result-empty {
+ padding: 10px;
+ font-size: 12px;
+ color: var(--tt-ink-2);
+ text-align: center;
+ cursor: default;
+}
+
+/* ── selection card ───────────────────────────────────── */
+
+.sel-main {
+ display: flex;
+ align-items: center;
+ gap: 10px;
+ padding-bottom: 10px;
+ border-bottom: 1px solid var(--tt-line);
+}
+
+.sel-txt {
+ flex: 1;
+ min-width: 0;
+ display: flex;
+ flex-direction: column;
+ gap: 1px;
+}
+
+.sel-txt strong {
+ font-size: 13.5px;
+ font-weight: 600;
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+}
+
+.sel-txt span {
+ font-size: 11.5px;
+ color: var(--tt-ink-2);
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+}
+
+.sel-row {
+ display: flex;
+ align-items: center;
+ gap: 6px;
+ width: 100%;
+ padding: 9px 2px;
+ border-bottom: 1px solid var(--tt-line);
+ font-size: 12.5px;
+ color: var(--tt-ink-2);
+ text-align: left;
+}
+
+.sel-val {
+ margin-left: auto;
+ color: var(--tt-ink);
+ font-weight: 500;
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ max-width: 180px;
+}
+
+.sel-chevron {
+ display: inline-grid;
+ place-items: center;
+ color: var(--tt-ink-3);
+}
+
+.sel-chevron svg { width: 14px; height: 14px; }
+
+.note {
+ width: 100%;
+ margin-top: 9px;
+ border: none;
+ outline: none;
+ background: transparent;
+ color: var(--tt-ink);
+ font-size: 12.5px;
+ padding: 2px;
+}
+
+.note::placeholder { color: var(--tt-ink-3); }
+
+.note-sugg { margin-top: 9px; }
+
+/* task dropdown menu */
+
+.task-menu {
+ position: relative;
+ margin: 6px 0 2px;
+ background: var(--tt-bg);
+ border: 1px solid var(--tt-line);
+ border-radius: 10px;
+ padding: 4px;
+ max-height: 180px;
+ overflow-y: auto;
+}
+
+.task-menu .result { font-size: 12.5px; padding: 7px 8px; }
+
+.task-create-row {
+ color: var(--tt-accent);
+ font-weight: 600;
+}
+
+/* ── running view ─────────────────────────────────────── */
+
+.run {
+ padding: 16px 14px 14px;
+ text-align: center;
+}
+
+.run-head {
+ display: flex;
+ align-items: center;
+ gap: 10px;
+ text-align: left;
+ margin-bottom: 10px;
+}
+
+.rec {
+ margin-left: auto;
+ display: inline-flex;
+ align-items: center;
+ gap: 5px;
+ font-size: 10.5px;
+ font-weight: 700;
+ letter-spacing: 0.06em;
+ color: var(--tt-red);
+ background: var(--tt-red-soft);
+ padding: 3px 9px;
+ border-radius: 999px;
+ flex: none;
+}
+
+.rec-dot {
+ width: 6px;
+ height: 6px;
+ border-radius: 50%;
+ background: var(--tt-red);
+ animation: recPulse 1.6s ease-in-out infinite;
+}
+
+.rec.is-paused {
+ color: var(--tt-orange);
+ background: var(--tt-orange-soft);
+}
+
+.rec.is-paused .rec-dot {
+ animation: none;
+ background: var(--tt-orange);
+}
+
+@keyframes recPulse {
+ 50% { opacity: 0.2; }
+}
+
+.clock {
+ font-size: 40px;
+ font-weight: 700;
+ letter-spacing: -0.03em;
+ line-height: 1.15;
+ font-variant-numeric: tabular-nums;
+}
+
+.run[data-paused="1"] .clock { color: var(--tt-ink-2); }
+
+.run-meta {
+ font-size: 11.5px;
+ color: var(--tt-ink-2);
+ margin-top: 5px;
+}
+
+.run-actions {
+ display: flex;
+ gap: 8px;
+}
+
+.note-editor input { font-size: 12.5px; }
+
+/* ── insights ─────────────────────────────────────────── */
+
+.stats {
+ display: grid;
+ grid-template-columns: repeat(3, 1fr);
+ gap: 8px;
+}
+
+.stat {
+ background: var(--tt-surface);
+ border: 1px solid var(--tt-line);
+ border-radius: 12px;
+ padding: 10px 8px;
+ text-align: center;
+ display: flex;
+ flex-direction: column;
+ gap: 2px;
+}
+
+.stat-v {
+ font-size: 18px;
+ font-weight: 700;
+ letter-spacing: -0.02em;
+ font-variant-numeric: tabular-nums;
+}
+
+.stat-l {
+ font-size: 10.5px;
+ font-weight: 600;
+ color: var(--tt-ink-2);
+ text-transform: uppercase;
+ letter-spacing: 0.05em;
+}
+
+.block-head {
+ display: flex;
+ justify-content: space-between;
+ align-items: baseline;
+ gap: 8px;
+ font-size: 12.5px;
+ font-weight: 600;
+ margin-bottom: 9px;
+}
+
+.block-sub2 {
+ font-size: 11px;
+ font-weight: 500;
+ color: var(--tt-ink-2);
+}
+
+.block-sub {
+ font-size: 11px;
+ color: var(--tt-ink-2);
+ margin: 0;
+}
+
+.week-chart svg {
+ display: block;
+ width: 100%;
+ height: auto;
+ margin-top: 2px;
+}
+
+.week-chart .bar { fill: var(--tt-bar); }
+.week-chart .bar.today { fill: var(--tt-accent); }
+.week-chart text { fill: var(--tt-ink-2); font-size: 11px; }
+.week-chart text.val { fill: var(--tt-accent); font-weight: 600; }
+
+.top-row {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ font-size: 12.5px;
+ margin-top: 9px;
+}
+
+.top-row:first-of-type { margin-top: 0; }
+
+.top-name {
+ flex: 1;
+ min-width: 0;
+ font-weight: 500;
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+}
+
+.top-h {
+ color: var(--tt-ink-2);
+ font-size: 11.5px;
+ font-variant-numeric: tabular-nums;
+}
+
+.top-track {
+ height: 4px;
+ border-radius: 999px;
+ background: var(--tt-surface-2);
+ overflow: hidden;
+ margin: 5px 0 0 16px;
+}
+
+.top-track > div {
+ height: 100%;
+ border-radius: 999px;
+ opacity: 0.9;
+}
+
+/* ── recents ──────────────────────────────────────────── */
+
+#recents-list {
+ display: flex;
+ flex-direction: column;
+ gap: 10px;
+}
+
+.recents-foot {
+ text-align: center;
+}
+
+.group-h {
+ font-size: 11px;
+ font-weight: 600;
+ text-transform: uppercase;
+ letter-spacing: 0.05em;
+ color: var(--tt-ink-2);
+ margin-bottom: 3px;
+}
+
+.entry {
+ display: flex;
+ align-items: center;
+ gap: 10px;
+ padding: 9px 0;
+ border-bottom: 1px solid var(--tt-line);
+}
+
+.entry:last-child {
+ border-bottom: none;
+ padding-bottom: 2px;
+}
+
+.entry-main {
+ flex: 1;
+ min-width: 0;
+ display: flex;
+ flex-direction: column;
+ gap: 1px;
+}
+
+.entry-main strong {
+ font-size: 12.5px;
+ font-weight: 600;
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+}
+
+.entry-main span {
+ font-size: 11px;
+ color: var(--tt-ink-2);
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+}
+
+.entry-side {
+ display: flex;
+ align-items: center;
+ gap: 6px;
+}
+
+.entry-side strong {
+ font-size: 12.5px;
+ font-variant-numeric: tabular-nums;
+ font-weight: 600;
+}
+
+.entry-side .iconbtn { width: 26px; height: 26px; }
+
+@media (prefers-reduced-motion: reduce) {
+ * { animation: none !important; transition: none !important; }
+}
diff --git a/browser-extension/popup/popup.html b/browser-extension/popup/popup.html
new file mode 100644
index 000000000..58648e44c
--- /dev/null
+++ b/browser-extension/popup/popup.html
@@ -0,0 +1,159 @@
+
+
+
+
+
+ TimeTracker
+
+
+
+
+
+
+
+
+
diff --git a/browser-extension/popup/recents-tab.js b/browser-extension/popup/recents-tab.js
new file mode 100644
index 000000000..c513ea9ee
--- /dev/null
+++ b/browser-extension/popup/recents-tab.js
@@ -0,0 +1,100 @@
+/**
+ * Recents tab: recent entries grouped by day with one-click "track again".
+ */
+
+import { colorForProject, formatClock, formatTimeOf, groupEntriesByDay, entryDurationSeconds } from '../lib/format.js';
+import { icons } from '../lib/icons.js';
+import { decodeEntities } from './text.js';
+
+const el = (id) => document.getElementById(id);
+
+export function initRecentsTab(ctx) {
+ const els = {
+ list: el('recents-list'),
+ historyBtn: el('btn-history'),
+ historyArrow: el('history-arrow'),
+ };
+
+ els.historyArrow.innerHTML = icons['arrow-up-right'];
+ els.historyBtn.addEventListener('click', () => {
+ const url = ctx.state.serverUrl;
+ if (url) chrome.tabs.create({ url });
+ });
+
+ function render() {
+ const entries = ctx.state.recentEntries || [];
+ if (!entries.length) {
+ els.list.innerHTML = 'No entries in the last 7 days.
';
+ return;
+ }
+
+ els.list.innerHTML = '';
+ for (const group of groupEntriesByDay(entries)) {
+ const card = document.createElement('div');
+ card.className = 'card';
+
+ const head = document.createElement('div');
+ head.className = 'group-h';
+ head.textContent = group.label;
+ card.appendChild(head);
+
+ for (const entry of group.entries) {
+ card.appendChild(renderEntry(entry));
+ }
+ els.list.appendChild(card);
+ }
+ }
+
+ function renderEntry(entry) {
+ const row = document.createElement('div');
+ row.className = 'entry';
+
+ const dot = document.createElement('span');
+ dot.className = 'pdot';
+ dot.style.background = colorForProject(entry.project_id);
+ row.appendChild(dot);
+
+ const main = document.createElement('div');
+ main.className = 'entry-main';
+ const name = document.createElement('strong');
+ name.textContent = decodeEntities(entry.project || entry.client || `Project #${entry.project_id ?? ''}`);
+ const range = entry.is_active
+ ? `${formatTimeOf(entry.start_time)}–now`
+ : `${formatTimeOf(entry.start_time)}–${formatTimeOf(entry.end_time)}`;
+ const sub = decodeEntities([entry.task, range].filter(Boolean).join(' · '));
+ const subEl = document.createElement('span');
+ subEl.textContent = sub;
+ main.append(name, subEl);
+ row.appendChild(main);
+
+ const side = document.createElement('div');
+ side.className = 'entry-side';
+ const duration = document.createElement('strong');
+ duration.textContent = formatClock(entryDurationSeconds(entry));
+ side.appendChild(duration);
+
+ const again = document.createElement('button');
+ again.type = 'button';
+ again.className = 'iconbtn';
+ again.innerHTML = icons['rotate-ccw'];
+ again.setAttribute(
+ 'aria-label',
+ `Track again: ${entry.project || entry.client || 'entry'}`
+ );
+ again.setAttribute('data-tooltip', 'Track again');
+ again.addEventListener('click', () => {
+ void ctx.startTracking({
+ projectId: entry.project_id,
+ clientId: entry.client_id ?? null,
+ taskId: entry.task_id ?? null,
+ });
+ ctx.switchTab('timer');
+ });
+ side.appendChild(again);
+ row.appendChild(side);
+
+ return row;
+ }
+
+ return { render };
+}
diff --git a/browser-extension/popup/search.js b/browser-extension/popup/search.js
new file mode 100644
index 000000000..3df663e57
--- /dev/null
+++ b/browser-extension/popup/search.js
@@ -0,0 +1,141 @@
+/**
+ * Unified project search: filters the project list by name or client name,
+ * keyboard navigable, with an inline "create project" escape hatch.
+ */
+
+import { colorForProject } from '../lib/format.js';
+import { displayHtml } from './text.js';
+import { icons } from '../lib/icons.js';
+
+export function createProjectSearch({ wrapEl, input, listEl, getProjects, onSelect, onCreate }) {
+ let open = false;
+ let items = [];
+ let activeIndex = -1;
+ let query = '';
+
+ function currentProjects() {
+ return typeof getProjects === 'function' ? getProjects() : [];
+ }
+
+ function close() {
+ open = false;
+ activeIndex = -1;
+ listEl.classList.add('hidden');
+ listEl.innerHTML = '';
+ }
+
+ function filtered() {
+ const q = query.trim().toLowerCase();
+ const projects = currentProjects();
+ if (!q) return projects.slice(0, 8);
+ return projects
+ .filter((p) => {
+ const haystack = `${p.name} ${p.clientName || ''}`.toLowerCase();
+ return haystack.includes(q);
+ })
+ .slice(0, 8);
+ }
+
+ function render() {
+ const matches = filtered();
+ const q = query.trim();
+ const exact = q
+ ? currentProjects().some((p) => p.name.toLowerCase() === q.toLowerCase())
+ : true;
+ const canCreate = q.length >= 2 && !exact;
+
+ items = [...matches];
+ if (canCreate) items.push({ __create: true, name: q });
+
+ listEl.innerHTML = '';
+ if (!items.length) {
+ const empty = document.createElement('div');
+ empty.className = 'result-empty';
+ empty.textContent = 'No projects match your search';
+ listEl.appendChild(empty);
+ }
+
+ items.forEach((item, index) => {
+ const row = document.createElement('button');
+ row.type = 'button';
+ row.className = 'result';
+ row.setAttribute('role', 'option');
+ if (index === activeIndex) row.classList.add('is-active');
+ if (item.__create) {
+ row.classList.add('result-create');
+ row.innerHTML = `${icons.plus}Create project “${displayHtml(item.name)}”`;
+ } else {
+ const dot = ``;
+ const star = item.favorite
+ ? `${icons.star}`
+ : '';
+ const client = item.clientName
+ ? `${displayHtml(item.clientName)}`
+ : '';
+ row.innerHTML = `${dot}${displayHtml(item.name)}${star}${client}`;
+ }
+ // mousedown fires before the input's blur, so selection survives it
+ row.addEventListener('mousedown', (event) => {
+ event.preventDefault();
+ pick(index);
+ });
+ listEl.appendChild(row);
+ });
+
+ open = true;
+ listEl.classList.remove('hidden');
+ }
+
+ function pick(index) {
+ const item = items[index];
+ if (!item) return;
+ close();
+ input.value = '';
+ query = '';
+ if (item.__create) onCreate?.(item.name);
+ else onSelect?.(item);
+ }
+
+ function move(delta) {
+ if (!items.length) return;
+ activeIndex = (activeIndex + delta + items.length) % items.length;
+ render();
+ }
+
+ input.addEventListener('input', () => {
+ query = input.value;
+ activeIndex = -1;
+ render();
+ });
+
+ input.addEventListener('focus', () => render());
+
+ input.addEventListener('keydown', (event) => {
+ if (event.key === 'ArrowDown') {
+ event.preventDefault();
+ if (!open) render();
+ else move(1);
+ } else if (event.key === 'ArrowUp') {
+ event.preventDefault();
+ move(-1);
+ } else if (event.key === 'Enter') {
+ if (open && items.length) {
+ event.preventDefault();
+ pick(activeIndex >= 0 ? activeIndex : 0);
+ }
+ } else if (event.key === 'Escape') {
+ close();
+ input.blur();
+ }
+ });
+
+ input.addEventListener('blur', () => {
+ // allow mousedown selection to run first
+ setTimeout(() => {
+ if (!listEl.matches(':hover')) close();
+ }, 120);
+ });
+
+ return { close };
+}
+
diff --git a/browser-extension/popup/text.js b/browser-extension/popup/text.js
new file mode 100644
index 000000000..779d8b3ed
--- /dev/null
+++ b/browser-extension/popup/text.js
@@ -0,0 +1,28 @@
+/**
+ * Text helpers for display. Server data occasionally contains pre-escaped
+ * HTML entities (e.g. "&" stored literally), so display strings are
+ * decoded once before rendering; anything written via innerHTML is escaped
+ * afterwards to stay XSS-safe.
+ */
+
+const decoder = typeof document !== 'undefined' ? document.createElement('textarea') : null;
+
+/** Decode HTML entities in display strings ("&" → "&"), once. */
+export function decodeEntities(text) {
+ const s = String(text ?? '');
+ if (!s.includes('&') || !decoder) return s;
+ decoder.innerHTML = s;
+ return decoder.value;
+}
+
+/** Escape a string for safe insertion via innerHTML. */
+export function escapeHtml(text) {
+ const div = document.createElement('div');
+ div.textContent = String(text ?? '');
+ return div.innerHTML;
+}
+
+/** decode → escape, for innerHTML targets. */
+export function displayHtml(text) {
+ return escapeHtml(decodeEntities(text));
+}
diff --git a/browser-extension/popup/timer-tab.js b/browser-extension/popup/timer-tab.js
new file mode 100644
index 000000000..4578a2a66
--- /dev/null
+++ b/browser-extension/popup/timer-tab.js
@@ -0,0 +1,578 @@
+/**
+ * Timer tab: idle selection flow (search/chips/notes) + running clock view.
+ * Talks to /api/v1/timer/* directly; badge/background sync via ctx.
+ */
+
+import { ApiClient, elapsedSecondsFromTimer } from '../lib/api.js';
+import { colorForProject, formatClock, formatTimeOf, noteSuggestions, formatHM } from '../lib/format.js';
+import { icons } from '../lib/icons.js';
+import { createProjectSearch } from './search.js';
+import { decodeEntities, displayHtml } from './text.js';
+
+/** Closed statuses — matches Task.is_active (anything else is selectable). */
+const CLOSED_TASK_STATUSES = new Set(['done', 'cancelled']);
+
+const el = (id) => document.getElementById(id);
+
+export function initTimerTab(ctx) {
+ const els = {
+ viewIdle: el('view-idle'),
+ viewRunning: el('view-running'),
+ runCard: el('run-card'),
+ searchInput: el('search-input'),
+ recentChiprow: el('recent-chiprow'),
+ recentChips: el('recent-chips'),
+ favChiprow: el('fav-chiprow'),
+ favChips: el('fav-chips'),
+ selection: el('selection'),
+ selDot: el('sel-dot'),
+ selProject: el('sel-project'),
+ selClient: el('sel-client'),
+ selClear: el('sel-clear'),
+ selTask: el('sel-task'),
+ selTaskName: el('sel-task-name'),
+ selTaskChevron: el('sel-task-chevron'),
+ taskMenu: el('task-menu'),
+ notes: el('notes'),
+ noteSugg: el('note-sugg'),
+ noteSuggChips: el('note-sugg-chips'),
+ btnStart: el('btn-start'),
+ runDot: el('run-dot'),
+ runProject: el('run-project'),
+ runSub: el('run-sub'),
+ recBadge: el('rec-badge'),
+ clock: el('clock'),
+ runMeta: el('run-meta'),
+ btnPause: el('btn-pause'),
+ btnStop: el('btn-stop'),
+ btnNote: el('btn-note'),
+ noteEditor: el('note-editor'),
+ quickNoteInput: el('quick-note-input'),
+ quickNoteSave: el('quick-note-save'),
+ };
+
+ let searchHandle = null;
+ let taskLoadGeneration = 0;
+ const taskCache = new Map();
+ let noteSaveTimer = null;
+
+ const state = ctx.state;
+
+ function selectedProject() {
+ return state.projects.find((p) => p.id === state.selected.projectId) || null;
+ }
+
+ function selectedTask() {
+ if (state.selected.taskId == null) return null;
+ return (taskCache.get(state.selected.projectId) || []).find(
+ (t) => t.id === state.selected.taskId
+ ) || null;
+ }
+
+ // ── rendering: idle ────────────────────────────────────
+
+ function renderChips() {
+ const favorites = state.projects.filter((p) => p.favorite).slice(0, 3);
+ const favoriteIds = new Set(favorites.map((p) => p.id));
+ const recent = state.projects
+ .filter((p) => p.last_used_at && !favoriteIds.has(p.id))
+ .slice(0, 3);
+
+ els.favChiprow.hidden = favorites.length === 0;
+ els.recentChiprow.hidden = recent.length === 0;
+ fillChipRow(els.favChips, favorites);
+ fillChipRow(els.recentChips, recent);
+ }
+
+ function fillChipRow(container, projects) {
+ container.innerHTML = '';
+ for (const p of projects) {
+ const chip = document.createElement('button');
+ chip.type = 'button';
+ chip.className = 'chip';
+ chip.classList.toggle('is-selected', p.id === state.selected.projectId);
+ const star = p.favorite ? `${icons.star}` : '';
+ chip.innerHTML = `${displayHtml(p.name)}${star}`;
+ chip.addEventListener('click', () => {
+ selectProject(p);
+ searchHandle?.close();
+ });
+ container.appendChild(chip);
+ }
+ }
+
+ function renderNoteSuggestions() {
+ const suggestions = noteSuggestions(state.recentEntries, 3);
+ els.noteSugg.hidden = suggestions.length === 0;
+ els.noteSuggChips.innerHTML = '';
+ for (const note of suggestions) {
+ const chip = document.createElement('button');
+ chip.type = 'button';
+ chip.className = 'chip sm';
+ chip.textContent = decodeEntities(note);
+ chip.addEventListener('click', () => {
+ els.notes.value = note;
+ els.notes.focus();
+ });
+ els.noteSuggChips.appendChild(chip);
+ }
+ }
+
+ function renderSelection() {
+ const project = selectedProject();
+ els.selection.classList.toggle('hidden', !project);
+ els.btnStart.disabled = !project;
+ els.btnStart.innerHTML = project
+ ? `${icons.play}Start timer`
+ : `${icons.play}Select a project`;
+ if (!project) return;
+
+ els.selDot.style.background = colorForProject(project.id);
+ els.selProject.textContent = decodeEntities(project.name);
+ els.selClient.textContent = decodeEntities(project.clientName || '');
+
+ const task = selectedTask();
+ els.selTaskName.textContent = task ? decodeEntities(task.name) : 'None';
+
+ renderNoteSuggestions();
+ }
+
+ function renderIdle() {
+ renderChips();
+ renderSelection();
+ }
+
+ // ── rendering: running ─────────────────────────────────
+
+ function runningName(timer) {
+ return (
+ timer.project ||
+ timer.client ||
+ (timer.project_id ? `Project #${timer.project_id}` : 'Timer')
+ );
+ }
+
+ function renderRunning() {
+ const timer = state.timer;
+ if (!timer) return;
+
+ const paused = Boolean(timer.paused_at);
+ els.runCard.dataset.paused = paused ? '1' : '';
+ els.runDot.style.background = colorForProject(timer.project_id);
+ els.runProject.textContent = decodeEntities(runningName(timer));
+ const parts = [timer.client, timer.task].filter(Boolean);
+ els.runSub.textContent = decodeEntities(parts.join(' · '));
+
+ els.recBadge.classList.toggle('is-paused', paused);
+ els.recBadge.innerHTML = paused
+ ? 'Paused'
+ : 'Rec';
+
+ els.btnPause.innerHTML = paused
+ ? `${icons.play}Resume`
+ : `${icons.pause}Pause`;
+ els.btnStop.innerHTML = `${icons.stop}Stop`;
+
+ updateClock();
+ }
+
+ function updateClock() {
+ const timer = state.timer;
+ if (!timer) return;
+ els.clock.textContent = formatClock(elapsedSecondsFromTimer(timer));
+
+ const meta = [`Started ${formatTimeOf(timer.start_time)}`];
+ const breakSeconds = Number(timer.break_seconds) || 0;
+ if (breakSeconds > 0) {
+ meta.push(
+ breakSeconds < 3600
+ ? `Break ${Math.round(breakSeconds / 60)} min`
+ : `Break ${formatHM(breakSeconds)} h`
+ );
+ }
+ if (state.todaySeconds > 0) {
+ meta.push(`Today ${formatHM(state.todaySeconds + elapsedSecondsFromTimer(timer))}`);
+ }
+ els.runMeta.textContent = meta.join(' · ');
+ }
+
+ function render() {
+ const running = Boolean(state.timer);
+ els.viewRunning.classList.toggle('hidden', !running);
+ els.viewIdle.classList.toggle('hidden', running);
+ if (running) {
+ resetNoteEditor();
+ renderRunning();
+ } else {
+ renderIdle();
+ }
+ }
+
+ // ── actions ────────────────────────────────────────────
+
+ function selectProject(project) {
+ state.selected.projectId = project.id;
+ state.selected.taskId = null;
+ renderSelection();
+ }
+
+ function clearSelection() {
+ state.selected.projectId = null;
+ state.selected.taskId = null;
+ renderSelection();
+ }
+
+ async function startTracking({ projectId, clientId = null, taskId = null, notes = '' } = {}) {
+ const project = state.projects.find((p) => p.id === projectId);
+ ctx.clearMessage();
+ els.btnStart.disabled = true;
+ try {
+ const result = await ctx.client.startTimer({
+ projectId,
+ clientId: clientId ?? project?.client_id ?? null,
+ taskId,
+ notes,
+ });
+ const timer = result?.timer;
+ if (timer) {
+ applyTimer(timer);
+ ctx.showMessage('Timer started.', 'success');
+ }
+ const name = timer?.project || project?.name || 'Timer';
+ chrome.storage.local.set({
+ last_project: { project_id: projectId, task_id: taskId || null, name },
+ });
+ ctx.refreshInsights();
+ ctx.refreshRecents();
+ } catch (error) {
+ await handleStartConflict(error);
+ } finally {
+ els.btnStart.disabled = !selectedProject();
+ }
+ }
+
+ /** When start conflicts with an existing timer, sync to that running timer. */
+ async function handleStartConflict(error) {
+ const isConflict =
+ error.status === 409 ||
+ error.code === 'CONFLICT' ||
+ error.code === 'timer_already_running';
+ if (isConflict) {
+ if (error.data?.timer) {
+ applyTimer(error.data.timer);
+ ctx.showMessage('A timer is already running.', 'success');
+ return;
+ }
+ try {
+ const status = await ctx.client.getTimerStatus();
+ if (status?.active && status?.timer) {
+ applyTimer(status.timer);
+ ctx.showMessage('A timer is already running.', 'success');
+ return;
+ }
+ } catch {
+ /* fall through */
+ }
+ }
+ ctx.showMessage(error.message || 'Could not start timer.');
+ }
+
+ function applyTimer(timer) {
+ state.timer = timer || null;
+ ctx.notifyBackground();
+ render();
+ }
+
+ els.btnStart.addEventListener('click', () => {
+ const project = selectedProject();
+ if (!project) return;
+ void startTracking({
+ projectId: project.id,
+ taskId: state.selected.taskId,
+ notes: els.notes.value.trim(),
+ });
+ });
+
+ els.notes.addEventListener('keydown', (event) => {
+ if (event.key === 'Enter') els.btnStart.click();
+ });
+
+ els.btnPause.addEventListener('click', async () => {
+ const timer = state.timer;
+ if (!timer) return;
+ const paused = Boolean(timer.paused_at);
+ els.btnPause.disabled = true;
+ ctx.clearMessage();
+ try {
+ const result = paused
+ ? await ctx.client.resumeTimer()
+ : await ctx.client.pauseTimer();
+ const updated = result?.time_entry || result?.timer;
+ if (updated) applyTimer(updated);
+ } catch (error) {
+ ctx.showMessage(error.message || 'Could not update timer.');
+ } finally {
+ els.btnPause.disabled = false;
+ }
+ });
+
+ els.btnStop.addEventListener('click', async () => {
+ if (!state.timer) return;
+ els.btnStop.disabled = true;
+ ctx.clearMessage();
+ try {
+ await ctx.client.stopTimer();
+ state.timer = null;
+ ctx.notifyBackground();
+ render();
+ ctx.refreshAll();
+ } catch (error) {
+ ctx.showMessage(error.message || 'Could not stop timer.');
+ } finally {
+ els.btnStop.disabled = false;
+ }
+ });
+
+ // ── quick note (updates the running entry) ─────────────
+
+ function resetNoteEditor() {
+ els.noteEditor.classList.add('hidden');
+ els.btnNote.classList.remove('hidden');
+ }
+
+ els.btnNote.innerHTML = `${icons.plus}Quick note`;
+ els.btnNote.addEventListener('click', () => {
+ els.btnNote.classList.add('hidden');
+ els.noteEditor.classList.remove('hidden');
+ els.quickNoteInput.value = state.timer?.notes || '';
+ els.quickNoteInput.focus();
+ });
+
+ els.quickNoteSave.innerHTML = icons['check-circle'];
+ els.quickNoteSave.addEventListener('click', saveQuickNote);
+ els.quickNoteInput.addEventListener('keydown', (event) => {
+ if (event.key === 'Enter') saveQuickNote();
+ });
+
+ async function saveQuickNote() {
+ const timer = state.timer;
+ if (!timer) return;
+ const notes = els.quickNoteInput.value.trim();
+ els.quickNoteSave.disabled = true;
+ try {
+ await ctx.client.updateTimeEntry(timer.id, { notes });
+ state.timer = { ...timer, notes };
+ resetNoteEditor();
+ els.btnNote.innerHTML = `${icons.plus}Saved`;
+ clearTimeout(noteSaveTimer);
+ noteSaveTimer = setTimeout(() => {
+ els.btnNote.innerHTML = `${icons.plus}Quick note`;
+ }, 1500);
+ } catch (error) {
+ ctx.showMessage(error.message || 'Could not save note.');
+ } finally {
+ els.quickNoteSave.disabled = false;
+ }
+ }
+
+ // ── task menu ──────────────────────────────────────────
+
+ els.selTaskChevron.innerHTML = icons['chevron-right'];
+ els.selClear.innerHTML = icons.x;
+
+ els.selClear.addEventListener('click', clearSelection);
+
+ els.selTask.addEventListener('click', async () => {
+ const project = selectedProject();
+ if (!project) return;
+ if (!els.taskMenu.classList.contains('hidden')) {
+ els.taskMenu.classList.add('hidden');
+ els.selTaskChevron.innerHTML = icons['chevron-right'];
+ return;
+ }
+ els.selTaskChevron.innerHTML = icons['chevron-down'];
+ await openTaskMenu(project);
+ });
+
+ async function openTaskMenu(project) {
+ const tasks = await loadTasks(project.id);
+ renderTaskMenu(project, tasks, '');
+ }
+
+ async function loadTasks(projectId) {
+ if (taskCache.has(projectId)) return taskCache.get(projectId);
+ const generation = ++taskLoadGeneration;
+ try {
+ const data = await ctx.client.getTasks({
+ project_id: projectId,
+ status: 'open',
+ per_page: 200,
+ });
+ if (generation !== taskLoadGeneration) return taskCache.get(projectId) || [];
+ const byId = new Map();
+ for (const t of data?.tasks || []) {
+ if (!t || t.id == null) continue;
+ if (!t.status || CLOSED_TASK_STATUSES.has(t.status)) continue;
+ if (!byId.has(t.id)) byId.set(t.id, t);
+ }
+ const tasks = Array.from(byId.values()).sort((a, b) =>
+ String(a.name).localeCompare(String(b.name))
+ );
+ taskCache.set(projectId, tasks);
+ return tasks;
+ } catch (error) {
+ console.warn('Failed to load tasks', error);
+ return [];
+ }
+ }
+
+ function renderTaskMenu(project, tasks, filter) {
+ els.taskMenu.innerHTML = '';
+ els.taskMenu.classList.remove('hidden');
+
+ const search = document.createElement('div');
+ search.className = 'field task-search';
+ search.innerHTML = '';
+ const searchInput = search.querySelector('input');
+ searchInput.addEventListener('input', () => {
+ renderTaskList(project, tasks, searchInput.value);
+ });
+ searchInput.addEventListener('keydown', (event) => {
+ if (event.key === 'Enter') {
+ event.preventDefault();
+ createTaskIfMissing(project, tasks, searchInput.value);
+ }
+ if (event.key === 'Escape') closeTaskMenu();
+ });
+ els.taskMenu.appendChild(search);
+
+ const list = document.createElement('div');
+ list.setAttribute('role', 'listbox');
+ els.taskMenu.appendChild(list);
+ els.taskMenu._list = list;
+
+ renderTaskList(project, tasks, filter);
+ searchInput.focus();
+ }
+
+ function renderTaskList(project, tasks, filter) {
+ const list = els.taskMenu._list;
+ if (!list) return;
+ const q = String(filter || '').trim().toLowerCase();
+ const visible = q ? tasks.filter((t) => String(t.name).toLowerCase().includes(q)) : tasks;
+
+ list.innerHTML = '';
+ const none = document.createElement('button');
+ none.type = 'button';
+ none.className = 'result';
+ none.innerHTML = `No task${state.selected.taskId == null ? '✓' : ''}`;
+ none.addEventListener('mousedown', (event) => {
+ event.preventDefault();
+ state.selected.taskId = null;
+ closeTaskMenu();
+ renderSelection();
+ });
+ list.appendChild(none);
+
+ for (const t of visible) {
+ const row = document.createElement('button');
+ row.type = 'button';
+ row.className = 'result';
+ const selected = state.selected.taskId === t.id;
+ const status = t.status && t.status !== 'todo' ? ` (${t.status})` : '';
+ row.innerHTML = `${displayHtml(t.name)}${displayHtml(status)}${selected ? '✓' : ''}`;
+ row.addEventListener('mousedown', (event) => {
+ event.preventDefault();
+ state.selected.taskId = t.id;
+ closeTaskMenu();
+ renderSelection();
+ });
+ list.appendChild(row);
+ }
+
+ const exact = tasks.some((t) => String(t.name).toLowerCase() === q);
+ if (q.length >= 2 && !exact) {
+ const create = document.createElement('button');
+ create.type = 'button';
+ create.className = 'result result-create';
+ create.innerHTML = `${icons.plus}Create task “${displayHtml(q)}”`;
+ create.addEventListener('mousedown', (event) => {
+ event.preventDefault();
+ void createTask(project, q);
+ });
+ list.appendChild(create);
+ }
+ }
+
+ async function createTaskIfMissing(project, tasks, value) {
+ const q = String(value || '').trim();
+ if (q.length < 2) return;
+ const exact = tasks.some((t) => String(t.name).toLowerCase() === q.toLowerCase());
+ if (!exact) await createTask(project, q);
+ }
+
+ async function createTask(project, name) {
+ try {
+ const result = await ctx.client.createTask({ name, projectId: project.id });
+ const created = result?.task || result;
+ const task = created?.id
+ ? { id: created.id, name: created.name || name, status: created.status || 'todo' }
+ : null;
+ if (task) {
+ taskCache.set(project.id, [...(taskCache.get(project.id) || []), task]);
+ state.selected.taskId = task.id;
+ }
+ closeTaskMenu();
+ renderSelection();
+ ctx.showMessage('Task created.', 'success');
+ } catch (error) {
+ ctx.showMessage(error.message || 'Could not create task.');
+ }
+ }
+
+ function closeTaskMenu() {
+ els.taskMenu.classList.add('hidden');
+ els.taskMenu.innerHTML = '';
+ els.selTaskChevron.innerHTML = icons['chevron-right'];
+ }
+
+ // ── search ─────────────────────────────────────────────
+
+ searchHandle = createProjectSearch({
+ wrapEl: el('search-wrap'),
+ input: els.searchInput,
+ listEl: el('search-results'),
+ getProjects: () => state.projects,
+ onSelect: selectProject,
+ onCreate: (name) => void createProjectInline(name),
+ });
+
+ async function createProjectInline(name) {
+ try {
+ const result = await ctx.client.createProject({ name });
+ const created = result?.project || result;
+ ctx.showMessage('Project created.', 'success');
+ await ctx.loadDirectory(created?.id ?? null);
+ const project = state.projects.find((p) => p.id === created?.id);
+ if (project) selectProject(project);
+ else if (created?.id) selectProject({ id: created.id, name: created.name || name });
+ } catch (error) {
+ ctx.showMessage(error.message || 'Could not create project.');
+ }
+ }
+
+ // ── tick + external updates ────────────────────────────
+
+ setInterval(() => {
+ if (state.timer && !els.viewRunning.classList.contains('hidden')) updateClock();
+ }, 1000);
+
+ return {
+ render,
+ renderIdle,
+ applyTimer,
+ startTracking,
+ renderNoteSuggestions,
+ };
+}
+
diff --git a/browser-extension/styles/tokens.css b/browser-extension/styles/tokens.css
new file mode 100644
index 000000000..f33039f76
--- /dev/null
+++ b/browser-extension/styles/tokens.css
@@ -0,0 +1,360 @@
+/**
+ * TimeTracker extension design tokens + shared components.
+ * Apple-inspired: system font stack, hairline borders, soft shadows,
+ * auto light/dark via prefers-color-scheme.
+ */
+
+:root {
+ color-scheme: light dark;
+
+ --tt-font: -apple-system, BlinkMacSystemFont, "SF Pro Text", "Helvetica Neue",
+ "Segoe UI", Roboto, Inter, Arial, sans-serif;
+
+ --tt-bg: #f5f5f7;
+ --tt-surface: #ffffff;
+ --tt-surface-2: #e9e9eb;
+ --tt-ink: #1d1d1f;
+ --tt-ink-2: #6e6e73;
+ --tt-ink-3: #c7c7cc;
+ --tt-line: rgba(0, 0, 0, 0.08);
+ --tt-accent: #0071e3;
+ --tt-accent-soft: rgba(0, 113, 227, 0.1);
+ --tt-red: #ff3b30;
+ --tt-red-soft: rgba(255, 59, 48, 0.12);
+ --tt-green: #34c759;
+ --tt-orange: #ff9500;
+ --tt-orange-soft: rgba(255, 149, 0, 0.12);
+ --tt-gray-badge: #8e8e93;
+ --tt-bar: #d6d6db;
+ --tt-shadow: 0 1px 3px rgba(0, 0, 0, 0.06), 0 8px 24px -12px rgba(0, 0, 0, 0.18);
+}
+
+@media (prefers-color-scheme: dark) {
+ :root {
+ --tt-bg: #111113;
+ --tt-surface: #1c1c1e;
+ --tt-surface-2: #2c2c2e;
+ --tt-ink: #f5f5f7;
+ --tt-ink-2: #a1a1a6;
+ --tt-ink-3: #48484a;
+ --tt-line: rgba(255, 255, 255, 0.1);
+ --tt-accent: #0a84ff;
+ --tt-accent-soft: rgba(10, 132, 255, 0.18);
+ --tt-red: #ff453a;
+ --tt-red-soft: rgba(255, 69, 58, 0.2);
+ --tt-green: #30d158;
+ --tt-orange: #ff9f0a;
+ --tt-orange-soft: rgba(255, 159, 10, 0.2);
+ --tt-gray-badge: #636366;
+ --tt-bar: #3a3a3c;
+ --tt-shadow: 0 1px 3px rgba(0, 0, 0, 0.4), 0 8px 24px -12px rgba(0, 0, 0, 0.6);
+ }
+}
+
+* {
+ box-sizing: border-box;
+}
+
+body {
+ margin: 0;
+ font-family: var(--tt-font);
+ color: var(--tt-ink);
+ background: var(--tt-bg);
+ font-size: 13px;
+ -webkit-font-smoothing: antialiased;
+}
+
+button {
+ font-family: inherit;
+ color: inherit;
+ background: none;
+ border: none;
+ padding: 0;
+ cursor: pointer;
+}
+
+input,
+textarea,
+select {
+ font-family: inherit;
+}
+
+kbd {
+ font-size: 11px;
+ background: var(--tt-surface-2);
+ color: var(--tt-ink-2);
+ border-radius: 5px;
+ padding: 1px 6px;
+ box-shadow: inset 0 -1px 0 var(--tt-line);
+}
+
+.hidden {
+ display: none !important;
+}
+
+[hidden] {
+ display: none !important;
+}
+
+/* ── surfaces ─────────────────────────────────────────── */
+
+.card {
+ background: var(--tt-surface);
+ border: 1px solid var(--tt-line);
+ border-radius: 12px;
+ padding: 12px;
+}
+
+/* ── buttons ──────────────────────────────────────────── */
+
+.cta {
+ display: flex;
+ width: 100%;
+ height: 42px;
+ align-items: center;
+ justify-content: center;
+ gap: 7px;
+ background: var(--tt-accent);
+ color: #fff;
+ border-radius: 12px;
+ font-size: 14px;
+ font-weight: 600;
+ transition: filter 0.15s ease, transform 0.06s ease;
+}
+
+.cta:hover { filter: brightness(1.07); }
+.cta:active { transform: scale(0.98); }
+.cta:disabled { opacity: 0.55; cursor: default; }
+
+.btn {
+ flex: 1;
+ height: 40px;
+ border-radius: 11px;
+ font-size: 13px;
+ font-weight: 600;
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ gap: 6px;
+ transition: filter 0.15s ease, transform 0.06s ease, background 0.15s ease;
+}
+
+.btn:active { transform: scale(0.97); }
+.btn:disabled { opacity: 0.55; cursor: default; }
+
+.btn-sec { background: var(--tt-surface-2); color: var(--tt-ink); }
+.btn-sec:hover { filter: brightness(0.97); }
+
+.btn-danger { background: var(--tt-red); color: #fff; }
+.btn-danger:hover { filter: brightness(1.06); }
+
+.btn-ghost {
+ width: 100%;
+ height: 36px;
+ border-radius: 10px;
+ flex: none;
+ color: var(--tt-accent);
+ font-size: 12.5px;
+ font-weight: 600;
+ background: var(--tt-accent-soft);
+}
+
+.iconbtn {
+ width: 28px;
+ height: 28px;
+ border-radius: 8px;
+ display: inline-grid;
+ place-items: center;
+ color: var(--tt-ink-2);
+ transition: background 0.15s ease, color 0.15s ease;
+}
+
+.iconbtn:hover { background: var(--tt-surface-2); color: var(--tt-ink); }
+
+.iconbtn svg,
+.cta svg,
+.btn svg {
+ width: 16px;
+ height: 16px;
+ flex: none;
+}
+
+.link {
+ display: inline-flex;
+ align-items: center;
+ gap: 4px;
+ color: var(--tt-accent);
+ font-size: 12.5px;
+ font-weight: 600;
+}
+
+.link svg { width: 13px; height: 13px; }
+
+/* ── fields ───────────────────────────────────────────── */
+
+.field {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ background: var(--tt-surface);
+ border: 1px solid var(--tt-line);
+ border-radius: 12px;
+ padding: 10px 12px;
+ color: var(--tt-ink-2);
+ transition: box-shadow 0.15s ease, border-color 0.15s ease;
+}
+
+.field:focus-within {
+ border-color: var(--tt-accent);
+ box-shadow: 0 0 0 3px var(--tt-accent-soft);
+}
+
+.field input {
+ flex: 1;
+ min-width: 0;
+ border: none;
+ outline: none;
+ background: transparent;
+ color: var(--tt-ink);
+ font-size: 13.5px;
+}
+
+.field input::placeholder { color: var(--tt-ink-3); }
+
+/* ── chips ────────────────────────────────────────────── */
+
+.chiprow {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 6px;
+ align-items: center;
+}
+
+.chiprow-label {
+ flex-basis: 100%;
+ font-size: 11px;
+ font-weight: 600;
+ color: var(--tt-ink-2);
+ text-transform: uppercase;
+ letter-spacing: 0.05em;
+}
+
+.chip {
+ display: inline-flex;
+ align-items: center;
+ gap: 6px;
+ padding: 6px 11px;
+ border-radius: 999px;
+ background: var(--tt-surface);
+ border: 1px solid var(--tt-line);
+ font-size: 12px;
+ font-weight: 500;
+ color: var(--tt-ink);
+ transition: background 0.15s ease, border-color 0.15s ease;
+}
+
+.chip:hover { background: var(--tt-surface-2); }
+.chip svg { width: 12px; height: 12px; }
+.chip.sm { padding: 4px 9px; font-size: 11px; color: var(--tt-ink-2); }
+
+.chip.is-selected {
+ background: var(--tt-accent-soft);
+ border-color: transparent;
+ color: var(--tt-accent);
+ font-weight: 600;
+}
+
+.pdot {
+ width: 8px;
+ height: 8px;
+ border-radius: 50%;
+ flex: none;
+}
+
+.pdot.big { width: 11px; height: 11px; }
+
+/* ── meters ───────────────────────────────────────────── */
+
+.track {
+ height: 6px;
+ border-radius: 999px;
+ background: var(--tt-surface-2);
+ overflow: hidden;
+}
+
+.track > .fill {
+ height: 100%;
+ border-radius: 999px;
+ background: var(--tt-accent);
+ transition: width 0.4s ease;
+}
+
+/* ── iOS switch ───────────────────────────────────────── */
+
+.switch {
+ position: relative;
+ width: 44px;
+ height: 26px;
+ flex: none;
+ display: inline-block;
+}
+
+.switch input {
+ position: absolute;
+ inset: 0;
+ width: 100%;
+ height: 100%;
+ margin: 0;
+ opacity: 0;
+}
+
+.switch .knob {
+ position: absolute;
+ inset: 0;
+ border-radius: 999px;
+ background: var(--tt-surface-2);
+ transition: background 0.2s ease;
+}
+
+.switch .knob::after {
+ content: "";
+ position: absolute;
+ top: 2px;
+ left: 2px;
+ width: 22px;
+ height: 22px;
+ border-radius: 50%;
+ background: #fff;
+ box-shadow: 0 1px 3px rgba(0, 0, 0, 0.3);
+ transition: left 0.2s ease;
+}
+
+.switch input:checked + .knob { background: var(--tt-green); }
+.switch input:checked + .knob::after { left: 20px; }
+
+/* ── toast ────────────────────────────────────────────── */
+
+.toast {
+ border-radius: 10px;
+ padding: 8px 11px;
+ font-size: 12px;
+ font-weight: 500;
+ margin-bottom: 10px;
+}
+
+.toast.error {
+ color: var(--tt-red);
+ background: var(--tt-red-soft);
+}
+
+.toast.success {
+ color: var(--tt-green);
+ background: rgba(52, 199, 89, 0.14);
+}
+
+.hint {
+ margin: 0;
+ text-align: center;
+ font-size: 11px;
+ color: var(--tt-ink-3);
+}
diff --git a/browser-extension/test/api.test.js b/browser-extension/test/api.test.js
index 9a1a90ae3..71c61095a 100644
--- a/browser-extension/test/api.test.js
+++ b/browser-extension/test/api.test.js
@@ -63,3 +63,76 @@ test('ApiClient.stopTimer posts stop_time when provided', async () => {
delete globalThis.fetch;
});
+
+test('getSummary builds date-range query', async () => {
+ const calls = [];
+ globalThis.fetch = async (url, init) => {
+ calls.push({ url, init });
+ return { ok: true, text: async () => JSON.stringify({ summary: {} }) };
+ };
+
+ const client = new ApiClient('https://tracker.test', 'tt_demo');
+ await client.getSummary({ startDate: '2026-10-13', endDate: '2026-10-13' });
+
+ assert.equal(calls[0].url, 'https://tracker.test/api/v1/reports/summary?start_date=2026-10-13&end_date=2026-10-13');
+
+ delete globalThis.fetch;
+});
+
+test('getTimeEntries passes range, paging and include_active flags', async () => {
+ const calls = [];
+ globalThis.fetch = async (url, init) => {
+ calls.push({ url, init });
+ return { ok: true, text: async () => JSON.stringify({ time_entries: [] }) };
+ };
+
+ const client = new ApiClient('https://tracker.test', 'tt_demo');
+ await client.getTimeEntries({
+ startDate: '2026-10-07',
+ endDate: '2026-10-13',
+ perPage: 200,
+ includeActive: true,
+ });
+
+ const url = new URL(calls[0].url);
+ assert.equal(url.pathname, '/api/v1/time-entries');
+ assert.equal(url.searchParams.get('start_date'), '2026-10-07');
+ assert.equal(url.searchParams.get('end_date'), '2026-10-13');
+ assert.equal(url.searchParams.get('per_page'), '200');
+ assert.equal(url.searchParams.get('include_active'), 'true');
+
+ delete globalThis.fetch;
+});
+
+test('getWeeklyGoals forwards status filter', async () => {
+ const calls = [];
+ globalThis.fetch = async (url, init) => {
+ calls.push({ url, init });
+ return { ok: true, text: async () => JSON.stringify({ weekly_goals: [] }) };
+ };
+
+ const client = new ApiClient('https://tracker.test', 'tt_demo');
+ await client.getWeeklyGoals({ status: 'active' });
+
+ assert.equal(calls[0].url, 'https://tracker.test/api/v1/weekly-goals?status=active');
+
+ delete globalThis.fetch;
+});
+
+test('updateTimeEntry PATCHes partial fields on the entry', async () => {
+ const calls = [];
+ globalThis.fetch = async (url, init) => {
+ calls.push({ url, init });
+ return { ok: true, text: async () => JSON.stringify({ time_entry: { id: 501 } }) };
+ };
+
+ const client = new ApiClient('https://tracker.test', 'tt_demo');
+ await client.updateTimeEntry(501, { notes: 'Design review — updated' });
+
+ assert.equal(calls[0].url, 'https://tracker.test/api/v1/time-entries/501');
+ assert.equal(calls[0].init.method, 'PATCH');
+ const body = JSON.parse(calls[0].init.body);
+ assert.equal(body.notes, 'Design review — updated');
+
+ delete globalThis.fetch;
+});
diff --git a/browser-extension/test/helpers.test.js b/browser-extension/test/helpers.test.js
new file mode 100644
index 000000000..1885ebdb2
--- /dev/null
+++ b/browser-extension/test/helpers.test.js
@@ -0,0 +1,116 @@
+import test from 'node:test';
+import assert from 'node:assert/strict';
+import {
+ colorForProject,
+ dailyTotals,
+ entryDurationSeconds,
+ formatClock,
+ formatHM,
+ groupEntriesByDay,
+ localDateKey,
+ noteSuggestions,
+ weekdayLetter,
+ addDays,
+} from '../lib/format.js';
+
+test('formatClock renders h:mm:ss above one hour and m:ss below', () => {
+ assert.equal(formatClock(5077), '1:24:37');
+ assert.equal(formatClock(94), '1:34');
+ assert.equal(formatClock(0), '0:00');
+ assert.equal(formatClock(-5), '0:00');
+});
+
+test('formatHM renders hours and minutes', () => {
+ assert.equal(formatHM(5 * 3600 + 12 * 60), '5:12');
+ assert.equal(formatHM(0), '0:00');
+});
+
+test('colorForProject is stable per id and valid', () => {
+ assert.equal(colorForProject(1), colorForProject(1));
+ assert.equal(colorForProject(1), colorForProject(9));
+ assert.match(colorForProject(2), /^#[0-9a-f]{6}$/);
+ assert.equal(colorForProject(undefined), colorForProject('not-a-number'));
+});
+
+test('localDateKey and addDays work on local dates', () => {
+ const d = new Date(2026, 9, 13); // Oct 13 2026 local
+ assert.equal(localDateKey(d), '2026-10-13');
+ assert.equal(localDateKey(addDays(d, -1)), '2026-10-12');
+ assert.equal(weekdayLetter(d), 'T');
+});
+
+test('entryDurationSeconds computes live elapsed for active entries', () => {
+ const now = Date.parse('2026-10-13T14:26:37');
+ const active = {
+ is_active: true,
+ start_time: '2026-10-13T13:02:00',
+ paused_at: null,
+ break_seconds: 0,
+ };
+ assert.equal(entryDurationSeconds(active, now), 5077);
+
+ const paused = { ...active, paused_at: '2026-10-13T14:00:00' };
+ assert.equal(entryDurationSeconds(paused, now), 3480);
+
+ const withBreak = { ...active, break_seconds: 60 };
+ assert.equal(entryDurationSeconds(withBreak, now), 5017);
+
+ const finished = {
+ is_active: false,
+ start_time: '2026-10-13T08:00:00',
+ end_time: '2026-10-13T09:00:00',
+ duration_seconds: 3600,
+ break_seconds: 0,
+ };
+ assert.equal(entryDurationSeconds(finished, now), 3600);
+});
+
+test('groupEntriesByDay buckets Today, Yesterday and weekday labels', () => {
+ const now = new Date(2026, 9, 13, 18, 0); // Tuesday
+ const entries = [
+ { start_time: '2026-10-13T08:30:00' },
+ { start_time: '2026-10-12T10:00:00' },
+ { start_time: '2026-10-09T09:00:00' },
+ { start_time: '2026-10-13T13:02:00' },
+ { start_time: 'bogus' },
+ ];
+ const groups = groupEntriesByDay(entries, now);
+ assert.deepEqual(
+ groups.map((g) => g.label),
+ ['Today', 'Yesterday', 'Friday']
+ );
+ assert.equal(groups[0].entries.length, 2);
+});
+
+test('dailyTotals returns exactly 7 buckets oldest-first and skips unknown days', () => {
+ const now = new Date(2026, 9, 13, 18, 0);
+ const entries = [
+ { start_time: '2026-10-13T08:30:00', end_time: '2026-10-13T09:00:00', duration_seconds: 1800, is_active: false },
+ { start_time: '2026-10-12T10:00:00', end_time: '2026-10-12T12:00:00', duration_seconds: 7200, is_active: false },
+ ];
+ const daily = dailyTotals(entries, 7, now);
+ assert.equal(daily.length, 7);
+ assert.equal(daily[6].hours > 0, true);
+ assert.equal(daily[5].hours > 0, true);
+ assert.equal(daily[0].hours, 0);
+ assert.equal(daily[6].letter, 'T');
+});
+
+test('noteSuggestions dedupes and respects limit', () => {
+ const entries = [
+ { notes: 'Design review' },
+ { notes: ' design review ' },
+ { notes: 'Sprint planning' },
+ { notes: '' },
+ { notes: 'ok' },
+ { notes: 'Code review' },
+ ];
+ assert.deepEqual(noteSuggestions(entries, 3), ['Design review', 'Sprint planning', 'Code review']);
+});
+
+test('formatBadgeText stays compact for the toolbar', async () => {
+ const { formatBadgeText } = await import('../lib/api.js');
+ assert.equal(formatBadgeText(5077), '1:24');
+ assert.equal(formatBadgeText(600), ':10');
+ assert.equal(formatBadgeText(3600 * 12), '12h');
+});