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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
100 changes: 69 additions & 31 deletions browser-extension/README.md
Original file line number Diff line number Diff line change
@@ -1,29 +1,52 @@
# 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)

1. Open Chrome/Edge → **Extensions** → enable **Developer mode**.
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

Expand All @@ -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
127 changes: 121 additions & 6 deletions browser-extension/background.js
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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(() => {
Expand Down
12 changes: 6 additions & 6 deletions browser-extension/build/zip.js
Original file line number Diff line number Diff line change
Expand Up @@ -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',
];

Expand Down
Loading
Loading