# Settings: Time Tracking Page (`/settings/time-tracking`) — Implementation Plan

**Spec:** docs/specs/2026-06-01-settings-time-tracking.md  ·  **Slug:** settings-time-tracking  ·  **Wave:** 10
**Depends on:** foundation-auth-rbac, settings-module, time-management

## Goal
Deliver the `/settings/time-tracking` configuration page that exposes the tenant-level knobs governing time tracking behavior defined by the `time-management` module: rounding, minimum billable duration, idle detection, overtime flagging, contractor-time enablement, mileage opt-in, and magic-link time tracking. The page reads/writes a set of columns on the shared `tenant_settings` table and renders inside the settings shell owned by `settings-module`. It surfaces outbound links (mileage rate, manage vehicles) to features owned by other specs but does not own that data.

## Architecture
- **Data layer:** This page adds 9 columns to the existing `tenant_settings` table via an `ALTER TABLE` migration. The table is NOT created here — it is first introduced by the `time-management` dependency (which adds `time_rounding`). This page reads/writes `time_rounding` (owned by `time-management`) but does not re-add it.
- **API layer:** Two Hono routes mounted on the `apps/zync-api` worker — `GET /api/settings/time-tracking` (`settings:read`) and `PATCH /api/settings/time-tracking` (`settings:write`). Both use `authMiddleware` for the session, `requirePermission` for the scope gate, and `tenantQuery` for tenant-scoped Drizzle reads/writes. Zod validation via `@hono/zod-validator` on PATCH (all fields optional — "any subset").
- **UI layer:** A React page in `apps/zync-app` at route `/settings/time-tracking`, rendered inside the settings shell from `settings-module`. Built from `@zync/ui` primitives (`Select`, `Input`, `Switch`, `Button`, `Card`, `FormField`, `FormLabel`). A TanStack Query hook fetches settings; a mutation hook PATCHes them.
- **Scope of ownership (do NOT widen):** This page owns exactly the 9 columns below plus read/write of `time_rounding`. It does NOT own contractor approval policy (`contractor_require_time_approval` — owned by `time-management` wave 5, edited via spec 148 `/settings/contractors`), the mileage rate, or vehicles (owned by `mileage-logbook` spec 166). The mock's "Manage vehicles →" / "Update rate →" controls are outbound links only.

## Tech Stack
- **Apps:** `apps/zync-api` (Hono routes), `apps/zync-app` (Vite+React settings page).
- **Packages:** `@zync/db` (Drizzle schema + `tenantQuery`), `@zync/auth` (`authMiddleware`, `requirePermission`), `@zync/ui` (form primitives), `@zync/types`.
- **Libraries:** Drizzle ORM, `@hono/zod-validator` + `zod`, TanStack Query.
- **Cloudflare bindings:** Neon Postgres via Hyperdrive (`Env.DB`/`Env.HYPERDRIVE`), Workers runtime.
- **Permissions:** `settings:read`, `settings:write` (seeded by `foundation-auth-rbac`).

## Wave Plan
| Sub-wave | Tasks | Files touched | Parallelizable? |
|----------|-------|---------------|-----------------|
| A | 1 | `packages/db` schema + migration | No (blocks all) |
| B | 2, 3 | `apps/zync-api` accessors + routes | Task 3 after 2 |
| C | 4, 5 | `apps/zync-app` data hooks + page UI | After Task 3; 5 after 4 |
| D | 6 | settings nav registration | After Task 4 |

## Tasks

### Task 1: Add time-tracking columns to `tenant_settings`
**Blocks:** 2, 3, 4  ·  **Blocked by:** — (assumes `tenant_settings` table already exists from `time-management`, which runs in wave 5; do NOT create the table here)
**Files:**
- Modify: `packages/db/src/schema/tenant-settings.ts` (Drizzle table definition — add 9 columns)
- Create: `packages/db/migrations/<timestamp>_settings_time_tracking_columns.sql`
**Steps:**
- [ ] Append the 9 columns to the existing `tenant_settings` Drizzle table object (do NOT redeclare `time_rounding` — it is owned by `time-management`; reference the existing column).
- [ ] Write the raw SQL migration with the exact `ALTER TABLE tenant_settings ADD COLUMN` statements below.
- [ ] Note in a migration comment: column `time_idle_threshold_minutes` is the canonical idle-threshold column owned by THIS page. The `time-management` spec prose references `idle_timer_threshold_minutes`, but its actual schema delta adds only `time_rounding`; do NOT create a second idle column.
- [ ] Regenerate Drizzle types so `@zync/db` exports the updated `tenant_settings` row type.
**Schema / Interfaces:**
```sql
-- tenant_settings already exists (table introduced by time-management, which ALSO adds:
--   time_rounding TEXT NOT NULL DEFAULT 'none'
--     CHECK (time_rounding IN ('none','nearest_5','nearest_15','nearest_30','up_15','up_30'))
-- This page reads/writes time_rounding but does NOT re-add it.)

ALTER TABLE tenant_settings ADD COLUMN time_min_billable_minutes INTEGER NOT NULL DEFAULT 0;
  -- Minimum entry duration in minutes; 0 = no minimum

ALTER TABLE tenant_settings ADD COLUMN time_idle_threshold_minutes INTEGER NOT NULL DEFAULT 10;
  -- Minutes of inactivity before idle prompt (valid range 5-60, enforced in API zod)

ALTER TABLE tenant_settings ADD COLUMN time_auto_pause_on_idle BOOLEAN NOT NULL DEFAULT true;
  -- true = show "Resume or discard idle time?" on return

ALTER TABLE tenant_settings ADD COLUMN time_standard_hours_per_day NUMERIC(4,2) NOT NULL DEFAULT 8.0;
  -- Standard working hours/day for overtime flagging

ALTER TABLE tenant_settings ADD COLUMN time_flag_overtime BOOLEAN NOT NULL DEFAULT false;
  -- Flag entries exceeding time_standard_hours_per_day in reports

ALTER TABLE tenant_settings ADD COLUMN time_require_overtime_approval BOOLEAN NOT NULL DEFAULT false;
  -- Overtime entries require manager approval before billing

ALTER TABLE tenant_settings ADD COLUMN contractor_time_enabled BOOLEAN NOT NULL DEFAULT true;
  -- Allow contractors to submit time entries via contractor portal
  -- (contractor APPROVAL policy lives on /settings/contractors, spec 148 — NOT here)

ALTER TABLE tenant_settings ADD COLUMN mileage_enabled BOOLEAN NOT NULL DEFAULT false;
  -- Enable mileage logbook module (spec 166)

ALTER TABLE tenant_settings ADD COLUMN time_magic_link_enabled BOOLEAN NOT NULL DEFAULT true;
  -- Allow task magic link time tracking (spec 13 / time-management)
```
**Acceptance:**
- [ ] Migration applies cleanly against a Neon branch that already has `tenant_settings` + `time_rounding`.
- [ ] No `CREATE TABLE tenant_settings` statement is emitted anywhere in this task.
- [ ] `time_rounding` is referenced, not re-added; no duplicate idle column is created.
- [ ] Drizzle row type for `tenant_settings` includes the 9 new columns with correct TS types (number/boolean/string).

### Task 2: Tenant-settings accessor functions for time tracking
**Blocks:** 3  ·  **Blocked by:** 1
**Files:**
- Create: `apps/zync-api/src/routes/settings/time-tracking.accessors.ts`
**Steps:**
- [ ] Implement `getTimeTrackingSettings(db, tenantId)` returning the 10-field projection (9 owned columns + `time_rounding`) via `tenantQuery` (tenant-scoped Drizzle select on `tenant_settings`).
- [ ] Implement `updateTimeTrackingSettings(db, tenantId, patch)` that UPDATEs only the provided keys on the tenant's `tenant_settings` row via `tenantQuery`, returning the updated projection.
- [ ] Do NOT reuse `getTenantSettings`/`upsertTenantSettings` from the locked sheet — those are `ai_tenant_settings`-scoped accessors. Read/write `tenant_settings` columns directly via `tenantQuery` + Drizzle.
- [ ] Coerce `time_standard_hours_per_day` (NUMERIC) to a `number` on read for JSON output.
**Schema / Interfaces:**
```typescript
export interface TimeTrackingSettings {
  time_rounding: 'none' | 'nearest_5' | 'nearest_15' | 'nearest_30' | 'up_15' | 'up_30';
  time_min_billable_minutes: number;
  time_idle_threshold_minutes: number;
  time_auto_pause_on_idle: boolean;
  time_standard_hours_per_day: number;
  time_flag_overtime: boolean;
  time_require_overtime_approval: boolean;
  contractor_time_enabled: boolean;
  mileage_enabled: boolean;
  time_magic_link_enabled: boolean;
}

export function getTimeTrackingSettings(
  db: Db, tenantId: TenantId,
): Promise<TimeTrackingSettings>;

export function updateTimeTrackingSettings(
  db: Db, tenantId: TenantId, patch: Partial<TimeTrackingSettings>,
): Promise<TimeTrackingSettings>;
```
**Acceptance:**
- [ ] `getTimeTrackingSettings` returns all 10 fields for an existing tenant.
- [ ] `updateTimeTrackingSettings` with a partial object updates only those columns and leaves others unchanged.
- [ ] All queries are tenant-scoped through `tenantQuery`; no cross-tenant leakage.

### Task 3: API routes `GET` / `PATCH /api/settings/time-tracking`
**Blocks:** 4  ·  **Blocked by:** 2
**Files:**
- Create: `apps/zync-api/src/routes/settings/time-tracking.ts`
- Modify: `apps/zync-api/src/routes/settings/index.ts` (mount the sub-router)
**Steps:**
- [ ] Define a zod schema `timeTrackingPatchSchema` where EVERY field is optional (PATCH accepts any subset). Include exactly the 9 owned columns plus `time_rounding`. Do NOT include `contractor_require_time_approval` (owned by `time-management`, edited via spec 148) or any mileage rate / vehicle field.
- [ ] `GET /api/settings/time-tracking`: `authMiddleware` → `requirePermission('settings:read')` → call `getTimeTrackingSettings(db, session.tenant_id)` → JSON.
- [ ] `PATCH /api/settings/time-tracking`: `authMiddleware` → `requirePermission('settings:write')` → `zValidator('json', timeTrackingPatchSchema)` → call `updateTimeTrackingSettings(db, session.tenant_id, body)` → JSON updated settings.
- [ ] Return `400` on validation failure via the standard `ApiError` shape.
**Schema / Interfaces:**
```typescript
import { z } from 'zod';

export const timeTrackingPatchSchema = z.object({
  time_rounding: z.enum(['none','nearest_5','nearest_15','nearest_30','up_15','up_30']).optional(),
  time_min_billable_minutes: z.number().int().min(0).optional(),
  time_idle_threshold_minutes: z.number().int().min(5).max(60).optional(),
  time_auto_pause_on_idle: z.boolean().optional(),
  time_standard_hours_per_day: z.number().min(0).max(24).optional(),
  time_flag_overtime: z.boolean().optional(),
  time_require_overtime_approval: z.boolean().optional(),
  contractor_time_enabled: z.boolean().optional(),
  mileage_enabled: z.boolean().optional(),
  time_magic_link_enabled: z.boolean().optional(),
}).strict();
// .strict() rejects unknown keys so contractor approval / mileage-rate fields cannot leak in.

// Routes:
// GET   /api/settings/time-tracking  -> TimeTrackingSettings           (settings:read)
// PATCH /api/settings/time-tracking  -> TimeTrackingSettings (updated)  (settings:write)
```
**Acceptance:**
- [ ] `GET` returns 200 with exactly the 10 keys; 403 without `settings:read`.
- [ ] `PATCH` with a partial body succeeds and returns the updated row; 403 without `settings:write`.
- [ ] `PATCH` with `time_idle_threshold_minutes: 3` or `: 61` returns 400 (range 5–60).
- [ ] `PATCH` with an unknown key (e.g. `contractor_require_time_approval`) returns 400 (`.strict()`).
- [ ] Response body never includes contractor approval policy or mileage-rate/vehicle fields.

### Task 4: React data hooks (query + mutation)
**Blocks:** 5, 6  ·  **Blocked by:** 3
**Files:**
- Create: `apps/zync-app/src/features/settings/time-tracking/useTimeTrackingSettings.ts`
**Steps:**
- [ ] `useTimeTrackingSettings()` — TanStack Query hook GETting `/api/settings/time-tracking`, keyed `['settings','time-tracking']`.
- [ ] `useUpdateTimeTrackingSettings()` — mutation PATCHing the endpoint, invalidating the query key on success and firing a success `toast`.
- [ ] Surface error state for the page to render an `ErrorState`.
**Schema / Interfaces:**
```typescript
export function useTimeTrackingSettings(): UseQueryResult<TimeTrackingSettings>;
export function useUpdateTimeTrackingSettings(): UseMutationResult<
  TimeTrackingSettings, ApiError, Partial<TimeTrackingSettings>
>;
```
**Acceptance:**
- [ ] Query loads settings and exposes loading/error/data states.
- [ ] Mutation PATCHes, invalidates `['settings','time-tracking']`, and toasts on success.

### Task 5: Time Tracking settings page UI
**Blocks:** —  ·  **Blocked by:** 4
**Files:**
- Create: `apps/zync-app/src/features/settings/time-tracking/TimeTrackingSettingsPage.tsx`
- Modify: `apps/zync-app/src/app/router.tsx` (register `/settings/time-tracking` route)
**Steps:**
- [ ] Render the form inside the settings shell with sections: General, Idle Detection, Overtime, Contractor Time, Mileage & Vehicles, Integrations.
- [ ] **General:** `Select` for `time_rounding` (6 options: No rounding, Nearest 5/15/30 min, Always round up 15/30); live example text "8m 40s → rounds to {X} with current setting" computed client-side from the selected mode; `Input type=number` for `time_min_billable_minutes` with helper "(0 = no minimum)".
- [ ] **Idle Detection:** `Input type=number` for `time_idle_threshold_minutes` (5–60); `Switch` for `time_auto_pause_on_idle`. Render the mock's "Show idle warning in timer widget" checkbox as a client-only/local UI affordance only — there is NO backing column or API field for it; do NOT add one or send it in the PATCH.
- [ ] **Overtime:** `Input type=number` for `time_standard_hours_per_day`; `Switch` for `time_flag_overtime`; `Switch` for `time_require_overtime_approval`.
- [ ] **Contractor Time:** `Switch` for `contractor_time_enabled`. Render the contractor approval policy as read-only descriptive text with a link to `/settings/contractors` — it is owned by spec 148 and is NOT editable or sent here.
- [ ] **Mileage & Vehicles:** `Switch` for `mileage_enabled`; display the mileage rate as read-only text with an outbound "Update rate →" link and a "Manage vehicles →" link (both owned by mileage-logbook spec 166 — links only, no data ownership).
- [ ] **Integrations:** `Switch` for `time_magic_link_enabled` with helper text.
- [ ] Single "Save settings" `Button` collects all dirty fields and calls the mutation with only changed keys.
- [ ] Gate the page so non-`settings:read` users see an access-denied/empty state; disable all inputs + Save for users lacking `settings:write` (read-only view).
- [ ] **Cross-cutting:** all labels via i18n translations (`@zync/ui` `useDirection`/`LocaleProvider`) with Hebrew/RTL support; form controls keyboard-navigable with proper `aria-label`/`aria-describedby` on each field and helper text; respect `prefers-reduced-motion` for any transitions; no hardcoded colors/spacing (use design tokens).
**Acceptance:**
- [ ] All 6 sections render with the correct control type per field.
- [ ] Rounding example text updates live when the dropdown changes.
- [ ] "Show idle warning" checkbox is NOT included in the PATCH payload (no backing column).
- [ ] Contractor approval and mileage rate/vehicles appear only as read-only text + outbound links.
- [ ] Users with only `settings:read` see disabled inputs and no Save; `settings:write` users can save.
- [ ] Page is RTL-correct in Hebrew, keyboard-navigable, and uses tokens (no hardcoded colors/spacing).

### Task 6: Register route in the settings navigation manifest
**Blocks:** —  ·  **Blocked by:** 4
**Files:**
- Modify: `apps/zync-app/src/features/settings/settingsNav.ts` (the settings sidebar manifest owned by `settings-module`)
**Steps:**
- [ ] Add the `/settings/time-tracking` entry to the settings nav manifest: label "Time Tracking", tier "All", `settings:read` gate, placed per the manifest ordering (after `/settings/expenses`, before `/settings/contractors`).
- [ ] Ensure the label is i18n-keyed.
**Acceptance:**
- [ ] "Time Tracking" appears in the settings sidebar for users with `settings:read`.
- [ ] Clicking it navigates to `/settings/time-tracking` rendered in the settings shell.
