From 202ac0e0c6d672d9b3e27a98cb0ebf6e6b1f9c54 Mon Sep 17 00:00:00 2001 From: Giorgio Date: Wed, 14 Oct 2026 11:41:20 +0200 Subject: [PATCH] feat(browser-extension): redesign popup with Insights, Recents, and keyboard control MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Complete rewrite of the browser extension UI and capabilities: - three-tab popup (Timer / Insights / Recents) in a shared Apple-style design system (styles/tokens.css, auto light/dark) - Timer tab: unified project/client search with keyboard navigation, recent + favorites chips, task dropdown with inline create, note suggestions from recent entries, live running clock with pause/stop and quick-note (PATCH on the running entry) - Insights tab: today/billable/entry stats, weekly goal, 7-day chart, top projects (reports/summary, time-entries, weekly-goals — degrades gracefully without read:reports/read:users) - Recents tab: last 7 days grouped by day with one-click track-again - global shortcuts (start/stop Ctrl+Shift+S, popup Ctrl+Shift+T) and a context-menu action to track selected text using the last project - options page: connect (password+2FA or token), read-only display of the server-managed idle policy (idle_timeout_minutes / idle_unanswered_action from timer/status), badge preference - toolbar icons regenerated as the gradient app glyph (running variant with recording dot); MV3 manifest 5.19.0 adds commands + contextMenus - dev/mock.js browser preview shim (no-op in production); tests updated (node --test), README rewritten --- browser-extension/README.md | 100 ++-- browser-extension/background.js | 127 ++++- browser-extension/build/zip.js | 12 +- browser-extension/dev/generate-icons.mjs | 86 ++++ browser-extension/dev/mock.js | 297 ++++++++++++ browser-extension/icons/idle-128.png | Bin 659 -> 10211 bytes browser-extension/icons/idle-16.png | Bin 138 -> 901 bytes browser-extension/icons/idle-32.png | Bin 198 -> 1713 bytes browser-extension/icons/idle-48.png | Bin 258 -> 2577 bytes browser-extension/icons/running-128.png | Bin 645 -> 10157 bytes browser-extension/icons/running-16.png | Bin 135 -> 957 bytes browser-extension/icons/running-32.png | Bin 194 -> 1830 bytes browser-extension/icons/running-48.png | Bin 252 -> 2897 bytes browser-extension/lib/api.js | 44 ++ browser-extension/lib/format.js | 149 ++++++ browser-extension/lib/icons.js | 39 ++ browser-extension/lib/picker.js | 196 -------- browser-extension/manifest.json | 25 +- browser-extension/options.html | 55 --- browser-extension/options.js | 186 ------- browser-extension/options/options.css | 209 ++++++++ browser-extension/options/options.html | 147 ++++++ browser-extension/options/options.js | 308 ++++++++++++ browser-extension/package.json | 2 +- browser-extension/popup.css | 440 ----------------- browser-extension/popup.html | 71 --- browser-extension/popup.js | 552 --------------------- browser-extension/popup/insights-tab.js | 139 ++++++ browser-extension/popup/main.js | 324 +++++++++++++ browser-extension/popup/popup.css | 592 +++++++++++++++++++++++ browser-extension/popup/popup.html | 159 ++++++ browser-extension/popup/recents-tab.js | 100 ++++ browser-extension/popup/search.js | 141 ++++++ browser-extension/popup/text.js | 28 ++ browser-extension/popup/timer-tab.js | 578 ++++++++++++++++++++++ browser-extension/styles/tokens.css | 360 ++++++++++++++ browser-extension/test/api.test.js | 73 +++ browser-extension/test/helpers.test.js | 116 +++++ 38 files changed, 4107 insertions(+), 1548 deletions(-) create mode 100644 browser-extension/dev/generate-icons.mjs create mode 100644 browser-extension/dev/mock.js create mode 100644 browser-extension/lib/format.js create mode 100644 browser-extension/lib/icons.js delete mode 100644 browser-extension/lib/picker.js delete mode 100644 browser-extension/options.html delete mode 100644 browser-extension/options.js create mode 100644 browser-extension/options/options.css create mode 100644 browser-extension/options/options.html create mode 100644 browser-extension/options/options.js delete mode 100644 browser-extension/popup.css delete mode 100644 browser-extension/popup.html delete mode 100644 browser-extension/popup.js create mode 100644 browser-extension/popup/insights-tab.js create mode 100644 browser-extension/popup/main.js create mode 100644 browser-extension/popup/popup.css create mode 100644 browser-extension/popup/popup.html create mode 100644 browser-extension/popup/recents-tab.js create mode 100644 browser-extension/popup/search.js create mode 100644 browser-extension/popup/text.js create mode 100644 browser-extension/popup/timer-tab.js create mode 100644 browser-extension/styles/tokens.css create mode 100644 browser-extension/test/helpers.test.js 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 `
` + + `${dot}
`; +} + +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 ec5cabed21c9827ec563c5850df008c4c881657d..caa7c06bbcb143335bac9677153767e18a1901ae 100644 GIT binary patch literal 10211 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

P)ART8 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 delta 171 zcmV;c096064aNbGBYyymNklQh@i zg8*dC*a5PA!2Sjozl(l%>(i;u|` ZsRxJ~{G3^IxHkX*002ovPDHLkV1f>xNuvM& diff --git a/browser-extension/icons/idle-48.png b/browser-extension/icons/idle-48.png index ef99a2dfb2167e6c4316687236bcd97cdccc6958..385238efe81bcf396303b1680e5174bd9b367e35 100644 GIT binary patch delta 2569 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 diff --git a/browser-extension/icons/running-128.png b/browser-extension/icons/running-128.png index 1ad80fd4f80f8950f7d6b38a59f2f8b22352f0f3..5cdd21b93c6cf5559cdb66976239f28ccd0a1bfd 100644 GIT binary patch literal 10157 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 bK~w_(Gf(*z9*`M%00000NkvXXu0mjfGTf?v delta 621 zcmZ4M-^w~cxt__#)5S5Q;?~={jz!G|0xS-DkNvL?`1fc|kv}J+^={kHeaXJwhZ^2) zb%BGZr_VqC{P#R{3EPxCzl{ztd$&8jG|-S`JKDUULO_JS>5*fA9m~(7|5;m>J)O_R zqWIKal!I&P|1yP&m4C7t6EZ$3*o4$YDOj*5*gmdj^wF!gX?l5de$#>)j(w-vAFKva zx{N-))(lrqukYB%+te~MzSK0UQ6cf>`aQC)%pNyCub$t3M2R86pkayezrOS0E{q-$ zceeDmr~NNrSg+)<`^Mywz*RO4M^@LyGVWQ^{B^WWxXKR}NKZPN6TjQ7GdzRI3iZ&c(h>>DrX!!EC zUfki)M`i{F26cuda`p@i7L0qi6#o8T++oll%fNgg=O;Tu0}q2A!|Zt+ObiXW2gHC% z?bw(m{F$sjw}0=Ms|Q}a{AYdS9jgg1P=cYs{m>MC28MbDhJfF@887Ah-_AATF9Sn@ zw8Y*&(X40cfGQGdeljp@*kzM^ec_*ucn~F9<8)`CYGc78x-E|ubEOW+_c>APT`yEq<0c0tR3#u&dDx%CwFIVJy-pw ztxsw>y4oFnDrm^DK;yajQeuD=kC%McVUNxZDDYVShpBJ*^`_bL=Qe|U>FMg{vd$@? F2>^9p3d;Zh diff --git a/browser-extension/icons/running-16.png b/browser-extension/icons/running-16.png index d669f67af5bb952aa5bed76c0b7110a161dd10be..a7320f6a81e12a1c8932811221856e188353501b 100644 GIT binary patch delta 936 zcmV;Z16TZq0lf#1BYy!WX+uL$UjP7joRyG2OT$1I#($QAf2f1#=#a6CQWd(WOBIVw zQY%=U(lkwlCWa)X2yT7>KZM}yC_-lk1wn8WKY}>8#wQ7qE**T2_wIMkJ>1>!6a1&w;mIBA_4)1ORfq+wd2&*YHn=RNbjt?>Vt?bxBE^?ys*3G}$GY>JnhjC%>U zGrmfA82OR-CgGsj*b(1~%gz|L$Jp|N#Q!Yba{B`-|2HMuJ3i97r(9;2b3%Ojm|S9T z$cE;+b}j=V@lcM4o+zZDlbWbWAcrTVi!CPUU-rcH2W*exIDX8vgAv?UVeTQ<%A2rI zf~S{U%X{jZfq$7wMP;Geh3`9Lcw6;1I8jfnSrR?j!7OSFlDy&ic z1t647`?P||JOBU!KS@MERCt_RQ$1)DK@|RGcF%ZcToDuSuMvZRfC_Fv2#F%faS*{y zw9!~eEG)%94u!L{N+7*W2!fqh9O@NkX)G*aK=2QfLVu%%OU{Mt?o8gB*}2PR;O)%Y zdGCAQ`xzg!Myu^m3sj+X8Cn&g0io!lHG~l|QtqrH((6cdtufQTC^F|J85h|AMl68f zgR%gbv(crsi##pHd_|E3N2Lq?@1i;&D0l7xXEsGTRDEG+(*?4XQ9jfwNi4v6OXF`V zHIbPW+kdi+gf2^)v!w>5a#Snf9#n>V@~k)|R}ZC&(&m?!V6VXBcoBogeG(6Mua*!H zVX2+yOaNc2F1o9B?bbfbzlrc=B{g>^k9c@|C%}W5b<&j$l=(^5CRgd<0Ut}>6TDxJ zaemOp$WQ?vmbdWfYl84pq00hR{GEMf7X1eVet-V5k$VaP=g7&Ys*Q&VH_8XGwz`3Z zw+i)U+cD_&m=V9NrZ{`TH#xq17a>kGCPvy&Pak6K+65G5r!a9bLS?j+F;TiRWxRj> z5o3#-ZeA&&ml|;TSPV7y1o6^G(`DXM(yw2Z83WkJ!c~{_+2@T4J8(7>1009604KZM}yC_-lk1wn8WKY}>8#wQ7qE**T2_wIMkJ>1>!6a1&w;mIBA_4)1ORfq+wd2&*YHn=RNbjt?>Vt?bxBE^?ys*3G}$GY>JnhjC%>U zGrmfA82OR-CgGsj*b(1~%gz|L$Jp|N#Q!Yba{B`-|2HMuJ3i97r(9;2b3%Ojm|S9T z$cE;+b}j=V@lcM4o+zZDlbWbWAcrTVi!CPUU-rcH2W*exIDX8vgAv?UVeTQ<%A2rI zf~S{U%X{jZfq$7wMP;Geh3`9Lcw6;1I8jfnSrR?j!7OSFlDy&ic z1t647`?P||JOBU%s7XXYRCt`6S9@#}MHv6h-rn_XX$6|1v>FK}$cPzeF$FGDa8wMf7k0Z9}Ad0P_DXn&9pL#jzhHBz*-G#1*s-d%fl zJI?Iw&VI9ZM=M{lH?MEL_v6L{f%GQXVKg* zAj@K*yD_E$z(RtvWQ}S2+(M8Ozj22M_xYUreC}F<69HYpxla+Ugi^5)WIWJpGO$Mb zGCWXorLo*&YN5|PT&7O-Gp5b8^G1_fyf#B4pMT~c8qt_QhUuZl0vYufQh2q(0>bx> zMw;gEG@{yu#Es1_6=GZwv6(o&9kKBK7ZD^9evRFXVOqL6U(lqQjLql3!xaXqCI&#^ zMsG^9fIn?>Wf!a89On0u=Tj;TOezo9^PYc`_@g_eWd%(~|D58P8JfG+$l>$n=H%h| zxqo?haJ+7Tzxz`7>QW5fT#PA8F;XqF#*P#~1rJCdfvm~6^_61Gs|%u2X#4SrD1Prw z*@S8)nRsSa2(K+K#>|=^wthOO(IY-r`))R{`b`6TEVcdZmy57)W(fPwL~-D3%-$;% zfhe*FktnydUA?FftCtj^?MfUw4h-r+=YKOjb1ZH!lIKj5&Xab*qamC5!3%N0Jg)FI zuPVXDl_fSIxo$lj#o_Z&EOp*4G8+PgkOL7btJ=|u78yb z?paRa{CI5`k4+6BXn1&r&l>WMVUJlfmVR~T<4XitmX2~iUMj^gu9zWTHgIyMTj0t& z<)wyx$I9eoWb-+lCz9z^UNF;AN7`bTSQfyxSH~eg{;Rsr{jmPm_75HRBA(l53 zIhp3gh5SsaF}T<17^w%Bu8rW?`g}~QGI9Mz0yJ|jUL8g}eh0%N91n=%w4*76(yq%G z`uZS}*MGC|il$D-@+B*_fn8G3H|>p}XHLG{6*Fs4WQ#p0n6(F>5+S*6C3oe*^sFFU!EPpkuwo52E^I%4=eD0fRRd= z$W;jq(AXe*S-yY%3d0o9J{m}z-kV{URzlM2P`%ri=kWJ)4F_6nKYw#o^gfYlqr&19 zX!}`NphH-|I+>w4%?R#ku2(R9ee`Su(aCjos^tQC7M(l;Cr%GK_XO{S>-(!Lmg;oh zfh@<9O6%vO$J2gaIl;s`O?h^z$!K?j(ss?_H1i!F4dGU=EJQqET2_rsbHN*ZYeZLS zab4jrM7ae>uMHob%zyKS+1!qH3DW1%?8i?HQU#k1e)iB_RfQYY_sRF+9zh_Old;2; zk;=(3@H{KheWnEG32?SnG6zT-kg#xlca?ZPH;dgy@ph{xI}xI0 zjH8`&&3Q*onK1(LmiS9nh>S4;q8R^y*v+GWX$U2;wGs+|tqSU`Y!Gb#)Tb7WK>#u_ zc7SYOumG$H%K+5Q*dKs^u=fFMFcA>QEI`My69U}{F!L5GSs?jR+GZ_p@i93f^#C

KZM}yC_-lk1wn8WKY}>8#wQ7qE**T2_wIMkJ>1>!6a1&w;mIBA_4)1ORfq+wd2&*YHn=RNbjt?>Vt?bxBE^?ys*3G}$GY>JnhjC%>U zGrmfA82OR-CgGsj*b(1~%gz|L$Jp|N#Q!Yba{B`-|2HMuJ3i97r(9;2b3%Ojm|S9T z$cE;+b}j=V@lcM4o+zZDlbWbWAcrTVi!CPUU-rcH2W*exIDX8vgAv?UVeTQ<%A2rI zf~S{U%X{jZfq$7wMP;Geh3`9Lcw6;1I8jfnSrR?j!7OSFlDy&ic z1t647`?P||JOBU*(@8`@RCt`ET77U-)fNBU_ulU2J3t@_1|cAkk6NV$X%T4oDCrjr zj00LnhiQlDjFv$PW5Jfrly)k}6ty!-{iAjqGR3OQRDWk$8H|le{U}usOy$EO(iUPO z2nidK-OYYn?|Gm1y}fU<31_*yckVg&ymQVy_uO;ei-8cBvz^x%gNb{P%e8=G76a-5 zu8VscIJ|p8Ihft;;0`b@5yXYb^dN5WXGKRh^nXX07WcD6r0b}I@QuwYx?%v_GOir!{!3+4qpt5i7X z*}{qyHB*xOBP^F$)BeQx6L|)h%0p`tj156WWJS5+L5`9^g&Z1F$dPA=4Pjt_zZ1D^ zi(`c5bmwwfD=AMNH-&I}kb?`vp>gTnn}WP@>8~nB>O)T_US{$ zd`L(*v|!Ae54eVy_sa|Pi%gZ{rFDjxDjCzA2YEhoVxD<|82rlFD3cN7r?xP>n0`;T zr+dJ@RzLjSx zOILD;7%VSk5)6tNXg`t~SSRN1YclYwHRULaS&!>Do58DxQ-Ap2;|#hkXH35FH9BUA47GH23}2pN899*R`1Lb! zynk{~Z9quKB}N3JamY5x|#!wZy!> z`QacAelUpO`Jg1?jVz5a|9|X?_K%|}yxn1Cqwmfu&7&9FG}rUK6P9%P3iMNJtkZDA zbyfwk|4>TFBYTU7$UE>(!lX}WFfgfJSIZcpc{3o#JRr@4A19bL!Ek5s@JEA6-uyKb zh+*-g4CL6^G%-?C%m?Y3FUC+H2H|;iT(xzS{`687*({W6L>!k~1Aoe6nlh%dS&pvH zv!2Ybm@lvXNmVxZw}k{#7)u|!T0C`qcCoi=9AelPDlM%Yc1W`QjV8**JIyfb=dXO z8r-|Q0u8l8uM2(B{C|hGh2{Am?(k9m{3F$U;4$So3+w9!#aeh09soesc`v zr5a|>h+_ZS3DeN~R#jp152~SS%$)%Os$|EG)^Sr)49gakqq0oH(c>xQ8ls;u-1?jP zsGjc|;6gaV;#*@lb3Wq@v@gy}g0@3R5poPaxw{ghDmB!Iwtsc)C`_Mhl@!Upzl_1R zUPt=$De$Xtu!<@~nx-Q_6PzgU*W?Tk-sHbZr!AHzn4^7U6YLK6CaouI}E2%;}Q|1EXmM zD%L#+V{#Kd`hR2)4?S|x)y;8`#tX%KERa-L3_D$lD{zpr1kOYt0j|6;${{}pkJ+;M+3 z#)?$v&k|m3GojYN%0*=+KY5?M`o-LBf1pZ?>m#D&D?-_3B;bs@ ze!St^rKPtLaNqAZ5bAlGPp1FBD}&#*#_{xveP|Z>e(Hn>Mps!|c5gh36(Xmb_7)lY zL2$;EmTGQq1U#+U7o-ad**AK`3AJ4u+<)5-C46ag>D)4ul`u2UkHiQXN03P5 zeEWb#AJm+T0V(?3T`wdP$@j(7EHpy|v`LKwoN?!c99OQ;or;t0(0blp?Oe(}p14iG<$BY5+l z{eRy1g0W*7eTNOp^Q_48fs!s|3c@?P)A;La*2YKWffLh10r3$(!TwkK(AAZ8_W&Wc zO{0&<49+^pk9nYg*IAJ?`~K+{u9!6q)i%|?d3?y3AiBvX_)ninq4m!_?hZ^kN4dUN zKl|2>MC07eF|i;PFvneiJTGSx7e@f+#ecomIsESwW=xHuc1kTuT9!i_S1Xcy8o8@| zVz)|}9*=(M7L=~O7Zvwyf;PIwRQ}N?FCv-DN&6EX+1ontZ?2w;H(Wd{R&=wRE{CI- z(m*@ZRCFrLsJ0mjC~U~{&Uj{xKl1FJ#@Xg9IgIoLoNIa;vp;mRVVnL|la&&50w#L<4QVC1ecg?gvA~!2N zFXPuTGm?YY5HrW5jgvgjG`42%Gm}mz4RFBxh|KvGLDW6L!&9)>&??e^tUz}@*VbzCZwsq 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

+ + + + +
+
+ +
+

Base URL only — no path. Example: http://localhost:8080

+ +
+ +
+
+ +
+ + + +
or paste an API token
+

+ For OIDC-only installs, or a token from Admin → API tokens. Must start with tt_. +

+
+ +
+ +
+ + + + +
Keyboard
+
+
+
Start / stop timer
+ Ctrl+Shift+S +
+
+
Open TimeTracker popup
+ Ctrl+Shift+T +
+
+
+
Track selected text
+
Right-click any selection → start with it as the note
+
+ right-click +
+
+
Customize shortcuts at chrome://extensions/shortcuts
+
+
+ + + +

TimeTracker extension · credentials stay in chrome.storage.local on this device

+
+ + + + 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 - - - -
-
-

TimeTracker

-
- Settings -
-
- - - - - - - - -
- - - 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 = + `` + + `Hours per day for the last 7 days, average ${round1(total / 7)} hours` + + rects.join('') + + labels.join('') + + ''; + } + + 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'); +});