# Time Management Module

**Date:** 2026-05-30  
**Status:** Draft  
**Depends on:** `foundation-auth-rbac`, `projects-module`, `tasks-board-engine`, `system-communications-notifications`  
**Referenced by:** `invoices-core`, `contractor-payouts`, `reports-analytics`

---

## Overview

Time tracking for tasks and projects. Toggle-based timer with magic-link remote start, idle detection, auto-pause, and time entry management. Time entries feed hourly + retainer billing calculations.

---

## Data Model

```sql
time_entries (
  id UUID PRIMARY KEY,
  tenant_id UUID NOT NULL,
  user_id UUID,                    -- NULL when entry belongs to external contractor (contractor_id set)
  contractor_id UUID,              -- FK to contractors; NULL for staff entries
  task_id UUID,                    -- nullable (project-level time)
  project_id UUID NOT NULL,
  description TEXT,
  started_at TIMESTAMPTZ NOT NULL,
  stopped_at TIMESTAMPTZ,          -- NULL = currently running
  duration_seconds INTEGER,        -- computed on stop: EXTRACT(EPOCH FROM stopped_at - started_at)
  source TEXT DEFAULT 'manual',    -- 'manual' | 'magic_link' | 'auto' | 'contractor_portal'
  billable BOOLEAN DEFAULT true,
  -- Approval + lock state: base columns owned here (wave 5). The approval workflow lives in
  -- time-approval-workflow (spec 52); lock/unlock endpoints in time-entry-locking (spec 95);
  -- contractor-payouts (spec 21) and contractor-portal (spec 87) read/write these.
  approval_status TEXT NOT NULL DEFAULT 'auto_approved'
    CHECK (approval_status IN ('auto_approved','pending','approved','rejected','locked')),
  locked_at TIMESTAMPTZ,                       -- set when included in a payout bill
  locked_reason TEXT CHECK (locked_reason IN ('invoiced','period_closed','approved')),
  created_at TIMESTAMPTZ DEFAULT now(),
  updated_at TIMESTAMPTZ DEFAULT now(),
  CONSTRAINT user_or_contractor CHECK (user_id IS NOT NULL OR contractor_id IS NOT NULL)
)

-- one active timer per user per tenant (enforced by API, not DB constraint)
-- index: (tenant_id, user_id, stopped_at IS NULL) for active timer lookup
-- external contractor entries: user_id = NULL, contractor_id set; active-timer constraint skipped for those

magic_link_tokens (
  id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  tenant_id UUID NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
  user_id UUID REFERENCES users(id) ON DELETE CASCADE,   -- NULL for portal invites (pre-registration)
  task_id UUID,                                           -- populated for purpose = 'timer'
  email TEXT,                                             -- populated for purpose = 'portal_invite' | 'portal_password_reset'
  token TEXT NOT NULL UNIQUE,      -- random URL-safe token (plaintext); SHA-256 hash stored separately for lookup
  token_hash TEXT NOT NULL UNIQUE, -- SHA-256 hex of token; used for DB lookup (token itself never stored for lookup)
  purpose TEXT NOT NULL DEFAULT 'timer',
    -- 'timer'                  — time entry magic link (spec 13)
    -- 'portal'                 — customer portal magic-link login (spec 30)
    -- 'portal_invite'          — customer contact portal invitation (spec 30)
    -- 'portal_password_reset'  — customer portal password reset (spec 30)
    -- 'pending_2fa'            — mid-login 2FA challenge session (spec auth-2fa)
    -- 'pending_2fa_setup'      — forced 2FA enrollment session (spec auth-2fa)
  expires_at TIMESTAMPTZ NOT NULL, -- TTL varies by purpose: 48h timer, 7d portal invite, 1h reset/2fa
  used_at TIMESTAMPTZ              -- NULL = not yet used; set atomically on redemption
)
```

---

## Timer Widget (Header)

Persistent timer widget in the app header. Visible on all pages.

```
┌──────────────────────────────────────────┐
│ ▶ 01:23:45  Project Alpha › Task name  ✕ │
└──────────────────────────────────────────┘
```

States:
- **Idle**: shows "Start timer" button; click → opens task/project picker
- **Running**: shows elapsed time (counting up, updated every second via `setInterval`), project + task name, stop button (✕)

Elapsed time: calculated client-side from `started_at` stored in Zustand. On page load, if active entry exists (from API), restores timer state immediately.

### Start timer flow

1. Click "Start timer" → popover
2. Select project (required) + task (optional) + description
3. `POST /api/time/start` → creates entry with `stopped_at = NULL`
4. Widget switches to running state; Zustand stores `{ entryId, startedAt, projectName, taskName }`

### Stop timer flow

1. Click ✕ or "Stop" button
2. `POST /api/time/:id/stop` → sets `stopped_at = now()`, computes `duration_seconds`
3. Widget returns to idle state
4. Webhook `timer.stopped` fires (see webhooks section)

### One active timer rule

Only one running entry per user. API enforces: `POST /api/time/start` first stops any currently running entry before creating new one. Client warns: "You have an active timer — starting a new one will stop it."

### Timer Accessibility

The running HH:MM:SS counter updates every second. Announcing every-second updates via `aria-live` is unusable. Use periodic `aria-label` updates instead:

- Timer container: `aria-live="off"` — prevents per-second announcements from flooding screen readers
- `aria-label` on the timer display element: updated **every 10 seconds** → `"Timer running: {h} hours {m} minutes"`
  (polling screen readers like NVDA and JAWS pick this up on their cycle)
- Start/Stop button:
  - `aria-label`: `"Start timer"` when stopped, `"Stop timer"` when running
  - `aria-pressed="true"` when running, `aria-pressed="false"` when stopped
- On timer stop: a `role="status"` region (polite, not assertive) announces: `"Timer stopped at {h} hours {m} minutes {s} seconds"`

---

## Time Tracking Page (`/time`)

Full time management view.

### Layout

```
┌─────────────────────────────────────────────────────┐
│  [Week selector ◄ ▶]   [+ Log time]  [▶ Start timer]│
│                                                     │
│  Mon 26  Tue 27  Wed 28  Thu 29  Fri 30  Total       │
│  2h 30m  4h 00m  1h 15m  3h 45m  0h 00m  11h 30m    │
│                                                     │
│  ┌─────────────────────────────────────────────┐    │
│  │ Today                                5h 30m │    │
│  │ ● 09:00–11:30  Project A › Task X  2h 30m  │    │
│  │ ● 13:00–16:00  Project B           3h 00m  │    │
│  ├─────────────────────────────────────────────┤    │
│  │ Yesterday                            4h 00m │    │
│  │ ● 10:00–14:00  Project A › Task Y  4h 00m  │    │
│  └─────────────────────────────────────────────┘    │
└─────────────────────────────────────────────────────┘
```

Grouped by day, sorted descending. Each entry row: project chip, task link (if set), description, start–stop times, duration, billable toggle, edit/delete icons.

### Week summary bar

Horizontal bar chart: each day column = total hours. Clicking a column scrolls to that day's entries.

### Log time manually

"+ Log time" → Sheet form:
- Project (required), Task (optional)
- Date, Start time, End time (or duration)
- Description, Billable toggle
- Creates entry with `source = 'manual'`
- Successful start, stop, create, update, and delete mutations MUST await invalidation of active time-entry and project-budget queries before settling. The manual-log sheet closes only after refreshed list data can expose the new entry.

### Edit entry

Inline edit on row click: same fields. Updates via `PATCH /api/time/:id`.

### Delete entry

Soft-confirm (no modal — just icon turns red on hover, click to confirm). Hard delete (no soft delete — time entries are user-owned data, not referenced by other records until invoice is generated).

---

## Magic Link

When a task detail is open, user can send a "Start timer" magic link to themselves or a team member. Recipient clicks link → timer starts in their browser session.

### Flow

1. User clicks "Send magic link" on task detail (or via task action menu)
2. API creates `magic_link_tokens` record (48h TTL, single-use)
3. Email sent to recipient (via Resend): subject "Start timer: {task title}", body contains link `https://app.zync.is/magic/time?token={token}`
4. Recipient clicks link → `GET /api/time/magic?token={token}`:
   - Validates token (not expired, not used)
   - Marks `used_at = now()`
   - Starts timer server-side: `INSERT INTO time_entries (user_id, task_id, ...)` using `token.user_id`
   - Fires webhook `timer.started`
   - Redirects to `app.zync.is/time?started={entryId}` (confirmation page)
5. App page shows "Timer started for {task title}" + running widget

> The token carries its own authority (`user_id`). Timer starts in the GET handler, not by a subsequent authenticated POST. Recipient need not be logged in as that user for the start to succeed.

### Auto-report on window close

When a timer is running and the tab/window is closed, a `beforeunload` event fires `POST /api/time/:id/stop` (best-effort — browser may kill the request). Additionally, `navigator.sendBeacon('/api/time/beacon', JSON.stringify({ entryId }))` is used as the primary mechanism (beacon requests survive tab close). Server-side: if an entry has `stopped_at = NULL` and `started_at > 1h ago`, a scheduled cleanup cron stops it automatically.

---

## Idle Detection & Auto-Pause

Configurable threshold (org admin setting in `/settings/business`, default: 10 minutes).

### Client-side idle detection

```ts
// packages/ui/src/hooks/useIdleDetection.ts
// Uses Page Visibility API + mousemove/keydown/scroll event listeners
// Idle = no activity for idleThresholdMs
```

When idle threshold exceeded while timer running:
1. Show "You've been idle for {N} minutes. Stop timer?" dialog
2. Options:
   - **Stop timer** — stops from last active time (`started_at + (now - idleStart)`)
   - **Keep running** — dismisses, continues
   - **Discard idle time** — stops timer, subtracts idle duration from entry

### Idle threshold setting

Stored in `tenant_settings.idle_timer_threshold_minutes` (default: 10). Loaded on app init, stored in Zustand. (Minutes is more user-friendly than seconds; the timer engine converts to seconds internally.)

Webhook `timer.auto_paused` fires when idle dialog is shown (not on resolution — only on system-initiated pause, distinct from user choice).

---

## Webhooks

| Event | Payload |
|-------|---------|
| `timer.started` | `{ entryId, userId, taskId, projectId, startedAt, source }` |
| `timer.stopped` | `{ entryId, userId, taskId, projectId, startedAt, stoppedAt, durationSeconds }` |
| `timer.auto_paused` | `{ entryId, userId, taskId, projectId, idleSeconds }` |

---

## Integration with Billing

Time entries are consumed by invoice generation:

- **Hourly projects**: sum of `duration_seconds` for billable entries in billing period → hours billed
- **Retainer projects**: sum of `duration_seconds` → updates `retainer_months.hours_used`
- Entries marked `billable = false` excluded from both

Time entry `project_id` must match the project being invoiced. Entries without a project can't be billed.

---

## Permissions

| Action | Required permission |
|--------|-------------------|
| View own time entries | `time:read` |
| View all users' entries | `time:read_all` |
| Log / edit own time | `time:write` |
| Edit others' entries | `time:write_all` |
| Delete own entries | `time:write` |
| Send magic links | `tasks:write` |

---

## Time Rounding Rules

Rounding is applied when a timer is stopped. The rounded duration is stored in `time_entries.duration_seconds` (the raw start/stop is preserved for audit). Rounding is configured per tenant via `tenant_settings.time_rounding`.

### Rounding modes

| Mode | Behavior |
|------|----------|
| `none` (default) | No rounding — raw elapsed seconds |
| `nearest_5` | Round to nearest 5-minute increment |
| `nearest_15` | Round to nearest 15-minute increment |
| `nearest_30` | Round to nearest 30-minute increment |
| `up_15` | Always round UP to next 15-minute increment |
| `up_30` | Always round UP to next 30-minute increment |

### Implementation

```ts
// packages/time/src/rounding.ts
export function roundDuration(durationSeconds: number, mode: TimeRoundingMode): number {
  if (mode === 'none') return durationSeconds

  const minuteMap: Record<TimeRoundingMode, number> = {
    nearest_5:  5,
    nearest_15: 15,
    nearest_30: 30,
    up_15:      15,
    up_30:      30,
  }
  const interval = minuteMap[mode] * 60  // convert to seconds

  if (mode.startsWith('nearest_')) {
    return Math.round(durationSeconds / interval) * interval
  }
  // 'up_*' modes: always round up
  return Math.ceil(durationSeconds / interval) * interval
}
```

Minimum billable duration: 0 (no minimum enforced by default). Tenants who always round up to 15 minutes effectively have a 15-minute minimum per entry.

### Schema delta

```sql
ALTER TABLE tenant_settings ADD COLUMN time_rounding TEXT NOT NULL DEFAULT 'none'
  CHECK (time_rounding IN ('none', 'nearest_5', 'nearest_15', 'nearest_30', 'up_15', 'up_30'));
-- Contractor time-approval gate, owned here (wave 5; earliest reader is contractor-portal, spec 87).
-- time-approval-workflow (spec 52) reads it; contractor-settings (spec 148) is the editing UI.
ALTER TABLE tenant_settings ADD COLUMN contractor_require_time_approval BOOLEAN NOT NULL DEFAULT true;
```

### Configuration UI

On the `/settings/time-tracking` page (spec 169), a "Time rounding" section:

```
Rounding  [No rounding ▾]
          ▸ No rounding (raw seconds)
          ▸ Nearest 5 minutes
          ▸ Nearest 15 minutes
          ▸ Nearest 30 minutes
          ▸ Always round up (15 min)
          ▸ Always round up (30 min)
```

Below the dropdown: "Example: 8m 40s entered → rounds to {X}min with current setting."

### Rounding and invoicing

When time entries are billed, the invoice line quantity uses `duration_seconds / 3600` (hours, rounded to 2 decimal places). Rounding is already baked into `duration_seconds` — no further rounding at invoice generation.

---

## API Endpoints

```
GET    /api/time                         → list entries (filterable: date range, user, project, task)
POST   /api/time/start                   → start timer { projectId, taskId?, description? }
POST   /api/time/:id/stop                → stop running timer
POST   /api/time/beacon                  → navigator.sendBeacon endpoint (stop on tab close)
POST   /api/time                         → manual log entry { projectId, taskId?, startedAt, stoppedAt, ... }
PATCH  /api/time/:id                     → edit entry
DELETE /api/time/:id                     → delete entry
GET    /api/time/active                  → current running entry or `200 null` when idle
GET    /api/time/summary?week=YYYY-WNN   → weekly summary (total per day)
GET    /api/time/magic?token=...         → validate + consume + START timer server-side → redirect to /time?started={entryId}
POST   /api/time/magic                   → create magic link { taskId, recipientEmail }
```

---

Rationale (2026-07-12): an idle timer is normal state, so it returns `200 null` instead of producing a browser-level 404 console error on every authenticated shell load.

## Cron: Stale Timer Cleanup

`/api/cron/time-cleanup` — runs hourly (CRON_SECRET protected).

Finds entries where `stopped_at IS NULL AND started_at < now() - interval '2 hours'`. Auto-stops them with `source = 'auto'`, fires `timer.stopped` webhook. Prevents ghost timers from running indefinitely.

---

## Architecture Decisions

| Decision | Choice | Reason |
|----------|--------|--------|
| One active timer per user | API enforced, not DB constraint | Simpler; DB constraint would need partial unique index on nullable column |
| Elapsed time calculation | Client-side from `started_at` | No polling needed; accurate to second; server just stores start/stop |
| Tab-close stop | `navigator.sendBeacon` | Guaranteed delivery unlike `beforeunload` fetch |
| Idle detection | Client-side (Page Visibility + events) | No server-side polling; threshold configurable per tenant |
| Stale timer cron | Hourly cleanup | Safety net for beacon failures; 2h window avoids false-positives |
| Magic link TTL | 48h, single-use | Long enough for async workflows; single-use prevents replay |
| Magic link server-side start | Timer started in GET handler from `token.user_id` | Token IS the authority — requiring recipient to be logged in kills the "email a teammate" use case |
