# Team Time Overview (Manager View) — Implementation Plan

**Spec:** docs/specs/2026-05-31-team-time-overview.md  ·  **Slug:** team-time-overview  ·  **Wave:** 6
**Depends on:** foundation-auth-rbac, projects-module, time-management

## Goal
Deliver a manager-facing live operational view at `/time/team` (zync-app) showing who on the team is tracking time right now, what project/task they are on, whether they have gone idle, and per-person + team totals for the day. It is distinct from historical time reports (spec 56): this is near-real-time status via 60-second polling. Access is restricted to OWNER/ADMIN with `time:read`; MEMBERs never see colleagues' live activity (privacy boundary). Contractors working on the tenant's projects appear in the same unified list.

## Architecture
- **Data source — read-only consumer.** This spec owns NO new domain tables. It reads `time_entries` (from `time-management`), `projects` / `project_members` (from `projects-module`), `contractors` / `contractor_assignments` (from `contractor-payouts`, already shipped upstream of wave 6 via `time-management` integration), `users` and `tenant_memberships` / `roles` (from `foundation-auth-rbac`).
- **Schema delta it DOES own.** The spec's Architecture Decisions table states the idle marker "comes from `time_entries.idle_since` set by spec 13's beacon handler", but `time-management`'s `time_entries` DDL never declares that column. This plan adds `idle_since TIMESTAMPTZ` to `time_entries` as an additive, nullable migration (Task 1). Live-status classification (ACTIVE vs IDLE) derives from it.
- **Live status classification.** For a running entry (`stopped_at IS NULL`): if `idle_since IS NOT NULL` and `now() - idle_since >= 15 min` → IDLE; else → ACTIVE. The "No timer" state is no running entry for the requested day.
- **Server query layer.** Two tenant-scoped query helpers (`getTeamTimerStatus`, `getTeamDayEntries`) built with the existing `tenantQuery` helper from `@zync/db`. They union staff rows (driven by `tenant_memberships`) with contractor rows (driven by `contractor_assignments` scoped to the tenant's projects). "Today total" and the day's completed entries are aggregated by `user_id` / `contractor_id`.
- **API.** Two Hono routes on the zync-api worker: `GET /api/time/team/status` (live) and `GET /api/time/team/day?date=YYYY-MM-DD` (historical, no live status). Both gated by `requirePermission('time:read')` + an OWNER/ADMIN role assertion, both run under `requireModuleEnabled('time')`.
- **Client.** A React route `/time/team` in zync-app rendered as a sub-navigation tab inside the existing time module shell. Live data fetched with react-query `refetchInterval: 60_000`. Running durations are extrapolated client-side from `startedAt` with a 1-second `setInterval` so the wall-clock counter increments without re-fetching. Rows expand to show the person's completed entries for the day. A date stepper switches to the historical `/day` endpoint (live indicators suppressed for past dates).

## Tech Stack
- **Apps/packages:** `apps/zync-api` (Hono routes + query helpers, or `packages/time` for shared query/serializer code), `apps/zync-app` (React + Vite page, react-query, Zustand for the per-second tick), `packages/db` (Drizzle schema delta + migration), `packages/ui` (existing primitives: `Card`, `Badge`, `StatCard`, `EmptyState`, `Spinner`, `Avatar`).
- **Libraries:** Drizzle ORM, Hono, `@tanstack/react-query`, Zod (query param validation), `date-fns` / `Intl` for day boundaries in the tenant timezone.
- **Cloudflare bindings:** Neon Postgres via Hyperdrive (`DB`/`Db`). No `DO_REALTIME`, no `QUEUE`, no WebSocket — explicitly avoided per the spec's polling decision.
- **Cross-cutting:** RTL/Hebrew safe (logical CSS, `useDirection`), `prefers-reduced-motion` honored on the live pulse indicator, `aria-live` used sparingly (see Task 6), no hardcoded colors/spacing/radius (design-token lint rules).

## Wave Plan
| Sub-wave | Tasks | Files touched | Parallelizable? |
|----------|-------|---------------|-----------------|
| 6a | 1 | `packages/db/src/schema/time.ts`, migration SQL | No (foundation for all) |
| 6b | 2, 3 | `packages/time/src/team-overview.ts`, serializers | Yes (3 depends only on types from 2) |
| 6c | 4 | `apps/zync-api/src/routes/time-team.ts` | After 2,3 |
| 6d | 5, 6, 7 | `apps/zync-app/src/routes/time/team/*` | 6,7 parallel after 5 |
| 6e | 8 | tests | After 4,5 |

## Tasks

### Task 1: Add `idle_since` to `time_entries` (schema delta + migration)
**Blocks:** 2, 3  ·  **Blocked by:** —
**Files:**
- Modify: `packages/db/src/schema/time.ts` (Drizzle `time_entries` table definition owned by time-management)
- Create: `packages/db/migrations/<timestamp>_team_time_overview_idle_since.sql`
**Steps:**
- [ ] Add a nullable `idle_since` timestamp column to the Drizzle `time_entries` table definition (additive; does not alter existing columns).
- [ ] Write the forward migration adding the column with no default (NULL = active/never idle).
- [ ] Add a partial index supporting the live-status lookup: running entries per tenant.
- [ ] Confirm `idle_since` is exported in the table's inferred row type so query helpers can read it.
**Schema / Interfaces:**
```sql
-- Additive migration — column referenced by spec 64 but not declared in time-management DDL.
ALTER TABLE time_entries
  ADD COLUMN idle_since TIMESTAMPTZ;   -- NULL = not idle; set by spec 13 beacon/idle handler

-- Supports GET /api/time/team/status: fetch all running entries for a tenant.
CREATE INDEX IF NOT EXISTS idx_time_entries_running
  ON time_entries (tenant_id)
  WHERE stopped_at IS NULL;
```
```ts
// Drizzle delta (within the existing timeEntries pgTable in packages/db/src/schema/time.ts)
idleSince: timestamp('idle_since', { withTimezone: true }), // nullable
```
**Acceptance:**
- [ ] Migration applies cleanly on a Neon branch; `time_entries.idle_since` exists, nullable, `TIMESTAMPTZ`.
- [ ] `idx_time_entries_running` exists as a partial index on `stopped_at IS NULL`.
- [ ] Existing time-management reads/writes are unaffected (no NOT NULL, no default change).

### Task 2: Team-overview query helpers (`getTeamTimerStatus`, `getTeamDayEntries`)
**Blocks:** 3, 4  ·  **Blocked by:** 1
**Files:**
- Create: `packages/time/src/team-overview.ts`
- Modify: `packages/time/src/index.ts` (export the helpers + types)
**Steps:**
- [ ] Implement `getTeamTimerStatus(db, ctx)` returning, for every active staff member and every contractor assigned to the tenant's projects: identity (`userId` or `contractorId`, `name`, `role`), the running entry (if any) joined to its project + task names, and the day's total billed-or-not seconds.
- [ ] Enumerate staff via `tenant_memberships` (status `'active'`) joined to `users` and `roles` (for the role label). Enumerate contractors via `contractor_assignments` joined to `contractors`, scoped to projects whose `tenant_id` matches; de-duplicate contractors assigned to multiple projects.
- [ ] Compute `todayTotal` as the sum of `duration_seconds` for completed entries that day PLUS, for any currently running entry, the live elapsed `EXTRACT(EPOCH FROM now() - started_at)` (so totals include the in-flight timer).
- [ ] Compute `runningEntry.durationSeconds` from `now() - started_at`. Set `runningEntry.idleSince = idle_since` when present.
- [ ] Day boundaries computed in the tenant timezone (`tenants.default_timezone`); "today" = the tenant-local calendar day.
- [ ] Implement `getTeamDayEntries(db, ctx, date)` returning the same per-member shape but with `runningEntry = null` and a populated `entries[]` list of completed entries for the given date (project name, task name, startedAt, stoppedAt, durationSeconds). No live extrapolation for historical dates.
- [ ] Both helpers MUST run through `tenantQuery` so every read is tenant-scoped; never accept a raw tenant id from the client.
- [ ] Sort members: running-with-activity first, then idle, then no-timer; contractors interleaved by the same rule (not a separate group), matching the spec's unified-view decision.
**Schema / Interfaces:**
```ts
export interface TeamTimerRunningEntry {
  id: string;
  projectId: string;
  projectName: string;
  taskId: string | null;
  taskName: string | null;
  startedAt: string;          // ISO 8601
  durationSeconds: number;    // now() - startedAt at query time
  idleSince?: string | null;  // ISO 8601 when idle marker set
}

export interface TeamMemberStatus {
  userId: string | null;        // null for contractor rows
  contractorId: string | null;  // null for staff rows
  name: string;
  role: string;                 // 'OWNER' | 'ADMIN' | 'MEMBER' | 'VIEWER' | 'CONTRACTOR'
  runningEntry: TeamTimerRunningEntry | null;
  todayTotal: number;           // seconds (includes live running time)
}

export interface TeamDayEntry {
  id: string;
  projectId: string;
  projectName: string;
  taskId: string | null;
  taskName: string | null;
  startedAt: string;            // ISO 8601
  stoppedAt: string;            // ISO 8601 (completed only)
  durationSeconds: number;
}

export interface TeamDayMember {
  userId: string | null;
  contractorId: string | null;
  name: string;
  role: string;
  entries: TeamDayEntry[];
  todayTotal: number;           // sum of completed durationSeconds for the date
}

export interface TeamTimerStatusContext {
  tenantId: string;
  timezone: string;             // tenants.default_timezone
}

export function getTeamTimerStatus(
  db: Db,
  ctx: TeamTimerStatusContext,
): Promise<TeamMemberStatus[]>;

export function getTeamDayEntries(
  db: Db,
  ctx: TeamTimerStatusContext,
  date: string,                 // YYYY-MM-DD (tenant-local)
): Promise<TeamDayMember[]>;
```
**Acceptance:**
- [ ] A running staff entry surfaces with correct `projectName`/`taskName` and a `durationSeconds` close to `now() - started_at`.
- [ ] A contractor with a running entry appears in the same list (not segregated).
- [ ] A member with no running entry returns `runningEntry: null` and a `todayTotal` equal to the sum of their completed entries that day.
- [ ] `getTeamDayEntries` for a past date returns completed entries only and `runningEntry`-equivalent live fields are absent.
- [ ] Cross-tenant rows never leak (verified with a two-tenant fixture).

### Task 3: Live-status derivation + response serializers
**Blocks:** 4  ·  **Blocked by:** 2
**Files:**
- Create: `packages/time/src/team-overview-status.ts`
- Modify: `packages/time/src/index.ts` (export serializers + the status enum)
**Steps:**
- [ ] Implement `classifyTimerStatus(member)` returning `'active' | 'idle' | 'none'`: `none` when `runningEntry` is null; `idle` when `idleSince` set and `now - idleSince >= 15 min`; otherwise `active`. (15-min threshold per spec; consistent with spec 13 idle definition.)
- [ ] Implement `idleMinutes(member)` returning whole minutes since `idleSince` for the "IDLE 23 min" label, or null.
- [ ] Implement `serializeTeamStatusResponse(members)` producing the exact `{ members: [...] }` shape from the spec's API contract.
- [ ] Implement `serializeTeamDayResponse(members)` for the `/day` endpoint (entry lists, no live status).
- [ ] Implement `summarizeTeam(members)` → `{ teamTotalToday, activeCount, idleCount }` for the Summary footer (`idleCount` counts only `idle` per the >15-min rule).
**Schema / Interfaces:**
```ts
export type TeamTimerStatus = 'active' | 'idle' | 'none';

export function classifyTimerStatus(member: TeamMemberStatus): TeamTimerStatus;
export function idleMinutes(member: TeamMemberStatus): number | null;

export interface TeamStatusResponse {
  members: Array<{
    userId: string | null;
    contractorId: string | null;
    name: string;
    role: string;
    runningEntry: TeamTimerRunningEntry | null;
    todayTotal: number;
  }>;
}
export function serializeTeamStatusResponse(members: TeamMemberStatus[]): TeamStatusResponse;

export interface TeamDayResponse {
  date: string;
  members: TeamDayMember[];
}
export function serializeTeamDayResponse(date: string, members: TeamDayMember[]): TeamDayResponse;

export interface TeamSummary {
  teamTotalToday: number;  // seconds
  activeCount: number;
  idleCount: number;       // idle > 15 min
}
export function summarizeTeam(members: TeamMemberStatus[]): TeamSummary;
```
**Acceptance:**
- [ ] `classifyTimerStatus` returns `idle` exactly when a running entry has `idleSince` older than 15 minutes.
- [ ] `summarizeTeam` `activeCount`/`idleCount` match the per-member classification; `teamTotalToday` equals the sum of every member's `todayTotal`.
- [ ] Serialized response matches the spec's documented JSON keys verbatim (`userId`, `contractorId`, `name`, `role`, `runningEntry{id,projectId,projectName,taskId,taskName,startedAt,durationSeconds,idleSince?}`, `todayTotal`).

### Task 4: API routes `GET /api/time/team/status` and `GET /api/time/team/day`
**Blocks:** 5, 8  ·  **Blocked by:** 2, 3
**Files:**
- Create: `apps/zync-api/src/routes/time-team.ts`
- Modify: `apps/zync-api/src/routes/index.ts` (mount the router under the time module)
**Steps:**
- [ ] Define a Hono sub-router. Apply `authMiddleware`, then `requireModuleEnabled('time')`, then `requirePermission('time:read')`.
- [ ] Add an OWNER/ADMIN role gate: after permission passes, assert the caller's tenant role is `OWNER` or `ADMIN`; otherwise respond `403` (MEMBER privacy boundary). Implement as a small inline guard or reuse a role-assertion helper from `@zync/auth`.
- [ ] `GET /status`: resolve tenant timezone from session/tenant context, call `getTeamTimerStatus`, then `serializeTeamStatusResponse`; include the `summarizeTeam` result in the payload for the footer. Contractors included because caller is OWNER/ADMIN.
- [ ] `GET /day`: validate `date` query param with Zod (`YYYY-MM-DD`, default to today tenant-local), call `getTeamDayEntries`, return `serializeTeamDayResponse`.
- [ ] Set `Cache-Control: no-store` on both responses (live operational data).
- [ ] All DB access via `tenantQuery`; no raw Drizzle calls in the route body (lint: `no-raw-drizzle-from-routes`). All inputs Zod-validated (lint: `require-zod-validation-in-routes`).
**Schema / Interfaces:**
```ts
// GET /api/time/team/status
//   auth: time:read + role OWNER|ADMIN, module 'time' enabled
//   200 → { members: TeamStatusResponse['members'], summary: TeamSummary }
//   403 → role not OWNER/ADMIN
//
// GET /api/time/team/day?date=YYYY-MM-DD
//   auth: same
//   200 → { date: string, members: TeamDayMember[] }
const dayQuerySchema = z.object({
  date: z.string().regex(/^\d{4}-\d{2}-\d{2}$/).optional(),
});
```
**Acceptance:**
- [ ] OWNER and ADMIN receive `200`; MEMBER and VIEWER receive `403` on both routes.
- [ ] Request without `time:read` permission receives `403`; unauthenticated receives `401`.
- [ ] With the `time` module disabled, both routes are blocked by `requireModuleEnabled`.
- [ ] `/status` payload includes `members` and a `summary` with `teamTotalToday`, `activeCount`, `idleCount`.
- [ ] `/day` rejects malformed `date` (e.g. `2026-13-40`) with `400`.
- [ ] Both responses carry `Cache-Control: no-store`.

### Task 5: `/time/team` route + data hooks (zync-app)
**Blocks:** 6, 7, 8  ·  **Blocked by:** 4
**Files:**
- Create: `apps/zync-app/src/routes/time/team/index.tsx`
- Create: `apps/zync-app/src/routes/time/team/useTeamTimer.ts`
- Modify: `apps/zync-app/src/routes/time/_layout.tsx` (add the "Team" sub-navigation tab, OWNER/ADMIN only)
**Steps:**
- [ ] Add a "Team" tab to the time module sub-navigation, rendered only when the current role is OWNER or ADMIN (use the existing role/permission context; MEMBERs must not see the tab).
- [ ] Implement `useTeamTimerStatus()` (react-query) hitting `GET /api/time/team/status` with `refetchInterval: 60_000`, `refetchOnWindowFocus: true`.
- [ ] Implement `useTeamDayEntries(date)` hitting `GET /api/time/team/day?date=...`; disabled when `date` is today (today uses the live status hook).
- [ ] Page composes a header (title + date stepper), the member list, and the summary footer. When `date` is today → live mode (status hook); when past → historical mode (day hook, no live indicators).
- [ ] Guard the route: if a MEMBER/VIEWER navigates directly, show the `ErrorPage`/`EmptyState` (403) rather than the table.
- [ ] "View in Time log →" link points to `/time?userId={id}&date={date}` (the spec 13 time log filtered to user + date).
**Schema / Interfaces:**
```ts
export function useTeamTimerStatus(): UseQueryResult<{
  members: TeamStatusResponse['members'];
  summary: TeamSummary;
}>;

export function useTeamDayEntries(date: string): UseQueryResult<TeamDayResponse>;
```
**Acceptance:**
- [ ] The "Team" tab appears for OWNER/ADMIN and is absent for MEMBER/VIEWER.
- [ ] Live mode refetches `/status` every 60 seconds.
- [ ] Switching the date stepper to a past day fetches `/day` and hides live status indicators.
- [ ] Direct navigation by a non-manager renders a 403 state, not data.
- [ ] "View in Time log →" navigates to `/time?userId=...&date=...`.

### Task 6: Member row, live counter, expandable detail (UI)
**Blocks:** 8  ·  **Blocked by:** 5
**Files:**
- Create: `apps/zync-app/src/routes/time/team/TeamMemberRow.tsx`
- Create: `apps/zync-app/src/routes/time/team/useLiveCounter.ts`
**Steps:**
- [ ] Render each member row: status glyph + label (`ACTIVE` green / `IDLE {n} min` amber / `No timer` muted), name (with `Avatar`), project · task line, "Running/Paused: Hh Mm", "Today: Hh Mm". Use `Badge` for the status pill and design tokens only (no hardcoded colors/spacing).
- [ ] Implement `useLiveCounter(startedAt, baseSeconds)`: a 1-second `setInterval` that extrapolates running/today durations client-side between 60s refetches; cleared on unmount. Only runs in live mode (today).
- [ ] Status classification on the client mirrors the server (`classifyTimerStatus`): ACTIVE when running and not idle-15m, IDLE with minutes when `idleSince` older than 15 min, No timer otherwise.
- [ ] Click a row to expand: list the member's day entries (`HH:MM–HH:MM` or `HH:MM–now`, `Project / Task`, duration), plus the "View in Time log →" link. In live mode the open entry shows `–now`.
- [ ] **A11y:** the row toggle is a real `button` with `aria-expanded`; expanded panel has an `id` referenced by `aria-controls`. The per-second live counter element is `aria-live="off"`; instead update its `aria-label` every 10 seconds to `"{name}: running {h} hours {m} minutes"` (mirrors time-management's timer-accessibility rule — do not flood SR with per-second updates). Status changes (active→idle) announce once via a single shared `role="status"` polite region, not per row.
- [ ] **Reduced motion:** the ACTIVE pulse/animation is gated behind `prefers-reduced-motion: no-preference`; a static dot is shown otherwise.
- [ ] **RTL:** use logical properties / `useDirection`; the date stepper arrows and row layout mirror correctly in Hebrew.
**Schema / Interfaces:**
```ts
export function useLiveCounter(
  startedAt: string | null,  // ISO; null when no running entry
  baseSeconds: number,       // server-provided duration at last fetch
): number;                   // current extrapolated seconds, ticking each 1s
```
**Acceptance:**
- [ ] A running timer's "Running" and "Today" values increment once per second without a network call.
- [ ] An entry idle for >15 min renders the amber `IDLE {n} min` pill with correct minutes.
- [ ] Expanding a row reveals that member's day entries and the time-log link; collapsing restores the summary row.
- [ ] Screen reader does not receive per-second announcements; the timer `aria-label` updates on a ~10s cadence.
- [ ] With `prefers-reduced-motion: reduce`, no pulsing animation runs.
- [ ] Layout mirrors correctly under `dir="rtl"`.

### Task 7: Summary footer + empty/loading/error states
**Blocks:** 8  ·  **Blocked by:** 5
**Files:**
- Create: `apps/zync-app/src/routes/time/team/TeamSummaryFooter.tsx`
- Modify: `apps/zync-app/src/routes/time/team/index.tsx` (wire states)
**Steps:**
- [ ] Render the summary footer: "Team total today: {Hh Mm}", "Active timers: {n}", "Idle (>15 min): {n}" from the `/status` `summary` payload (live) or computed from `/day` totals (historical: total only, no active/idle counts for past dates).
- [ ] Loading: show `Skeleton` rows while the first fetch is in flight (avoid layout shift).
- [ ] Empty: when the tenant has no members/contractors with any activity, show `EmptyState` ("No team activity yet") using the catalog.
- [ ] Error: on fetch failure show `ErrorState` with retry; 403 renders the manager-only `ErrorPage` message.
- [ ] Format durations as `Hh Mm` (e.g. `13h 57m`) and times in tenant timezone / locale; numerals respect Hebrew locale where applicable.
**Acceptance:**
- [ ] Footer counts match the member list classification in live mode.
- [ ] Historical date shows team total but suppresses live active/idle counts.
- [ ] Loading shows skeletons, empty shows the empty state, fetch error shows a retry affordance.

### Task 8: Tests (query, classification, route auth, serialization)
**Blocks:** —  ·  **Blocked by:** 4, 5, 6, 7
**Files:**
- Create: `packages/time/test/team-overview.test.ts`
- Create: `apps/zync-api/test/time-team.route.test.ts`
**Steps:**
- [ ] Unit-test `classifyTimerStatus` / `idleMinutes` boundaries: 14m59s idle → active, 15m00s → idle; null running → none.
- [ ] Unit-test `summarizeTeam`: mixed active/idle/none roster produces correct `teamTotalToday`, `activeCount`, `idleCount`.
- [ ] Integration-test `getTeamTimerStatus` against a seeded Neon branch: staff + contractor mix, running + completed entries, two tenants → assert no cross-tenant leakage and correct `todayTotal` including live running seconds.
- [ ] Integration-test `getTeamDayEntries` for a past date returns completed entries only.
- [ ] Route auth matrix: OWNER `200`, ADMIN `200`, MEMBER `403`, VIEWER `403`, no `time:read` `403`, unauthenticated `401`, module disabled blocked.
- [ ] Serialization snapshot: response JSON keys match the spec contract exactly.
**Acceptance:**
- [ ] All listed tests pass in CI.
- [ ] Auth matrix fully covered; cross-tenant isolation asserted.
- [ ] Idle 15-minute boundary asserted on both sides (active at <15m, idle at ≥15m).
