# Time Management Module — Implementation Plan

**Spec:** docs/specs/2026-05-30-time-management.md  ·  **Slug:** time-management  ·  **Wave:** 5
**Depends on:** foundation-auth-rbac, projects-module, system-communications-notifications, tasks-board-engine

## Goal
Deliver time tracking for tasks and projects: a persistent header timer widget, a full `/time` management page, manual time logging, magic-link remote timer start (token-authoritative), client-side idle detection with auto-pause, per-tenant time rounding, and a stale-timer cleanup cron. Time entries are the billing source of truth consumed downstream by invoices-core, contractor-payouts, and reporting. Webhooks (`timer.started`, `timer.stopped`, `timer.auto_paused`) fire on lifecycle transitions.

## Architecture
- **DB (`@zync/db`):** new `time_entries` and `magic_link_tokens` tables; an `ALTER TABLE tenant_settings ADD COLUMN` delta for `idle_timer_threshold_minutes` and `time_rounding`. The `tenant_settings` base table (`id` UUID PK, `tenant_id` UUID UNIQUE → `tenants(id)`, timestamps) is owned by `foundation-auth-rbac` and is in this plan's closure; this plan only adds columns idempotently.
- **Logic package (`@zync/time`):** new package holding `roundDuration` + `TimeRoundingMode`, duration computation, weekly-summary aggregation, and DB query helpers (all tenant-scoped via `tenantQuery` from `@zync/db`).
- **API (`apps/zync-api`, Hono):** `/api/time/*` routes guarded by `authMiddleware`, `requireModuleEnabled('time')`, `requirePermission(...)`, Zod-validated. Magic-link GET is unauthenticated (token carries authority). Cron route `/api/cron/time-cleanup` guarded by `CRON_SECRET`. Webhook lifecycle events enqueued onto the `webhook.deliver` QUEUE binding (consumer defined upstream in white-label-api/foundation).
- **App (`apps/zync-app`, Vite+React):** header `TimerWidget`, `/time` page (week selector, day groups, summary bar), `LogTimeSheet`, idle dialog, `magic/time` confirmation route. Timer state in a Zustand `useTimerStore`. Idle detection in `packages/ui` hook `useIdleDetection`.
- **Email:** magic-link email sent via `sendEmail` (`SendEmailOptions` from `@zync/notifications`) with `locale` resolved from the tenant/recipient.
- **Consumes upstream:** `projects` + `project_members` (projects-module), `tasks` + `task_statuses` (tasks-board-engine), `tenants`/`users`/`tenant_memberships`/`permissions`/`roles` (foundation-auth-rbac), `retainer_months` (projects-module) for hour-bank updates, `sendEmail`/QUEUE/`createNotification` (system-communications-notifications), `tenantQuery`/`systemQuery`/`createDb`/`buildPaginated`/`clampLimit`/`encodeCursor`/`decodeCursor` (`@zync/db`), `requirePermission`/`requireModuleEnabled`/`authMiddleware`/`hashToken`/`generateOpaqueToken`/`timingSafeEqual` (`@zync/auth`).

## Tech Stack
- **Packages:** `@zync/time` (new), `@zync/db` (schema), `@zync/types` (shared types), `@zync/ui` (`useIdleDetection`, widget primitives), `@zync/auth` (guards/token helpers), `@zync/notifications` (`sendEmail`).
- **Apps:** `apps/zync-api` (Hono on Cloudflare Workers), `apps/zync-app` (Vite + React).
- **Libraries:** Drizzle ORM, Zod, Zustand (+ `zustand/middleware` persist), TanStack Query, `date-fns` (ISO week math), Hono.
- **Cloudflare bindings:** `DB`/Hyperdrive (Neon Postgres), `QUEUE` (`webhook.deliver` producer), env `CRON_SECRET`, `RATE_LIMITER_WEBHOOK` (reused for magic-link create throttling). Cron trigger → `/api/cron/time-cleanup` hourly.
- **Runtime:** Turborepo + pnpm; Workers (`nodejs_compat`).

## Wave Plan
| Sub-wave | Tasks | Files touched | Parallelizable? |
|----------|-------|---------------|-----------------|
| A — schema & types | 1, 2 | `@zync/db`, `@zync/types` | 1 then 2 |
| B — core logic | 3, 4 | `@zync/time` | parallel after A |
| C — API | 5, 6, 7, 8, 9 | `apps/zync-api/src/routes/time.ts`, cron | after B; 5 first, 6–9 parallel |
| D — permissions/seed | 10 | auth seed | parallel after A |
| E — client state & hooks | 11, 12 | `@zync/ui`, `apps/zync-app` store | parallel after A |
| F — UI | 13, 14, 15, 16 | `apps/zync-app` pages/widgets | after C+E; parallel among themselves |
| G — settings & email | 17, 18 | settings UI, email template | after C |

## Tasks

### Task 1: Database schema — `time_entries`, `magic_link_tokens`, `tenant_settings` delta
**Blocks:** 2, 3, 4, 5, 10  ·  **Blocked by:** —
**Files:**
- Create: `packages/db/src/schema/time.ts`
- Modify: `packages/db/src/schema/index.ts` (re-export)
- Create: `packages/db/migrations/00XX_time_management.sql`
**Steps:**
- [ ] Define `time_entries` and `magic_link_tokens` as Drizzle pgTable definitions mirroring the DDL below; export their `$inferSelect`/`$inferInsert` row types. `time_entries` includes the base `approval_status`/`locked_at`/`locked_reason` columns owned here (downstream approval/lock/payout specs consume, never redeclare them).
- [ ] Add the `tenant_settings` column delta (`idle_timer_threshold_minutes`, `time_rounding`, `contractor_require_time_approval`) as `ADD COLUMN IF NOT EXISTS` (base table owned by `foundation-auth-rbac`; never re-create it here).
- [ ] Add the partial index for active-timer lookup and the magic-link `token_hash` index.
- [ ] Re-export the new tables from `@zync/db`'s schema index.
**Schema / Interfaces:**
```sql
CREATE TABLE time_entries (
  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 SET NULL,   -- NULL for external contractor entries
  contractor_id    UUID,                                           -- FK to contractors (owned downstream); NULL for staff
  task_id          UUID REFERENCES tasks(id) ON DELETE SET NULL,   -- nullable (project-level time)
  project_id       UUID NOT NULL REFERENCES projects(id) ON DELETE CASCADE,
  description      TEXT,
  started_at       TIMESTAMPTZ NOT NULL,
  stopped_at       TIMESTAMPTZ,                                     -- NULL = currently running
  duration_seconds INTEGER,                                        -- rounded value stored on stop
  source           TEXT NOT NULL DEFAULT 'manual'
                     CHECK (source IN ('manual','magic_link','auto','contractor_portal')),
  billable         BOOLEAN NOT NULL DEFAULT true,
  -- Approval + lock state: base columns OWNED HERE (time-management, wave 5 — earliest module
  -- in the time-entry domain). The approval WORKFLOW (queue, transitions, digest) lives in
  -- time-approval-workflow (spec 52); the lock/unlock endpoints live in time-entry-locking
  -- (spec 95); contractor-payouts (wave 7) writes 'locked' and contractor-portal (wave 8)
  -- sets pending/auto_approved on contractor submissions. Those modules read/write these
  -- columns via tenantQuery — they do NOT re-declare them.
  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 NOT NULL DEFAULT now(),
  updated_at       TIMESTAMPTZ NOT NULL DEFAULT now(),
  CONSTRAINT user_or_contractor CHECK (user_id IS NOT NULL OR contractor_id IS NOT NULL)
);

-- active-timer lookup (one running entry per user enforced in API):
CREATE INDEX idx_time_entries_active ON time_entries (tenant_id, user_id) WHERE stopped_at IS NULL;
CREATE INDEX idx_time_entries_project ON time_entries (project_id, started_at);
CREATE INDEX idx_time_entries_cursor ON time_entries (tenant_id, started_at DESC, id DESC);

CREATE TABLE 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 REFERENCES tasks(id) ON DELETE SET NULL,  -- populated for purpose = 'timer'
  email       TEXT,                                          -- populated for portal_invite / portal_password_reset
  token       TEXT NOT NULL UNIQUE,                          -- URL-safe plaintext token
  token_hash  TEXT NOT NULL UNIQUE,                          -- SHA-256 hex of token; used for DB lookup
  purpose     TEXT NOT NULL DEFAULT 'timer'
                CHECK (purpose IN ('timer','portal','portal_invite','portal_password_reset','pending_2fa','pending_2fa_setup')),
  expires_at  TIMESTAMPTZ NOT NULL,                          -- 48h timer, 7d portal invite, 1h reset/2fa
  used_at     TIMESTAMPTZ,                                   -- NULL = unused; set atomically on redemption
  created_at  TIMESTAMPTZ NOT NULL DEFAULT now()
);

CREATE INDEX idx_magic_link_tokens_hash ON magic_link_tokens (token_hash);
CREATE INDEX idx_magic_link_tokens_expiry ON magic_link_tokens (expires_at) WHERE used_at IS NULL;

-- tenant_settings base table is owned by foundation-auth-rbac; only add columns here:
ALTER TABLE tenant_settings
  ADD COLUMN IF NOT EXISTS idle_timer_threshold_minutes INTEGER NOT NULL DEFAULT 10;
ALTER TABLE tenant_settings
  ADD COLUMN IF NOT EXISTS 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) — the earliest reader is contractor-portal
-- (wave 8). time-approval-workflow (spec 52) reads it to gate the approval queue; contractor-settings
-- (spec 148, /settings/contractors) is the editing UI. All readers use tenantQuery; default true if absent.
ALTER TABLE tenant_settings
  ADD COLUMN IF NOT EXISTS contractor_require_time_approval BOOLEAN NOT NULL DEFAULT true;
```
**Acceptance:**
- [ ] Migration applies cleanly to a Neon branch; `\d time_entries` and `\d magic_link_tokens` show all columns, the two CHECK constraints, and the indexes above.
- [ ] Every FK is UUID→UUID; `source`/`purpose`/`time_rounding` are inline-CHECK enums; `billable` is `BOOLEAN`; timestamps are `TIMESTAMPTZ`.
- [ ] Re-applying the migration does not error on `tenant_settings` (IF NOT EXISTS guards).

### Task 2: Shared types & constants (`@zync/types`)
**Blocks:** 3, 5, 11, 13  ·  **Blocked by:** 1
**Files:**
- Create: `packages/types/src/time.ts`
- Modify: `packages/types/src/index.ts` (re-export)
**Steps:**
- [ ] Define `TimeEntry`, `TimeEntrySource`, `TimeRoundingMode`, `MagicLinkPurpose`, `WeekSummary`, `WeekDaySummary`, `TimerState`, and the API response/serializer types below.
- [ ] Define `TIME_ROUNDING_MODES` readonly array + `TIME_ROUNDING_LABELS` map (he/en) for the settings dropdown.
- [ ] Export everything from the package index.
**Schema / Interfaces:**
```ts
export type TimeEntrySource = 'manual' | 'magic_link' | 'auto' | 'contractor_portal';
export type TimeRoundingMode = 'none' | 'nearest_5' | 'nearest_15' | 'nearest_30' | 'up_15' | 'up_30';
export type MagicLinkPurpose =
  | 'timer' | 'portal' | 'portal_invite' | 'portal_password_reset' | 'pending_2fa' | 'pending_2fa_setup';

export interface TimeEntry {
  id: string; tenantId: string;
  userId: string | null; contractorId: string | null;
  taskId: string | null; projectId: string;
  description: string | null;
  startedAt: string; stoppedAt: string | null;
  durationSeconds: number | null;
  source: TimeEntrySource; billable: boolean;
  createdAt: string; updatedAt: string;
}
export interface TimeEntryObject extends TimeEntry {
  projectName: string; taskTitle: string | null; userName: string | null;
}
export interface WeekDaySummary { date: string; totalSeconds: number; }
export interface WeekSummary { week: string; days: WeekDaySummary[]; totalSeconds: number; }
export interface TimerState { entryId: string; startedAt: string; projectId: string; projectName: string; taskId: string | null; taskName: string | null; }

export const TIME_ROUNDING_MODES: readonly TimeRoundingMode[] =
  ['none','nearest_5','nearest_15','nearest_30','up_15','up_30'] as const;
export const TIME_ROUNDING_LABELS: Record<TimeRoundingMode, { he: string; en: string }> = {
  none:       { he: 'ללא עיגול',            en: 'No rounding (raw seconds)' },
  nearest_5:  { he: 'ל-5 הדקות הקרובות',     en: 'Nearest 5 minutes' },
  nearest_15: { he: 'ל-15 הדקות הקרובות',    en: 'Nearest 15 minutes' },
  nearest_30: { he: 'ל-30 הדקות הקרובות',    en: 'Nearest 30 minutes' },
  up_15:      { he: 'עיגול מעלה (15 דק׳)',    en: 'Always round up (15 min)' },
  up_30:      { he: 'עיגול מעלה (30 דק׳)',    en: 'Always round up (30 min)' },
};
```
**Acceptance:**
- [ ] `@zync/types` builds; `TimeRoundingMode` and `TimeEntryObject` are importable by `@zync/time`, `@zync/db` consumers, and `apps/zync-app`.

### Task 3: `@zync/time` package — rounding & duration
**Blocks:** 5, 6, 17  ·  **Blocked by:** 1, 2
**Files:**
- Create: `packages/time/package.json`
- Create: `packages/time/tsconfig.json`
- Create: `packages/time/src/rounding.ts`
- Create: `packages/time/src/duration.ts`
- Create: `packages/time/src/index.ts`
**Steps:**
- [ ] Scaffold the package (depends on `@zync/types`, `@zync/db`); add to pnpm workspace and Turbo pipeline.
- [ ] Implement `roundDuration` verbatim per spec.
- [ ] Implement `computeRawSeconds(startedAt, stoppedAt)` = `Math.max(0, floor((stopped - started)/1000))` and `formatRoundingExample(mode)` for the settings preview ("8m 40s → {X}min").
**Schema / Interfaces:**
```ts
import type { TimeRoundingMode } from '@zync/types';

export function roundDuration(durationSeconds: number, mode: TimeRoundingMode): number {
  if (mode === 'none') return durationSeconds;
  const minuteMap: Record<Exclude<TimeRoundingMode, 'none'>, number> = {
    nearest_5: 5, nearest_15: 15, nearest_30: 30, up_15: 15, up_30: 30,
  };
  const interval = minuteMap[mode] * 60; // seconds
  if (mode.startsWith('nearest_')) return Math.round(durationSeconds / interval) * interval;
  return Math.ceil(durationSeconds / interval) * interval; // up_* always round up
}

export function computeRawSeconds(startedAt: Date | string, stoppedAt: Date | string): number {
  const a = new Date(startedAt).getTime(); const b = new Date(stoppedAt).getTime();
  return Math.max(0, Math.floor((b - a) / 1000));
}
```
**Acceptance:**
- [ ] `roundDuration(520,'up_15') === 900`; `roundDuration(520,'nearest_15') === 600`; `roundDuration(520,'none') === 520`.
- [ ] Package builds and is importable from `apps/zync-api` and `apps/zync-app`.

### Task 4: `@zync/time` package — query & aggregation helpers
**Blocks:** 5, 6, 7, 8, 9  ·  **Blocked by:** 1, 2, 3
**Files:**
- Create: `packages/time/src/queries.ts`
- Create: `packages/time/src/summary.ts`
- Modify: `packages/time/src/index.ts`
**Steps:**
- [ ] Implement tenant-scoped query helpers using `tenantQuery`/`createDb` from `@zync/db` (no raw Drizzle from routes — all DB access lives here).
- [ ] Implement `getActiveEntry`, `startEntry` (stops any running entry first, transactional), `stopEntry` (computes + rounds + writes `duration_seconds`, returns updated row), `listEntries` (cursor pagination via `buildPaginated`/`clampLimit`), `logManualEntry`, `updateEntry`, `deleteEntry`, `serializeTimeEntry`.
- [ ] Implement `getWeekSummary(db, tenantId, userId, week)` — parse `YYYY-WNN` to the ISO week's Mon..Sun range, sum `duration_seconds` per local day (tenant timezone from `tenants.default_timezone`).
- [ ] On stop of a billable entry whose project is a retainer, increment the matching `retainer_months.hours_used` (month = entry local month) by `duration_seconds/3600`; create the `retainer_months` row if absent.
**Schema / Interfaces:**
```ts
export async function startEntry(db: Db, ctx: TenantCtx, input: {
  projectId: string; taskId?: string | null; description?: string | null;
  source?: TimeEntrySource; startedAt?: string;
}): Promise<TimeEntry>; // stops current running entry for ctx.userId first

export async function stopEntry(db: Db, ctx: TenantCtx, entryId: string): Promise<TimeEntry>;
export async function getActiveEntry(db: Db, ctx: TenantCtx): Promise<TimeEntry | null>;
export async function listEntries(db: Db, ctx: TenantCtx, filter: {
  from?: string; to?: string; userId?: string; projectId?: string; taskId?: string;
  cursor?: string; limit?: number;
}): Promise<PaginatedResponse<TimeEntryObject>>;
export async function logManualEntry(db: Db, ctx: TenantCtx, input: ManualEntryInput): Promise<TimeEntry>;
export async function updateEntry(db: Db, ctx: TenantCtx, entryId: string, patch: Partial<ManualEntryInput>): Promise<TimeEntry>;
export async function deleteEntry(db: Db, ctx: TenantCtx, entryId: string): Promise<void>; // hard delete
export async function getWeekSummary(db: Db, ctx: TenantCtx, week: string, userId?: string): Promise<WeekSummary>;
export function serializeTimeEntry(row: TimeEntryRow, joined: { projectName: string; taskTitle: string | null; userName: string | null }): TimeEntryObject;
```
**Acceptance:**
- [ ] `startEntry` is atomic: any pre-existing running entry for the user is stopped (with rounding applied) before the new row is inserted; never two running entries for one user.
- [ ] `stopEntry` writes `duration_seconds = roundDuration(computeRawSeconds(started, now), tenantRounding)` and sets `stopped_at = now()`.
- [ ] Stopping a billable retainer-project entry increments `retainer_months.hours_used` for the correct month.

### Task 5: API — start / stop / active / beacon
**Blocks:** 11, 13  ·  **Blocked by:** 4, 10
**Files:**
- Create: `apps/zync-api/src/routes/time.ts`
- Create: `apps/zync-api/src/lib/time-webhooks.ts`
- Modify: `apps/zync-api/src/index.ts` (mount router)
**Steps:**
- [ ] Mount `time` router under `/api/time` with `authMiddleware` + `requireModuleEnabled('time')` for authenticated routes.
- [ ] `POST /api/time/start` — `requirePermission('time:write')`, Zod body `{ projectId: uuid, taskId?: uuid, description?: string<=2000 }`; call `startEntry(source:'manual')`; enqueue `timer.stopped` for the auto-stopped prior entry (if any) then `timer.started` for the new one; return serialized entry.
- [ ] `POST /api/time/:id/stop` — `requirePermission('time:write')` (or `time:write_all` if not owner); call `stopEntry`; enqueue `timer.stopped`; return entry.
- [ ] `GET /api/time/active` — `requirePermission('time:read')`; return the serialized running entry, or `200 null` when idle so normal shell restoration is console-clean.
- [ ] `POST /api/time/beacon` — `requirePermission('time:write')`; Zod `{ entryId: uuid }`; idempotently stop the entry if still running (no error if already stopped); return `204`. Accept `text/plain`/`application/json` bodies (sendBeacon sends a Blob).
- [ ] Implement `enqueueTimerWebhook(env, event, payload)` in `time-webhooks.ts` → `env.QUEUE.send({ type:'webhook.deliver', tenantId, event, payload })` (producer onto the `webhook.deliver` queue; consumer owned upstream).
**Schema / Interfaces:**
```ts
// time-webhooks.ts
type TimerWebhook =
  | { event: 'timer.started';  payload: { entryId; userId; taskId; projectId; startedAt; source } }
  | { event: 'timer.stopped';  payload: { entryId; userId; taskId; projectId; startedAt; stoppedAt; durationSeconds } }
  | { event: 'timer.auto_paused'; payload: { entryId; userId; taskId; projectId; idleSeconds } };
export async function enqueueTimerWebhook(env: Env, tenantId: string, w: TimerWebhook): Promise<void>;
```
**Acceptance:**
- [ ] Starting a timer while one runs returns the new entry AND emits both a `timer.stopped` (old) and `timer.started` (new) webhook.
- [ ] `POST /api/time/beacon` succeeds with a `sendBeacon`-style request and is a no-op if the entry already stopped.
- [ ] All bodies are Zod-validated; routes never touch Drizzle directly (only `@zync/time` helpers).

### Task 6: API — list / summary / manual log / edit / delete
**Blocks:** 14, 15  ·  **Blocked by:** 4, 10
**Files:**
- Modify: `apps/zync-api/src/routes/time.ts`
**Steps:**
- [ ] `GET /api/time` — `requirePermission('time:read')`; query params `from,to,projectId,taskId,user,cursor,limit`; if `user` filter targets another user, require `time:read_all`; else scope to caller; return `PaginatedResponse<TimeEntryObject>`.
- [ ] `GET /api/time/summary?week=YYYY-WNN` — `requirePermission('time:read')`; `user` param requires `time:read_all`; return `WeekSummary`.
- [ ] `POST /api/time` — `requirePermission('time:write')`; Zod `{ projectId, taskId?, startedAt (ISO), stoppedAt (ISO), description?, billable? }`; require `stoppedAt > startedAt`; `logManualEntry(source:'manual')`; rounding applied to derived `duration_seconds`; return entry.
- [ ] `PATCH /api/time/:id` — `requirePermission('time:write')`; editing another user's entry requires `time:write_all`; Zod partial of manual fields + `billable`; recompute `duration_seconds` when times change; return entry.
- [ ] `DELETE /api/time/:id` — `requirePermission('time:write')` (own) / `time:write_all` (others); hard delete; return `204`.
**Schema / Interfaces:**
```ts
const ManualEntrySchema = z.object({
  projectId: z.string().uuid(),
  taskId: z.string().uuid().nullable().optional(),
  startedAt: z.string().datetime(),
  stoppedAt: z.string().datetime(),
  description: z.string().max(2000).nullable().optional(),
  billable: z.boolean().optional(),
}).refine(v => new Date(v.stoppedAt) > new Date(v.startedAt), { message: 'stoppedAt must be after startedAt' });
```
**Acceptance:**
- [ ] `GET /api/time?user=<other>` without `time:read_all` returns `403`.
- [ ] Manual entry rejects `stoppedAt <= startedAt` with `400`.
- [ ] Editing start/stop times recomputes and re-rounds `duration_seconds`.

### Task 7: API — create magic link (`POST /api/time/magic`)
**Blocks:** 16, 18  ·  **Blocked by:** 4, 10
**Files:**
- Modify: `apps/zync-api/src/routes/time.ts`
**Steps:**
- [ ] `POST /api/time/magic` — `requirePermission('tasks:write')`; rate-limit via `RATE_LIMITER_WEBHOOK` keyed on tenant+user; Zod `{ taskId: uuid, recipientEmail?: email }`.
- [ ] Resolve recipient `user_id`: if `recipientEmail` omitted → caller; else look up active `tenant_memberships` user by email (must be same tenant) else `404`.
- [ ] Generate token via `generateOpaqueToken()`; compute `token_hash = hashToken(token)`; insert `magic_link_tokens` with `purpose='timer'`, `task_id`, `user_id`, `expires_at = now()+48h`.
- [ ] Send email via `sendEmail({ to, templateKey: 'timer_magic_link_<locale>', vars: { taskTitle, link }, locale })` where `link = https://app.zync.is/magic/time?token={token}`; locale resolved from recipient `user_preferences` → tenant default (never default en).
- [ ] Return `201 { sent: true }` (never echo the raw token in the response body).
**Acceptance:**
- [ ] A `magic_link_tokens` row is created with a 48h `expires_at`, `used_at = NULL`, both `token` and `token_hash` populated, `purpose='timer'`.
- [ ] Email is dispatched with the magic URL; raw token is not returned in the HTTP response.
- [ ] Caller lacking `tasks:write` gets `403`.

### Task 8: API — consume magic link (`GET /api/time/magic`)
**Blocks:** 16  ·  **Blocked by:** 4
**Files:**
- Modify: `apps/zync-api/src/routes/time.ts`
**Steps:**
- [ ] `GET /api/time/magic?token=...` — UNAUTHENTICATED (token is the authority; no `authMiddleware`). Validate `token` query is present.
- [ ] Look up by `token_hash = hashToken(token)`; compare the stored plaintext `token` with the supplied token using `timingSafeEqual` (no string `===`).
- [ ] Reject (redirect to `/time?magic_error=expired|used|invalid`) if not found, `purpose != 'timer'`, `expires_at < now()`, or `used_at IS NOT NULL`.
- [ ] In a single transaction: atomically set `used_at = now()` WHERE `used_at IS NULL` (guard against replay race); if 0 rows updated → treat as already used. Then `startEntry` server-side using `token.user_id`, `token.task_id`, project derived from the task's `project_id`, `source='magic_link'`.
- [ ] Enqueue `timer.started` webhook (and `timer.stopped` for any prior running entry of that user that `startEntry` stops).
- [ ] `302` redirect to `https://app.zync.is/time?started={entryId}`.
**Acceptance:**
- [ ] A valid unused token starts a timer for `token.user_id` without any session cookie and redirects to `/time?started=<id>`.
- [ ] Replaying the same token a second time does not start a second timer (atomic `used_at` guard) and redirects with `magic_error=used`.
- [ ] Token comparison uses `timingSafeEqual`; expired tokens are rejected.

### Task 9: Cron — stale timer cleanup (`/api/cron/time-cleanup`)
**Blocks:** —  ·  **Blocked by:** 4, 5
**Files:**
- Create: `apps/zync-api/src/routes/cron/time-cleanup.ts`
- Modify: `apps/zync-api/src/index.ts` (mount under `/api/cron`)
- Modify: `apps/zync-api/wrangler.toml` (hourly cron trigger → fetch `/api/cron/time-cleanup`)
**Steps:**
- [ ] Guard with `CRON_SECRET` (constant-time header compare via `timingSafeEqual`); reject otherwise with `401`.
- [ ] Select all entries (across tenants, via `systemQuery`) where `stopped_at IS NULL AND started_at < now() - interval '2 hours'`.
- [ ] For each: stop it with `source='auto'` (apply that tenant's rounding using `stopEntry` semantics, `stopped_at = now()`), enqueue `timer.stopped` webhook, update retainer hour-bank as in Task 4.
- [ ] Return `{ stopped: <count> }`.
**Acceptance:**
- [ ] An entry running > 2h is auto-stopped with `source='auto'` and emits `timer.stopped`.
- [ ] An entry running < 2h is untouched.
- [ ] Wrong/absent `CRON_SECRET` → `401`.

### Task 10: Permissions seed (`time:*`)
**Blocks:** 5, 6, 7  ·  **Blocked by:** 1
**Files:**
- Modify: `packages/auth/src/seed/permissions.ts` (the `seedPermissions` source list)
- Modify: `packages/auth/src/seed/roles.ts` (`seedSystemRoles` role→permission grants)
**Steps:**
- [ ] Add permission keys `time:read`, `time:read_all`, `time:write`, `time:write_all` to the `permissions` seed catalog.
- [ ] Grant in `role_permissions`: `time:read`+`time:write` to member/staff roles; `time:read_all`+`time:write_all` to manager/admin/owner roles per the existing role matrix.
- [ ] Ensure the `time` module id is registered in `MODULE_MANIFEST` (module-management) so `requireModuleEnabled('time')` resolves; if absent, add a `ModuleDefinition` entry.
**Schema / Interfaces:**
```text
permissions: time:read, time:read_all, time:write, time:write_all
(magic-link creation reuses the existing tasks:write permission — no new key)
```
**Acceptance:**
- [ ] `seedPermissions` inserts the four `time:*` rows idempotently.
- [ ] Manager/owner roles resolve `time:read_all`/`time:write_all`; member roles resolve only `time:read`/`time:write`.

### Task 11: Client timer store (`useTimerStore`)
**Blocks:** 13, 15  ·  **Blocked by:** 2, 5
**Files:**
- Create: `apps/zync-app/src/stores/timer.ts`
- Create: `apps/zync-app/src/api/time.ts` (TanStack Query hooks + fetch wrappers)
**Steps:**
- [ ] Zustand store (persisted to `localStorage` via `zustand/middleware`) holding `TimerState | null`, plus `start`, `stop`, `restoreFromActive`, `tickNow`.
- [ ] `start(input)` → `POST /api/time/start`, store `{ entryId, startedAt, projectId, projectName, taskId, taskName }`; on mount, `restoreFromActive()` calls `GET /api/time/active` and rehydrates (server is source of truth for `startedAt`).
- [ ] `stop()` → `POST /api/time/:id/stop`, clear store, invalidate `['time']` queries.
- [ ] Elapsed seconds derived from `started_at` (never persisted as a counter); compute `Math.floor((Date.now()-startedAt)/1000)`.
- [ ] Define query hooks: `useActiveTimer`, `useTimeEntries(filter)`, `useWeekSummary(week)`, `useLogTime`, `useUpdateEntry`, `useDeleteEntry`, `useSendMagicLink`.
**Acceptance:**
- [ ] On reload with an active server entry, the widget resumes counting from the correct `started_at` (no drift, no client-stored counter).
- [ ] `start` while running surfaces the warning then replaces the timer.

### Task 12: `useIdleDetection` hook (`@zync/ui`)
**Blocks:** 13  ·  **Blocked by:** 2
**Files:**
- Create: `packages/ui/src/hooks/useIdleDetection.ts`
- Modify: `packages/ui/src/index.ts` (export)
**Steps:**
- [ ] Implement idle detection using the Page Visibility API + `mousemove`/`keydown`/`scroll` listeners; idle = no activity for `idleThresholdMs`.
- [ ] Track `idleStart` timestamp; expose `{ isIdle, idleSeconds, reset() }`; honor `prefers-reduced-motion` by not animating any idle indicator.
- [ ] Debounce activity events; clean up listeners on unmount; pause detection when `enabled=false` (no timer running).
**Schema / Interfaces:**
```ts
export function useIdleDetection(opts: { idleThresholdMs: number; enabled: boolean }):
  { isIdle: boolean; idleSeconds: number; reset: () => void };
```
**Acceptance:**
- [ ] After `idleThresholdMs` with no activity (and tab visible/hidden per Page Visibility), `isIdle` becomes true with accurate `idleSeconds`.
- [ ] Any tracked activity resets idle state; listeners removed on unmount.

### Task 13: Header `TimerWidget` (with a11y + idle dialog)
**Blocks:** —  ·  **Blocked by:** 5, 11, 12
**Files:**
- Create: `apps/zync-app/src/components/timer/TimerWidget.tsx`
- Create: `apps/zync-app/src/components/timer/StartTimerPopover.tsx`
- Create: `apps/zync-app/src/components/timer/IdleDialog.tsx`
- Modify: `apps/zync-app/src/components/AppHeader.tsx` (mount widget)
**Steps:**
- [ ] Idle state: "Start timer" button opens `StartTimerPopover` (project required, task optional, description); on submit calls `useTimerStore.start`.
- [ ] Running state: `▶ HH:MM:SS  ProjectName › TaskName  ✕` updated every second via `setInterval` derived from `started_at`; ✕ stops.
- [ ] Wire `useIdleDetection({ idleThresholdMs: idleThresholdMinutes*60000, enabled: timerRunning })` (threshold from tenant settings store, Task 17); when idle → show `IdleDialog` and fire `timer.auto_paused` (call a lightweight `POST /api/time/:id/stop` only on "Stop", but the auto_paused webhook fires when the dialog is shown — emit via the active entry; server emits on the stop/discard calls and a dedicated `POST /api/time/:id/auto-paused-notify` is NOT added — instead the dialog-shown event is reported through the stop/discard request metadata). IdleDialog options: **Stop timer** (stop at `started_at + (now - idleStart)`), **Keep running** (dismiss + `reset()`), **Discard idle time** (stop, subtracting idle duration).
- [ ] Accessibility (verbatim from spec): timer container `aria-live="off"`; `aria-label` on the time display updated **every 10 seconds** → `"Timer running: {h} hours {m} minutes"`; start/stop button `aria-label` `"Start timer"`/`"Stop timer"` with `aria-pressed` true/false; on stop a `role="status"` (polite) region announces `"Timer stopped at {h} hours {m} minutes {s} seconds"`.
**Acceptance:**
- [ ] Counter increments once per second; `aria-label` on the display updates only every 10s; container is `aria-live="off"`.
- [ ] Start/stop button toggles `aria-pressed` and its `aria-label`; stopping announces via a polite `role="status"` region.
- [ ] Idle dialog appears after the configured threshold with all three options behaving per spec.

### Task 14: `/time` page — week view, day groups, summary bar
**Blocks:** —  ·  **Blocked by:** 6, 11
**Files:**
- Create: `apps/zync-app/src/pages/time/TimePage.tsx`
- Create: `apps/zync-app/src/pages/time/WeekSummaryBar.tsx`
- Create: `apps/zync-app/src/pages/time/TimeEntryRow.tsx`
- Modify: `apps/zync-app/src/router.tsx` (route `/time`)
**Steps:**
- [ ] Toolbar: week selector (◄ ▶, ISO week), "+ Log time", "▶ Start timer" (opens the same popover/store as the widget).
- [ ] Week summary bar: horizontal bar chart, one column per day with total hours from `useWeekSummary`; clicking a column scrolls to that day group; bars are keyboard-focusable buttons with `aria-label="{day}: {h}h {m}m"`.
- [ ] Entry list from `useTimeEntries`, grouped by day descending; each `TimeEntryRow`: project chip, task link (if set), description, start–stop, duration, `billable` toggle, edit/delete icons.
- [ ] Delete: soft-confirm (icon turns red on hover, click confirms) → `useDeleteEntry`. Read the `?started=` query param to show a "Timer started" toast on arrival from a magic link.
- [ ] Handle `?magic_error=` query param with an error toast.
**Acceptance:**
- [ ] Entries render grouped by day descending with correct per-day and weekly totals matching `GET /api/time/summary`.
- [ ] Clicking a summary-bar column scrolls to the matching day group; bars are keyboard-operable.
- [ ] Delete requires the two-step hover→click confirm (no modal) and hard-deletes.

### Task 15: Log/Edit time Sheet
**Blocks:** —  ·  **Blocked by:** 6, 11
**Files:**
- Create: `apps/zync-app/src/pages/time/LogTimeSheet.tsx`
**Steps:**
- [ ] `Sheet` form (reuse `@zync/ui` `Sheet`): Project (required), Task (optional, filtered to project), Date, Start time, End time OR Duration, Description, Billable toggle.
- [ ] Submit → `useLogTime` (`POST /api/time`, `source='manual'`) for create; inline row edit reuses the same form bound to `useUpdateEntry` (`PATCH /api/time/:id`).
- [ ] Client validation mirrors server Zod (end > start; duration > 0); show field errors via `FormError`/`FormField`.
**Acceptance:**
- [ ] Creating a manual entry persists with `source='manual'` and the rounded duration; it appears in the correct day group.
- [ ] Inline edit updates the entry and re-rounds duration when times change.

### Task 16: Magic-link confirmation route (`/magic/time`)
**Blocks:** —  ·  **Blocked by:** 8
**Files:**
- Create: `apps/zync-app/src/pages/magic/MagicTimePage.tsx`
- Modify: `apps/zync-app/src/router.tsx` (route `/magic/time`)
- Create: `apps/zync-app/src/components/task/SendMagicLinkButton.tsx`
**Steps:**
- [ ] `/magic/time` is hit only as a fallback if the API redirect is intercepted client-side; it forwards `?token` to `GET /api/time/magic` and then lands on `/time?started=...`. Primary path is the server `302` from Task 8.
- [ ] On `/time?started={entryId}`, show "Timer started for {task title}" and ensure `useTimerStore.restoreFromActive()` runs so the running widget appears.
- [ ] `SendMagicLinkButton` (task detail / task action menu): opens a small form (recipient: self or teammate email) → `useSendMagicLink` (`POST /api/time/magic`); toast "Magic link sent". Gate visibility behind `tasks:write`.
**Acceptance:**
- [ ] Clicking a magic link results in a running timer and the confirmation message for the task.
- [ ] "Send magic link" is hidden for users without `tasks:write`.

### Task 17: Settings — idle threshold & time rounding (`/settings/time-tracking`)
**Blocks:** 13  ·  **Blocked by:** 3, 6
**Files:**
- Create: `apps/zync-app/src/pages/settings/TimeTrackingSettings.tsx`
- Modify: `apps/zync-app/src/router.tsx` (route `/settings/time-tracking`)
- Create: `apps/zync-api/src/routes/settings/time-tracking.ts` (GET/PATCH tenant time settings)
- Modify: `apps/zync-api/src/index.ts` (mount)
- Create: `apps/zync-app/src/stores/tenantSettings.ts` (load idle threshold + rounding on app init into Zustand)
**Steps:**
- [ ] API `GET /api/settings/time-tracking` (`time:read`) → `{ idleTimerThresholdMinutes, timeRounding }`; `PATCH` (`settings:modules:write` or owner) Zod `{ idleTimerThresholdMinutes?: int 1..120, timeRounding?: TimeRoundingMode }`; update `tenant_settings` (those two columns only — never touch expense-owned columns).
- [ ] UI: "Idle auto-pause threshold" numeric (minutes, default 10) and "Time rounding" `Select` populated from `TIME_ROUNDING_MODES`/`TIME_ROUNDING_LABELS`; below the dropdown render the live example "Example: 8m 40s entered → rounds to {X}min" via `formatRoundingExample`.
- [ ] On app init, load both values into `useTenantSettingsStore` so `TimerWidget` reads `idleTimerThresholdMinutes` and stop/round logic stays consistent.
**Acceptance:**
- [ ] Changing rounding persists to `tenant_settings.time_rounding` and the example preview updates live; expense-owned columns untouched.
- [ ] Changing the idle threshold persists to `idle_timer_threshold_minutes` and is reflected in the widget's idle detection on next load.

### Task 18: Magic-link email templates
**Blocks:** —  ·  **Blocked by:** 7
**Files:**
- Create: `packages/notifications/src/templates/timer-magic-link.he.mjml`
- Create: `packages/notifications/src/templates/timer-magic-link.en.mjml`
- Modify: `packages/notifications/src/templates/index.ts` (register `timer_magic_link_he`/`timer_magic_link_en`)
**Steps:**
- [ ] He + En templates: subject "Start timer: {{taskTitle}}", body with a primary button linking to `{{link}}`.
- [ ] Hebrew template sets `dir="rtl"` and `lang="he"` on `<body>` per the i18n RTL-email requirement; en sets `lang="en"`.
- [ ] Register both keys so `sendEmail({ templateKey: 'timer_magic_link_he'|'timer_magic_link_en', locale })` resolves.
**Acceptance:**
- [ ] `sendEmail` renders the correct locale template; Hebrew email body carries `dir="rtl"`.
- [ ] The rendered email contains the magic URL `https://app.zync.is/magic/time?token=...`.
