{
  "slug": "time-management",
  "spec_file": "docs/specs/2026-05-30-time-management.md",
  "audited_against": "9c68a05",
  "findings": [
    {
      "id": "tm-001",
      "severity": "P0",
      "code_ref": "apps/zync-api/src/routes/time.ts:481",
      "spec_ref": "docs/specs/2026-05-30-time-management.md — Permissions (Log/edit own time requires time:write); API Endpoints POST /api/time/beacon",
      "summary": "POST /api/time/beacon stops any tenant entry by ID without verifying caller owns the entry",
      "current_behavior": "Beacon handler calls stopEntry(db, session.tid, entryId) with no getTimeEntryById ownership check. Any user with time:write can POST {entryId:<victim UUID>} and stop another user's running timer.",
      "expected_per_spec": "Beacon is for tab-close of the caller's own running timer. Mutations on others' entries require time:write_all; own entries require time:write with user_id match.",
      "fix_direction": "code_to_spec",
      "suggested_fix": "Before stopEntry, load entry via getTimeEntryById; return 403 unless entry.userId === session.sub (or session has time:write_all for other users). Mirror the ownership gate on POST /:id/stop (lines 420-427)."
    },
    {
      "id": "tm-002",
      "severity": "P0",
      "code_ref": "apps/zync-api/src/routes/time.ts:239",
      "spec_ref": "docs/specs/2026-05-30-time-management.md — Magic Link (redirect to app.zync.is/time?started={entryId})",
      "summary": "Magic-link GET redirects to /time but SPA mounts TimePage only under /time-track/*",
      "current_behavior": "All magic-link outcomes redirect to https://app.zync.is/time?... (started, magic_error). moduleRoutes register time-track/* → TimePage; there is no /time route or redirect. MagicTimePage fallback also navigates to /time (line 44).",
      "expected_per_spec": "After valid token consumption, user lands on the time tracking page with ?started={entryId}, sees confirmation toast, and header timer restores via restoreFromActive().",
      "fix_direction": "code_to_spec",
      "suggested_fix": "Change API redirects to /time-track?started=... (or add SPA Route path='time' → Navigate to /time-track preserving query). Update MagicTimePage navigate target and email link docs if canonical path changes."
    },
    {
      "id": "tm-003",
      "severity": "P0",
      "code_ref": "apps/zync-app/src/components/timer/TimerWidget.tsx:133",
      "spec_ref": "docs/specs/2026-05-30-time-management.md — Idle Detection (Stop timer stops from last active time)",
      "summary": "Idle dialog 'Stop timer' calls stop at now() instead of idle-onset timestamp",
      "current_behavior": "handleIdleStop calls handleStop() with no stoppedAt override. Server computes duration to now(), billing idle seconds. handleIdleDiscard correctly passes idleStartedAt (line 146-147).",
      "expected_per_spec": "When user chooses Stop timer after idle, stopped_at should be last-active time (idle onset), excluding idle period from duration_seconds.",
      "fix_direction": "code_to_spec",
      "suggested_fix": "In handleIdleStop, pass idleStartedAt ISO string to stop() the same way handleIdleDiscard does: await handleStop(idleStartedAt ? new Date(idleStartedAt).toISOString() : undefined)."
    },
    {
      "id": "tm-004",
      "severity": "P0",
      "code_ref": "apps/zync-app/src/components/timer/StartTimerPopover.tsx:34",
      "spec_ref": "docs/specs/2026-05-30-time-management.md — Start timer flow (select project required)",
      "summary": "Start timer and log-time forms ship with empty project/task lists — cannot select a project",
      "current_behavior": "StartTimerPopover defaults projects=[]; TimerWidget mounts popover without passing projects/tasks. LogTimeSheet defaults projects=[]; TimePage renders LogTimeSheet without projects prop. Submit disabled when !projectId. Contractor portal correctly fetches /api/projects?status=active.",
      "expected_per_spec": "User selects project (required) and optional task from populated lists before starting timer or logging manual time.",
      "fix_direction": "code_to_spec",
      "suggested_fix": "Fetch /api/projects (and tasks filtered by project) in TimerWidget/TimePage; pass projects/tasks props into StartTimerPopover and LogTimeSheet. Mirror contractor-portal/time.tsx pattern (line 84)."
    },
    {
      "id": "tm-005",
      "severity": "P1",
      "code_ref": "apps/zync-app/src/shell/Header.tsx:78",
      "spec_ref": "docs/specs/2026-05-30-time-management.md — Timer Widget (Header)",
      "summary": "TimerWidget not mounted in app header",
      "current_behavior": "Header renders sidebar toggle, search, NotificationDropdown, UserMenu only. grep TimerWidget across apps/zync-app finds zero imports outside TimerWidget.tsx.",
      "expected_per_spec": "Persistent timer widget visible on all authenticated pages in the app header.",
      "fix_direction": "code_to_spec",
      "suggested_fix": "Import and mount <TimerWidget /> in Header between search and notification bell; gate on time_management module enabled + time:read permission."
    },
    {
      "id": "tm-006",
      "severity": "P1",
      "code_ref": "apps/zync-app/src/stores/timer.ts:75",
      "spec_ref": "docs/specs/2026-05-30-time-management.md — Auto-report on window close (navigator.sendBeacon)",
      "summary": "navigator.sendBeacon tab-close stop not implemented",
      "current_behavior": "timer store stop() uses fetch only. No sendBeacon registration, no beforeunload handler for running timer. Only unrelated beforeunload in realtime/client.ts.",
      "expected_per_spec": "When timer running and tab closes, navigator.sendBeacon('/api/time/beacon', {entryId}) fires as primary stop mechanism.",
      "fix_direction": "code_to_spec",
      "suggested_fix": "When timer becomes active, register beforeunload/pagehide listener that sendBeacon JSON {entryId} to /api/time/beacon with credentials; remove listener on stop."
    },
    {
      "id": "tm-007",
      "severity": "P1",
      "code_ref": "apps/zync-api/src/cron/runner.ts:17",
      "spec_ref": "docs/specs/2026-05-30-time-management.md — Cron: Stale Timer Cleanup (runs hourly)",
      "summary": "Stale-timer cleanup route exists but is not scheduled",
      "current_behavior": "timeCleanupCronRoute mounted at /api/cron/time-cleanup. CRON_ROUTE_MAP hourly '0 * * * *' array omits /api/cron/time-cleanup. wrangler.toml crons block is commented out (account limit note).",
      "expected_per_spec": "Hourly cron finds entries with stopped_at IS NULL AND started_at < now()-2h, auto-stops with source=auto, fires timer.stopped webhook.",
      "fix_direction": "code_to_spec",
      "suggested_fix": "Add '/api/cron/time-cleanup' to CRON_ROUTE_MAP['0 * * * *'] and re-enable wrangler cron trigger when account quota allows."
    },
    {
      "id": "tm-008",
      "severity": "P1",
      "code_ref": "apps/zync-app/src/components/task/SendMagicLinkButton.tsx:22",
      "spec_ref": "docs/specs/2026-05-30-time-management.md — Magic Link (Send magic link on task detail)",
      "summary": "SendMagicLinkButton exists but is not wired into task UI",
      "current_behavior": "Component implements POST /api/time/magic flow with tasks:write gate. grep SendMagicLinkButton across repo returns only its own file — no task detail or action menu imports.",
      "expected_per_spec": "Task detail shows 'Send magic link' action for users with tasks:write.",
      "fix_direction": "code_to_spec",
      "suggested_fix": "Import SendMagicLinkButton into task detail header/actions; pass taskId and canSend={permissions.includes('tasks:write')}."
    },
    {
      "id": "tm-009",
      "severity": "P1",
      "code_ref": "apps/zync-app/src/stores/tenantSettings.ts:24",
      "spec_ref": "docs/specs/2026-05-30-time-management.md — Idle threshold setting (loaded on app init, stored in Zustand)",
      "summary": "Tenant time settings not loaded on app init",
      "current_behavior": "useTenantSettingsStore.load() fetches /api/settings/time-tracking but is only invoked from TimeTrackingSettings after save. Shell/Header never calls load on mount.",
      "expected_per_spec": "idle_timer_threshold_minutes and time_rounding loaded on app init into Zustand for TimerWidget idle detection.",
      "fix_direction": "code_to_spec",
      "suggested_fix": "Call useTenantSettingsStore.getState().load() once in Shell after auth/me succeeds (or when TimerWidget mounts)."
    },
    {
      "id": "tm-010",
      "severity": "P1",
      "code_ref": "apps/zync-app/src/pages/time/TimePage.tsx:68",
      "spec_ref": "docs/specs/2026-05-30-time-management.md — Time Tracking Page (week selector drives visible entries)",
      "summary": "Time page does not filter entries by selected ISO week",
      "current_behavior": "useTimeEntries({from: undefined, to: undefined}) regardless of week state. Week selector only drives useWeekSummary(week). Entry list shows latest paginated entries, not the selected week.",
      "expected_per_spec": "Week selector ◄ ▶ controls which entries appear in day groups; totals match GET /api/time/summary for that week.",
      "fix_direction": "code_to_spec",
      "suggested_fix": "Derive Monday..Sunday ISO range from week state; pass from/to to useTimeEntries so list and summary bar stay in sync."
    },
    {
      "id": "tm-011",
      "severity": "P1",
      "code_ref": "apps/zync-app/src/components/timer/StartTimerPopover.tsx:64",
      "spec_ref": "docs/specs/2026-05-30-time-management.md — One active timer rule (client warns before auto-stop)",
      "summary": "No client warning when starting a new timer while one is active",
      "current_behavior": "handleSubmit calls start() directly with no check of useTimerStore timer state. API auto-stops prior entry silently.",
      "expected_per_spec": "Client warns: 'You have an active timer — starting a new one will stop it.' before submitting.",
      "fix_direction": "code_to_spec",
      "suggested_fix": "Read timer from useTimerStore; if non-null, show confirm dialog before calling start()."
    },
    {
      "id": "tm-012",
      "severity": "P1",
      "code_ref": "apps/zync-app/src/routes/index.tsx:110",
      "spec_ref": "docs/specs/2026-05-30-time-management.md — Time Tracking Page (/time)",
      "summary": "Canonical /time page route absent; personal log lives at /time-track/*",
      "current_behavior": "TimePage mounted under time-track/* module. Nav links to /time-track (nav-model.ts:67). No /time route or redirect; only /time/approvals and /time/submit exist as sibling paths.",
      "expected_per_spec": "Full time management view at /time with week selector, log time, start timer.",
      "fix_direction": "code_to_spec",
      "suggested_fix": "Add Route path='time' element={<Navigate to='/time-track' replace />} preserving search params, or remount TimePage at /time. Align nav, magic redirects, and notification deep links."
    },
    {
      "id": "tm-013",
      "severity": "P2",
      "code_ref": "packages/db/src/seed/permission-keys.ts:26",
      "spec_ref": "docs/specs/2026-05-30-time-management.md — Permissions table",
      "summary": "Extra permission keys time:track and time:manage beyond spec vocabulary",
      "current_behavior": "Seed catalog includes time:track (MEMBER/CONTRACTOR) and time:manage (locking/approval routes). Spec lists only time:read, time:read_all, time:write, time:write_all.",
      "expected_per_spec": "Four time:* keys per permissions table; locking/approval use write_all or a spec-defined manager permission.",
      "fix_direction": "spec_review",
      "suggested_fix": "Orchestrator decides: migrate locking/approval to time:write_all or document time:manage/time:track as extensions in spec."
    },
    {
      "id": "tm-014",
      "severity": "P2",
      "code_ref": "packages/db/src/schema/tenants.ts:99",
      "spec_ref": "docs/specs/2026-05-30-time-management.md — Schema delta (contractor_require_time_approval DEFAULT true)",
      "summary": "contractor_require_time_approval DB default false; spec requires true",
      "current_behavior": "tenantSettings.contractorRequireTimeApproval default(false) in Drizzle schema and migration 0004. getTimeSettings falls back to true when row missing (queries/time.ts:841) but persisted new tenants get false.",
      "expected_per_spec": "ALTER DEFAULT true; contractor submissions gated pending until manager approves when setting enabled.",
      "fix_direction": "code_to_spec",
      "suggested_fix": "Change schema/migration default to true; backfill existing false rows if product intent is opt-out not opt-in."
    },
    {
      "id": "tm-015",
      "severity": "P2",
      "code_ref": "apps/zync-api/src/routes/time.ts:436",
      "spec_ref": "docs/specs/2026-05-30-time-management.md — Webhooks (timer.auto_paused on idle dialog shown)",
      "summary": "timer.auto_paused fires on idle-discard stop with stopped-shaped payload, not when dialog shown",
      "current_behavior": "Webhook event chosen when stopBody.stoppedAt present (idle discard), emitting timer.stopped-shaped payload without idleSeconds. TimerWidget never calls API when dialog opens.",
      "expected_per_spec": "timer.auto_paused fires when idle dialog is shown with {entryId, userId, taskId, projectId, idleSeconds}; distinct from user stop resolution.",
      "fix_direction": "code_to_spec",
      "suggested_fix": "Emit timer.auto_paused from client on dialog show (dedicated lightweight endpoint or metadata flag) with idleSeconds; keep timer.stopped for actual stop actions."
    },
    {
      "id": "tm-016",
      "severity": "P2",
      "code_ref": "packages/db/src/queries/time.ts:697",
      "spec_ref": "docs/plans/tasks/time-management.md — Task 4 (getWeekSummary per local day in tenant timezone)",
      "summary": "Week summary aggregates by UTC date, not tenant timezone",
      "current_behavior": "dateStr = row.startedAt.toISOString().slice(0,10). Comment at line 685 says UTC. parseIsoWeek uses UTC boundaries.",
      "expected_per_spec": "Sum duration_seconds per local calendar day using tenants.default_timezone.",
      "fix_direction": "code_to_spec",
      "suggested_fix": "Load tenant timezone; bucket startedAt into local YYYY-MM-DD before aggregating; align parseIsoWeek range to tenant-local week boundaries."
    },
    {
      "id": "tm-017",
      "severity": "P2",
      "code_ref": "packages/db/src/queries/time.ts:484",
      "spec_ref": "docs/specs/2026-05-30-time-management.md — Integration with Billing (retainer hours_used on stop)",
      "summary": "logManualEntry does not increment retainer_months.hours_used",
      "current_behavior": "maybeUpdateRetainerHourBank called from stopEntry and startEntry auto-stop (lines 272, 346) but logManualEntry insert never invokes it.",
      "expected_per_spec": "Billable manual entries on retainer projects update retainer_months.hours_used for the entry month.",
      "fix_direction": "code_to_spec",
      "suggested_fix": "After logManualEntry insert, call maybeUpdateRetainerHourBank with durationSeconds (inside transaction)."
    },
    {
      "id": "tm-018",
      "severity": "P2",
      "code_ref": "apps/zync-app/src/pages/time/TimeEntryRow.tsx:66",
      "spec_ref": "docs/specs/2026-05-30-time-management.md — Time Tracking Page (billable toggle per row)",
      "summary": "TimeEntryRow shows static billable dot only; no inline toggle",
      "current_behavior": "Row renders colored dot indicator with no Switch or PATCH on change. Editing billable requires opening LogTimeSheet.",
      "expected_per_spec": "Each entry row includes billable toggle that updates via PATCH /api/time/:id.",
      "fix_direction": "code_to_spec",
      "suggested_fix": "Add inline Switch wired to useUpdateEntry({billable}); disable when entry locked."
    },
    {
      "id": "tm-019",
      "severity": "P2",
      "code_ref": "apps/zync-app/src/pages/time/TimePage.tsx:141",
      "spec_ref": "docs/specs/2026-05-30-time-management.md — Layout ([+ Log time] [▶ Start timer])",
      "summary": "Time page toolbar missing Start timer button",
      "current_behavior": "Toolbar has week selector and '+ Log time' only. No control opening StartTimerPopover.",
      "expected_per_spec": "Toolbar includes ▶ Start timer opening same popover as header widget.",
      "fix_direction": "code_to_spec",
      "suggested_fix": "Add StartTimerPopover to TimePage toolbar with shared projects/tasks fetch."
    },
    {
      "id": "tm-020",
      "severity": "P2",
      "code_ref": "apps/zync-api/src/routes/time.ts:628",
      "spec_ref": "docs/plans/tasks/time-management.md — Task 7 (locale from recipient user_preferences → tenant default)",
      "summary": "Magic-link email locale hardcoded he-IL",
      "current_behavior": "const locale = 'he-IL' always. Both branches use templateKey 'timer-magic-link'. No lookup of recipient user_preferences.",
      "expected_per_spec": "Locale resolved from recipient preferences, falling back to tenant default; never hard-default en.",
      "fix_direction": "code_to_spec",
      "suggested_fix": "Resolve locale from user_preferences.locale or tenant default; select timer_magic_link_he vs timer_magic_link_en template keys."
    },
    {
      "id": "tm-021",
      "severity": "P2",
      "code_ref": "packages/db/src/queries/time.ts:179",
      "spec_ref": "docs/plans/tasks/time-management.md — Task 4 (retainer month = entry local month)",
      "summary": "Retainer hour-bank month derived from UTC not tenant local month",
      "current_behavior": "month built from startedAt.getFullYear()/getMonth() (local server/UTC). Comment admits simplified UTC.",
      "expected_per_spec": "retainer_months.month keyed to entry's calendar month in tenant timezone.",
      "fix_direction": "code_to_spec",
      "suggested_fix": "Format month using tenant timezone (e.g. Intl or date-fns-tz) in maybeUpdateRetainerHourBank."
    },
    {
      "id": "tm-022",
      "severity": "P2",
      "code_ref": "packages/db/migrations/0004_wave4_5_gen.sql:227",
      "spec_ref": "docs/specs/2026-05-30-time-management.md — Data Model (partial index for active timer lookup)",
      "summary": "Partial index idx_time_entries_active not applied in migrations",
      "current_behavior": "Migration creates idx_time_entries_project and idx_time_entries_cursor only. Schema documents idx_time_entries_active in raw_ddl comment; no SQL CREATE INDEX for partial active-timer lookup.",
      "expected_per_spec": "CREATE INDEX idx_time_entries_active ON time_entries (tenant_id, user_id) WHERE stopped_at IS NULL for fast active-timer queries.",
      "fix_direction": "code_to_spec",
      "suggested_fix": "Add idempotent migration CREATE INDEX CONCURRENTLY IF NOT EXISTS for partial active index."
    },
    {
      "id": "tm-023",
      "severity": "P2",
      "code_ref": "apps/zync-app/src/pages/notifications/resolveEntityHref.ts:30",
      "spec_ref": "docs/specs/2026-05-30-time-management.md — Time Tracking Page (/time)",
      "summary": "Notification deep-link for time_entry uses nonexistent /time/:id route",
      "current_behavior": "ENTITY_MAP time_entry: (id) => `/time/${id}`. No such SPA route registered.",
      "expected_per_spec": "Notification click navigates to time entry context on the time tracking page.",
      "fix_direction": "code_to_spec",
      "suggested_fix": "Point to /time-track?highlight={id} or scroll-to-entry pattern once /time redirect exists."
    },
    {
      "id": "tm-024",
      "severity": "P2",
      "code_ref": "apps/zync-api/src/routes/time.ts:359",
      "spec_ref": "docs/specs/2026-05-30-time-management.md — Start timer flow (source distinct from manual log)",
      "summary": "POST /api/time/start records source='manual' for toggle timer starts",
      "current_behavior": "startEntry called with source: 'manual'. Magic-link correctly uses magic_link; cron uses auto.",
      "expected_per_spec": "Timer toggle starts should be distinguishable from manual log entries for reporting (source field).",
      "fix_direction": "spec_review",
      "suggested_fix": "Orchestrator decides: add timer source value to spec CHECK enum or accept manual for toggle starts."
    },
    {
      "id": "tm-025",
      "severity": "P2",
      "code_ref": "apps/zync-api/src/routes/time.ts:245",
      "spec_ref": "docs/plans/tasks/time-management.md — Task 5 (requireModuleEnabled('time'))",
      "summary": "Module gate uses time_management slug; task plan and several downstream specs say time",
      "current_behavior": "All time routes use requireModuleEnabled('time_management'). MODULE_MANIFEST id is time_management. Task plan references requireModuleEnabled('time').",
      "expected_per_spec": "Consistent module id between manifest, API guards, and spec references.",
      "fix_direction": "spec_review",
      "suggested_fix": "Orchestrator aligns spec to time_management (current reality) or renames manifest key to time across DB seed and guards."
    }
  ],
  "old_findings_status": [
    { "id": "time-management-001", "status": "STALE_WRONG" },
    { "id": "time-management-002", "status": "STILL_OPEN" },
    { "id": "time-management-003", "status": "ALREADY_FIXED" },
    { "id": "time-management-004", "status": "ALREADY_FIXED" },
    { "id": "time-management-005", "status": "ALREADY_FIXED" },
    { "id": "time-management-006", "status": "STILL_OPEN" },
    { "id": "time-management-007", "status": "STILL_OPEN" },
    { "id": "time-management-008", "status": "STILL_OPEN" },
    { "id": "time-management-009", "status": "STILL_OPEN" },
    { "id": "time-management-010", "status": "STILL_OPEN" },
    { "id": "time-management-011", "status": "STILL_OPEN" },
    { "id": "time-management-012", "status": "STALE_WRONG" },
    { "id": "time-management-013", "status": "STILL_OPEN" },
    { "id": "time-management-014", "status": "STILL_OPEN" },
    { "id": "time-management-015", "status": "STILL_OPEN" },
    { "id": "time-management-016", "status": "STILL_OPEN" },
    { "id": "time-management-017", "status": "STILL_OPEN" },
    { "id": "time-management-018", "status": "STILL_OPEN" },
    { "id": "time-management-019", "status": "STILL_OPEN" },
    { "id": "time-management-020", "status": "STILL_OPEN" },
    { "id": "time-management-021", "status": "STILL_OPEN" },
    { "id": "time-management-022", "status": "STILL_OPEN" },
    { "id": "time-management-023", "status": "STILL_OPEN" },
    { "id": "time-management-024", "status": "STILL_OPEN" },
    { "id": "time-management-025", "status": "STILL_OPEN" },
    { "id": "time-management-026", "status": "STILL_OPEN" },
    { "id": "time-management-027", "status": "STILL_OPEN" },
    { "id": "time-management-028", "status": "STILL_OPEN" },
    { "id": "time-management-029", "status": "STILL_OPEN" }
  ],
  "summary": {
    "p0": 4,
    "p1": 8,
    "p2": 13,
    "new": 1,
    "already_fixed": 3,
    "stale_wrong": 2
  }
}
