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 ec5cabed2..caa7c06bb 100644 Binary files a/browser-extension/icons/idle-128.png and b/browser-extension/icons/idle-128.png differ diff --git a/browser-extension/icons/idle-16.png b/browser-extension/icons/idle-16.png index d01606b53..a2fddafc3 100644 Binary files a/browser-extension/icons/idle-16.png and b/browser-extension/icons/idle-16.png differ diff --git a/browser-extension/icons/idle-32.png b/browser-extension/icons/idle-32.png index f505cd42c..2f2bfb314 100644 Binary files a/browser-extension/icons/idle-32.png and b/browser-extension/icons/idle-32.png differ diff --git a/browser-extension/icons/idle-48.png b/browser-extension/icons/idle-48.png index ef99a2dfb..385238efe 100644 Binary files a/browser-extension/icons/idle-48.png and b/browser-extension/icons/idle-48.png differ diff --git a/browser-extension/icons/running-128.png b/browser-extension/icons/running-128.png index 1ad80fd4f..5cdd21b93 100644 Binary files a/browser-extension/icons/running-128.png and b/browser-extension/icons/running-128.png differ diff --git a/browser-extension/icons/running-16.png b/browser-extension/icons/running-16.png index d669f67af..a7320f6a8 100644 Binary files a/browser-extension/icons/running-16.png and b/browser-extension/icons/running-16.png differ diff --git a/browser-extension/icons/running-32.png b/browser-extension/icons/running-32.png index 541faf192..fa18851f2 100644 Binary files a/browser-extension/icons/running-32.png and b/browser-extension/icons/running-32.png differ diff --git a/browser-extension/icons/running-48.png b/browser-extension/icons/running-48.png index 6e0be1d1e..6752a8d0c 100644 Binary files a/browser-extension/icons/running-48.png and b/browser-extension/icons/running-48.png differ diff --git a/browser-extension/lib/api.js b/browser-extension/lib/api.js index 576cd8e59..ebfd7ce30 100644 --- a/browser-extension/lib/api.js +++ b/browser-extension/lib/api.js @@ -368,6 +368,50 @@ export class ApiClient { getUsersMe() { return this.request('GET', '/api/v1/users/me'); } + + /** + * Summary report (total/billable hours, entries, per-project totals). + * startDate/endDate: local 'YYYY-MM-DD'. + */ + getSummary({ startDate = null, endDate = null } = {}) { + const qs = new URLSearchParams(); + if (startDate) qs.set('start_date', startDate); + if (endDate) qs.set('end_date', endDate); + const q = qs.toString(); + return this.request('GET', `/api/v1/reports/summary${q ? `?${q}` : ''}`); + } + + /** + * Time entries for a date range (used by the Recents and Insights tabs). + * Entries carry project/client/task display names. + */ + getTimeEntries(params = {}) { + const qs = new URLSearchParams(); + if (params.startDate) qs.set('start_date', params.startDate); + if (params.endDate) qs.set('end_date', params.endDate); + if (params.projectId) qs.set('project_id', String(params.projectId)); + if (params.billable === true || params.billable === false) { + qs.set('billable', params.billable ? 'true' : 'false'); + } + if (params.includeActive) qs.set('include_active', 'true'); + if (params.page) qs.set('page', String(params.page)); + if (params.perPage) qs.set('per_page', String(params.perPage)); + const q = qs.toString(); + return this.request('GET', `/api/v1/time-entries${q ? `?${q}` : ''}`); + } + + /** Weekly time goals for the authenticated user (read:users scope). */ + getWeeklyGoals(params = {}) { + const qs = new URLSearchParams(); + if (params.status) qs.set('status', params.status); + const q = qs.toString(); + return this.request('GET', `/api/v1/weekly-goals${q ? `?${q}` : ''}`); + } + + /** Partial update — used for quick notes on the running entry. */ + updateTimeEntry(entryId, fields) { + return this.request('PATCH', `/api/v1/time-entries/${Number(entryId)}`, fields); + } } /** Elapsed work seconds from a timer payload (respects break_seconds and paused_at). */ diff --git a/browser-extension/lib/format.js b/browser-extension/lib/format.js new file mode 100644 index 000000000..002adb466 --- /dev/null +++ b/browser-extension/lib/format.js @@ -0,0 +1,149 @@ +/** + * Pure formatting/grouping helpers shared by popup, options and tests. + * No DOM or chrome.* dependencies. + */ + +/** Apple-style palette assigned deterministically per project id. */ +const PROJECT_PALETTE = [ + '#5856d6', // indigo + '#ff2d55', // pink + '#30b0c7', // teal + '#ff9f0a', // orange + '#34c759', // green + '#bf5af2', // purple + '#64d2ff', // cyan + '#ff9500', // amber +]; + +export function colorForProject(projectId) { + const id = Number(projectId); + if (!Number.isFinite(id)) return PROJECT_PALETTE[0]; + return PROJECT_PALETTE[Math.abs(id) % PROJECT_PALETTE.length]; +} + +function pad2(n) { + return String(n).padStart(2, '0'); +} + +/** h:mm:ss when hours > 0, otherwise m:ss (mockup style). */ +export function formatClock(totalSeconds) { + const s = Math.max(0, Math.floor(totalSeconds || 0)); + const h = Math.floor(s / 3600); + const m = Math.floor((s % 3600) / 60); + const sec = s % 60; + return h > 0 ? `${h}:${pad2(m)}:${pad2(sec)}` : `${m}:${pad2(sec)}`; +} + +/** "5:12" style hours:minutes for stats. */ +export function formatHM(totalSeconds) { + const s = Math.max(0, Math.floor(totalSeconds || 0)); + const h = Math.floor(s / 3600); + const m = Math.floor((s % 3600) / 60); + return `${h}:${pad2(m)}`; +} + +/** Local YYYY-MM-DD for API date filters. */ +export function localDateKey(date) { + const d = date instanceof Date ? date : new Date(date); + return `${d.getFullYear()}-${pad2(d.getMonth() + 1)}-${pad2(d.getDate())}`; +} + +export function addDays(date, days) { + const d = new Date(date.getTime()); + d.setDate(d.getDate() + days); + return d; +} + +const WEEKDAY_LETTERS = ['S', 'M', 'T', 'W', 'T', 'F', 'S']; + +export function weekdayLetter(date) { + const d = date instanceof Date ? date : new Date(date); + return WEEKDAY_LETTERS[d.getDay()]; +} + +const WEEKDAY_LONG = ['Sunday', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday']; + +/** "13:02" from an ISO-ish datetime string, rendered in local time. */ +export function formatTimeOf(iso) { + const d = new Date(iso); + if (Number.isNaN(d.getTime())) return ''; + return `${pad2(d.getHours())}:${pad2(d.getMinutes())}`; +} + +/** Elapsed work seconds for a time-entry dict (running or finished). */ +export function entryDurationSeconds(entry, now = Date.now()) { + if (!entry) return 0; + if (entry.duration_seconds != null && !entry.is_active) return Number(entry.duration_seconds) || 0; + const start = Date.parse(entry.start_time || ''); + if (Number.isNaN(start)) return 0; + const endRef = entry.paused_at ? Date.parse(entry.paused_at) : now; + if (Number.isNaN(endRef)) return 0; + const breakSec = Number(entry.break_seconds) || 0; + return Math.max(0, Math.floor((endRef - start) / 1000) - breakSec); +} + +/** + * Group time entries into display buckets, newest first. + * Returns [{ key, label, entries: [...] }] with Today/Yesterday/weekday labels. + */ +export function groupEntriesByDay(entries, now = new Date()) { + const todayKey = localDateKey(now); + const yesterdayKey = localDateKey(addDays(now, -1)); + const buckets = new Map(); + + const sorted = [...(entries || [])].sort((a, b) => { + const at = Date.parse(a?.start_time || '') || 0; + const bt = Date.parse(b?.start_time || '') || 0; + return bt - at; + }); + + for (const entry of sorted) { + const start = new Date(entry?.start_time || ''); + if (Number.isNaN(start.getTime())) continue; + const key = localDateKey(start); + if (!buckets.has(key)) { + let label; + if (key === todayKey) label = 'Today'; + else if (key === yesterdayKey) label = 'Yesterday'; + else label = WEEKDAY_LONG[start.getDay()]; + buckets.set(key, { key, label, entries: [] }); + } + buckets.get(key).entries.push(entry); + } + + return Array.from(buckets.values()); +} + +/** Sum worked hours per local day for the last `days` days, oldest first. */ +export function dailyTotals(entries, days = 7, now = new Date()) { + const out = []; + const byDay = new Map(); + for (const entry of entries || []) { + const start = new Date(entry?.start_time || ''); + if (Number.isNaN(start.getTime())) continue; + const key = localDateKey(start); + const hours = entryDurationSeconds(entry, now.getTime()) / 3600; + byDay.set(key, (byDay.get(key) || 0) + hours); + } + for (let i = days - 1; i >= 0; i--) { + const date = addDays(now, -i); + const key = localDateKey(date); + out.push({ date, key, letter: weekdayLetter(date), hours: byDay.get(key) || 0 }); + } + return out; +} + +/** Unique, non-trivial note strings from entries, preserving recency order. */ +export function noteSuggestions(entries, limit = 3) { + const seen = new Set(); + const out = []; + for (const entry of entries || []) { + const note = String(entry?.notes || '').trim(); + const key = note.toLowerCase(); + if (note.length < 3 || seen.has(key)) continue; + seen.add(key); + out.push(note); + if (out.length >= limit) break; + } + return out; +} diff --git a/browser-extension/lib/icons.js b/browser-extension/lib/icons.js new file mode 100644 index 000000000..3b916eeaa --- /dev/null +++ b/browser-extension/lib/icons.js @@ -0,0 +1,39 @@ +/** + * Minimal inline SVG icon set (stroke, currentColor) — matches SF Symbols + * aesthetics without shipping an icon font. + */ + +const svg = (paths, viewBox = '0 0 24 24') => + ``; + +export const icons = { + search: svg(''), + settings: svg( + '' + + '' + ), + play: svg(''), + pause: svg(''), + stop: svg(''), + plus: svg(''), + pencil: svg(''), + 'chevron-right': svg(''), + 'chevron-down': svg(''), + x: svg(''), + 'rotate-ccw': svg(''), + 'arrow-up-right': svg(''), + star: svg( + '' + ), + timer: svg(''), + 'check-circle': svg(''), + lock: svg(''), + 'log-out': svg(''), + refresh: svg(''), +}; + +/** Replace the contents of `el` with an icon by name. */ +export function setIcon(el, name) { + if (el && icons[name]) el.innerHTML = icons[name]; +} diff --git a/browser-extension/lib/picker.js b/browser-extension/lib/picker.js deleted file mode 100644 index 1ce956d97..000000000 --- a/browser-extension/lib/picker.js +++ /dev/null @@ -1,196 +0,0 @@ -/** - * Searchable combobox with optional inline "Create …" row for the extension popup. - * Mirrors app/static/searchable-select.js behaviour without depending on the web app. - */ - -/** - * @param {HTMLSelectElement} select - * @param {{ - * kind?: string, - * canCreate?: boolean, - * placeholder?: string, - * onCreate?: (typedName: string) => void | Promise, - * canCreateGuard?: () => boolean, - * canCreateGuardHint?: string, - * }} options - */ -export function enhanceSelect(select, options = {}) { - if (!select || select.tagName !== 'SELECT') return null; - if (select.dataset.pickerEnhanced === '1') { - return select._ttPicker || null; - } - - const kind = options.kind || select.getAttribute('data-searchable-select') || 'option'; - const canCreate = - options.canCreate != null - ? options.canCreate - : select.getAttribute('data-can-create') === '1'; - const createLabels = { client: 'Create client', project: 'Create project', task: 'Create task' }; - - select.dataset.pickerEnhanced = '1'; - select.classList.add('sr-only'); - select.setAttribute('aria-hidden', 'true'); - select.tabIndex = -1; - - const wrapper = document.createElement('div'); - wrapper.className = 'tt-picker relative'; - - const input = document.createElement('input'); - input.type = 'text'; - input.className = 'tt-picker-input'; - input.setAttribute('autocomplete', 'off'); - input.setAttribute('role', 'combobox'); - input.setAttribute('aria-expanded', 'false'); - input.placeholder = options.placeholder || select.getAttribute('data-search-placeholder') || 'Type to search…'; - - const list = document.createElement('ul'); - list.className = 'tt-picker-list hidden'; - list.setAttribute('role', 'listbox'); - - select.parentNode.insertBefore(wrapper, select); - wrapper.appendChild(input); - wrapper.appendChild(list); - wrapper.appendChild(select); - - function selectedLabel() { - const opt = select.options[select.selectedIndex]; - return opt ? (opt.textContent || '').trim() : ''; - } - - function readOptions() { - const opts = []; - Array.prototype.forEach.call(select.options, (opt) => { - opts.push({ - value: opt.value, - label: (opt.textContent || '').trim(), - disabled: opt.disabled, - }); - }); - return opts; - } - - function closeList() { - list.classList.add('hidden'); - input.setAttribute('aria-expanded', 'false'); - } - - function setValue(value, label) { - select.value = value; - input.value = label || selectedLabel(); - try { - select.dispatchEvent(new Event('change', { bubbles: true })); - } catch (_) {} - closeList(); - } - - function renderList(query) { - const q = (query || '').trim().toLowerCase(); - const optionsList = readOptions(); - const filtered = optionsList.filter((o) => { - if (!q) return true; - return (o.label || '').toLowerCase().includes(q); - }); - - list.innerHTML = ''; - if (!filtered.length && !(canCreate && q)) { - const empty = document.createElement('li'); - empty.className = 'tt-picker-empty'; - empty.textContent = 'No matches'; - list.appendChild(empty); - } - - filtered.forEach((o) => { - const li = document.createElement('li'); - li.className = 'tt-picker-option'; - li.setAttribute('role', 'option'); - li.textContent = o.label || '(empty)'; - if (o.value === select.value) li.classList.add('is-selected'); - li.addEventListener('mousedown', (e) => { - e.preventDefault(); - setValue(o.value, o.label); - }); - list.appendChild(li); - }); - - if (canCreate && q) { - const exact = optionsList.some((o) => (o.label || '').toLowerCase() === q); - if (!exact) { - const guardOk = - typeof options.canCreateGuard !== 'function' || options.canCreateGuard(); - if (!guardOk) { - const hint = document.createElement('li'); - hint.className = 'tt-picker-hint'; - hint.textContent = options.canCreateGuardHint || 'Not available'; - list.appendChild(hint); - } else { - const createLi = document.createElement('li'); - createLi.className = 'tt-picker-create'; - createLi.setAttribute('role', 'option'); - createLi.textContent = `${createLabels[kind] || 'Create'} “${query.trim()}”`; - createLi.addEventListener('mousedown', (e) => { - e.preventDefault(); - closeList(); - if (typeof options.onCreate === 'function') { - options.onCreate(query.trim()); - } - }); - list.appendChild(createLi); - } - } - } - - list.classList.remove('hidden'); - input.setAttribute('aria-expanded', 'true'); - } - - input.value = selectedLabel(); - - input.addEventListener('focus', () => { - renderList(input.value === selectedLabel() ? '' : input.value); - }); - input.addEventListener('input', () => renderList(input.value)); - input.addEventListener('keydown', (e) => { - if (e.key === 'Escape') { - closeList(); - input.value = selectedLabel(); - } else if (e.key === 'Enter') { - const createRow = list.querySelector('.tt-picker-create'); - const first = list.querySelector('.tt-picker-option'); - if (!list.classList.contains('hidden')) { - e.preventDefault(); - if (createRow && !first) { - createRow.dispatchEvent(new Event('mousedown')); - } else if (first) { - first.dispatchEvent(new Event('mousedown')); - } - } - } - }); - - // Reset visible input when user types but does not commit a selection (Issue #728) - input.addEventListener('blur', () => { - setTimeout(() => { - input.value = selectedLabel(); - closeList(); - }, 150); - }); - - document.addEventListener('click', (e) => { - if (!wrapper.contains(e.target)) closeList(); - }); - - select.addEventListener('change', () => { - input.value = selectedLabel(); - }); - - const api = { - refresh() { - input.value = selectedLabel(); - }, - setCanCreate(v) { - options.canCreate = !!v; - }, - }; - select._ttPicker = api; - return api; -} diff --git a/browser-extension/manifest.json b/browser-extension/manifest.json index 468564d3f..25012bd0f 100644 --- a/browser-extension/manifest.json +++ b/browser-extension/manifest.json @@ -1,13 +1,14 @@ { "manifest_version": 3, "name": "TimeTracker Timer", - "version": "5.18.2", + "version": "5.19.0", "description": "Start and stop TimeTracker timers from your browser toolbar.", "permissions": [ "storage", "alarms", "idle", - "notifications" + "notifications", + "contextMenus" ], "optional_host_permissions": [ "http://*/*", @@ -18,7 +19,7 @@ "type": "module" }, "action": { - "default_popup": "popup.html", + "default_popup": "popup/popup.html", "default_title": "TimeTracker", "default_icon": { "16": "icons/idle-16.png", @@ -27,8 +28,24 @@ "128": "icons/idle-128.png" } }, + "commands": { + "toggle-timer": { + "suggested_key": { + "default": "Ctrl+Shift+S", + "mac": "Command+Shift+S" + }, + "description": "Start or stop the TimeTracker timer" + }, + "_execute_action": { + "suggested_key": { + "default": "Ctrl+Shift+T", + "mac": "Command+Shift+T" + }, + "description": "Open TimeTracker popup" + } + }, "options_ui": { - "page": "options.html", + "page": "options/options.html", "open_in_tab": true }, "icons": { diff --git a/browser-extension/options.html b/browser-extension/options.html deleted file mode 100644 index 2160c2d11..000000000 --- a/browser-extension/options.html +++ /dev/null @@ -1,55 +0,0 @@ - - - - - - TimeTracker Extension Settings - - - -
-

TimeTracker extension

-

Connect to your self-hosted TimeTracker instance. Same API tokens as the desktop and mobile apps.

- - - -
-
- - -
-

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

- -
Sign in with password
-
- - -
-
- - -
- - -
- -
Or paste an API token
-

- Use this for OIDC-only installs, or a token from Admin → API tokens. Token must start with - tt_. -

-
- - -
- - -
- -
Not connected.
- -
-
- - - diff --git a/browser-extension/options.js b/browser-extension/options.js deleted file mode 100644 index 5d9a69605..000000000 --- a/browser-extension/options.js +++ /dev/null @@ -1,186 +0,0 @@ -/** - * Options page: server URL + password login or pasted API token. - */ - -import { ApiClient, normalizeServerUrl, originFromServerUrl } from './lib/api.js'; - -const els = { - serverUrl: document.getElementById('server-url'), - username: document.getElementById('username'), - password: document.getElementById('password'), - apiToken: document.getElementById('api-token'), - loginBtn: document.getElementById('login-btn'), - tokenBtn: document.getElementById('token-btn'), - disconnectBtn: document.getElementById('disconnect-btn'), - message: document.getElementById('message'), - statusLine: document.getElementById('status-line'), -}; - -function showMessage(text, kind = 'error') { - els.message.textContent = text; - els.message.className = kind === 'success' ? 'success' : 'error'; - els.message.classList.remove('hidden'); -} - -function clearMessage() { - els.message.classList.add('hidden'); - els.message.textContent = ''; -} - -async function requestHostAccess(serverUrl) { - const originPattern = originFromServerUrl(serverUrl); - if (!originPattern) { - return { ok: false, message: 'Server URL is not valid.' }; - } - const already = await chrome.permissions.contains({ origins: [originPattern] }); - if (already) return { ok: true }; - const granted = await chrome.permissions.request({ origins: [originPattern] }); - if (!granted) { - return { - ok: false, - message: 'Permission to reach this server was denied. Allow access when prompted.', - }; - } - return { ok: true }; -} - -async function persistSession(serverUrl, token) { - await chrome.storage.local.set({ - server_url: normalizeServerUrl(serverUrl), - api_token: token, - logged_out: false, - }); - chrome.runtime.sendMessage({ type: 'refresh_timer' }).catch(() => {}); -} - -async function updateStatus() { - const { server_url, api_token, logged_out } = await chrome.storage.local.get([ - 'server_url', - 'api_token', - 'logged_out', - ]); - if (server_url) els.serverUrl.value = server_url; - if (!server_url || !api_token || logged_out) { - els.statusLine.textContent = 'Not connected.'; - return; - } - const client = new ApiClient(server_url, api_token); - const result = await client.validateSession(); - if (result.ok) { - const name = result.user?.username || result.user?.email || 'signed in'; - els.statusLine.textContent = `Connected to ${server_url} as ${name}.`; - } else { - els.statusLine.textContent = `Saved credentials for ${server_url}, but validation failed: ${result.message}`; - } -} - -async function connectWithPassword() { - clearMessage(); - const serverUrl = normalizeServerUrl(els.serverUrl.value); - const username = els.username.value.trim(); - const password = els.password.value; - if (!serverUrl || !username || !password) { - showMessage('Server URL, username, and password are required.'); - return; - } - - els.loginBtn.disabled = true; - try { - const access = await requestHostAccess(serverUrl); - if (!access.ok) { - showMessage(access.message); - return; - } - - const info = await ApiClient.testPublicServerInfo(serverUrl); - if (!info.ok) { - showMessage(info.message); - return; - } - - const login = await ApiClient.loginWithPassword(serverUrl, username, password); - if (!login.ok) { - showMessage(login.message); - return; - } - - const client = new ApiClient(serverUrl, login.token); - const session = await client.validateSession(); - if (!session.ok) { - showMessage(session.message); - return; - } - - await persistSession(serverUrl, login.token); - els.password.value = ''; - showMessage('Connected successfully.', 'success'); - await updateStatus(); - } catch (error) { - showMessage(error?.message || 'Connect failed. Check the URL and try again.'); - } finally { - els.loginBtn.disabled = false; - } -} - -async function connectWithToken() { - clearMessage(); - const serverUrl = normalizeServerUrl(els.serverUrl.value); - const token = els.apiToken.value.trim(); - if (!serverUrl || !token) { - showMessage('Server URL and API token are required.'); - return; - } - if (!token.startsWith('tt_')) { - showMessage('Token must start with tt_.'); - return; - } - - els.tokenBtn.disabled = true; - try { - const access = await requestHostAccess(serverUrl); - if (!access.ok) { - showMessage(access.message); - return; - } - - const info = await ApiClient.testPublicServerInfo(serverUrl); - if (!info.ok) { - showMessage(info.message); - return; - } - - const client = new ApiClient(serverUrl, token); - const session = await client.validateSession(); - if (!session.ok) { - showMessage(session.message); - return; - } - - await persistSession(serverUrl, token); - els.apiToken.value = ''; - showMessage('Token saved successfully.', 'success'); - await updateStatus(); - } catch (error) { - showMessage(error?.message || 'Token save failed. Check the URL and try again.'); - } finally { - els.tokenBtn.disabled = false; - } -} - -async function disconnect() { - clearMessage(); - await chrome.storage.local.set({ - api_token: null, - logged_out: true, - last_timer_status: { active: false, timer: null }, - }); - chrome.runtime.sendMessage({ type: 'refresh_timer' }).catch(() => {}); - showMessage('Disconnected.', 'success'); - await updateStatus(); -} - -els.loginBtn.addEventListener('click', connectWithPassword); -els.tokenBtn.addEventListener('click', connectWithToken); -els.disconnectBtn.addEventListener('click', disconnect); - -updateStatus(); diff --git a/browser-extension/options/options.css b/browser-extension/options/options.css new file mode 100644 index 000000000..0bd019c9a --- /dev/null +++ b/browser-extension/options/options.css @@ -0,0 +1,209 @@ +/* Options page layout — uses shared tokens from styles/tokens.css */ + +.page { + max-width: 640px; + margin: 40px auto 48px; + padding: 0 20px; +} + +.opt-title { + margin: 0; + font-size: 26px; + font-weight: 700; + letter-spacing: -0.02em; +} + +.opt-sub { + margin: 5px 0 24px; + font-size: 13.5px; + color: var(--tt-ink-2); +} + +.opt-h { + margin: 20px 6px 7px; + font-size: 12px; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.05em; + color: var(--tt-ink-2); +} + +.opt-card { + background: var(--tt-surface); + border: 1px solid var(--tt-line); + border-radius: 14px; + overflow: hidden; +} + +.form-card { + padding: 14px; + display: flex; + flex-direction: column; + gap: 10px; +} + +.form-card .cta, +.form-card .btn { + width: 100%; + flex: none; +} + +.divider { + display: flex; + align-items: center; + gap: 12px; + margin: 8px 0 2px; + color: var(--tt-ink-2); + font-size: 11.5px; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.04em; +} + +.divider::before, +.divider::after { + content: ""; + flex: 1; + height: 1px; + background: var(--tt-line); +} + +.opt-row { + display: flex; + align-items: center; + gap: 12px; + padding: 12px 16px; + min-height: 50px; +} + +.opt-row + .opt-row { + border-top: 1px solid var(--tt-line); +} + +.opt-row-main { + flex: 1; + min-width: 0; +} + +.opt-row-t { + font-size: 13.5px; + font-weight: 500; +} + +.opt-row-s { + font-size: 11.5px; + color: var(--tt-ink-2); + margin-top: 1px; +} + +.opt-side { + display: flex; + flex-direction: column; + align-items: flex-end; + gap: 5px; + flex: none; +} + +.opt-val { + font-size: 12.5px; + font-weight: 500; + color: var(--tt-ink-2); + white-space: nowrap; +} + +.opt-ok { + display: inline-flex; + align-items: center; + gap: 5px; + color: var(--tt-green); + font-size: 12px; + font-weight: 600; + white-space: nowrap; +} + +.opt-ok svg { + width: 14px; + height: 14px; +} + +.opt-managed { + display: inline-flex; + align-items: center; + gap: 4px; + font-size: 10.5px; + font-weight: 600; + letter-spacing: 0.03em; + color: var(--tt-ink-2); + background: var(--tt-surface-2); + border-radius: 999px; + padding: 3px 8px; + white-space: nowrap; +} + +.opt-managed svg { + width: 11px; + height: 11px; +} + +.avatar { + width: 34px; + height: 34px; + border-radius: 50%; + flex: none; + background: linear-gradient(135deg, #5e5ce6, #bf5af2); + color: #fff; + display: grid; + place-items: center; + font-weight: 700; + font-size: 14px; +} + +.opt-danger { + display: inline-flex; + align-items: center; + gap: 8px; + color: var(--tt-red); + font-size: 13.5px; + font-weight: 500; +} + +.opt-danger svg { + width: 15px; + height: 15px; +} + +.opt-foot { + margin: 18px 0 0; + text-align: center; + font-size: 11.5px; + color: var(--tt-ink-3); +} + +kbd, +.kbd { + font-family: ui-monospace, "SF Mono", Menlo, Consolas, monospace; + font-size: 11px; + background: var(--tt-surface-2); + color: var(--tt-ink-2); + border-radius: 6px; + padding: 3px 7px; + box-shadow: inset 0 -1px 0 var(--tt-line); + white-space: nowrap; +} + +code { + font-family: ui-monospace, "SF Mono", Menlo, Consolas, monospace; + font-size: 11.5px; + background: var(--tt-surface-2); + border-radius: 5px; + padding: 1px 5px; +} + +.hint.left { + text-align: left; + margin: -4px 2px 0; +} + +#disconnect-btn { + cursor: pointer; +} diff --git a/browser-extension/options/options.html b/browser-extension/options/options.html new file mode 100644 index 000000000..4881e2a3d --- /dev/null +++ b/browser-extension/options/options.html @@ -0,0 +1,147 @@ + + + + + + TimeTracker Settings + + + + +
+

Settings

+

TimeTracker extension · same account and tokens as desktop and mobile

+ + + + +
+
+ +
+

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'); +});