wulf-pulse/.planning/phases/07.1-user-timezone-fix-inserted-urgent/07.1-VERIFICATION.md

22 KiB
Raw Blame History

phase verified status score approved approved_by notes human_verification
07.1-user-timezone-fix-inserted-urgent 2026-05-07T13:30:00Z passed 5/5 must-haves verified 2026-05-07T20:30:00Z lorentz@wulfconsulting.com User-approved after 6/8 UAT items passed via curl; 2 fixes applied during UAT (BUG-7.1-A `updated_at` typo in d31fd48; BUG-7.1-B UTC validator allowlist in 660d039). Items 1 & 8 (two-browser TZ visual + Plan 5 page-render spot-check) deferred — require browser observation, not blocking.
test expected why_human
Two-browser-same-user timezone consistency User has timezone='America/New_York'. Open /mobile/finance and /dashboard in two browsers — one with system tz UTC, one with Eastern. Both browsers render IDENTICAL date strings (because both pull America/New_York from session, regardless of device tz). Requires running app, two browsers/devices, manual visual comparison of rendered date strings.
test expected why_human
Day-boundary fix on dashboard KPIs User has timezone='America/New_York'. With a ticket created at 03:30Z (= 23:30 ET previous day), GET /api/mobile/dashboard 'opened_today' does NOT count that ticket; GET /api/dashboard/overview 'yesterdayOpened' DOES count it. Run both endpoints near US-Eastern midnight to observe the bucket boundary. Requires running app server, real Postgres data, time-of-day sensitive boundary observation; cannot be checked by static grep.
test expected why_human
PUT /api/me/timezone end-to-end Authenticated curl PUT with {"timezone":"America/Los_Angeles"} returns 200 + {"timezone":"America/Los_Angeles"}; immediate refresh of /mobile/finance shows date strings in PT. PUT with {"timezone":"Etc/Garbage"} returns 400; unauth GET returns 401. Requires running app, live Better Auth session cookie, and observation of UI re-render after PUT.
test expected why_human
/api/mobile/finance auth gate Anonymous curl http://localhost:3100/api/mobile/finance returns 401; authenticated browser session reaches the route unchanged (no UI breakage on the existing /mobile/finance page). Requires running app to send authenticated browser request and unauthenticated curl side by side.
test expected why_human
Dashboard trends day buckets With user.timezone='America/New_York', GET /api/dashboard/trends returns volumeByDay/resolutionByDay arrays where each bucket date is an Eastern-Time calendar day; toggling user.timezone to 'UTC' shifts buckets accordingly. Trend covers exactly TREND_DAYS (30) consecutive ET days ending today (ET). Requires running app, live data, comparison of bucket arrays under two distinct user timezones.
test expected why_human
Engagement summary D7/D30/D90 rolling time-entries window With user.timezone='America/New_York', GET /api/mobile/engagement/summary returns a totalAutotaskHours value whose underlying time_entries window is anchored to user-tz 'now', not UTC 'now'. (Engagement_snapshots-derived metrics — active D7/D30/D90 + total Graph hours — remain UTC-bucketed by the documented TZ-02 carve-out.) Requires running app, time_entries near user-tz midnight to observe boundary, and confirmation that snapshot counts deliberately do NOT shift (carve-out behavior).
test expected why_human
Engagement trend sparkline buckets With user.timezone='America/New_York', GET /api/mobile/engagement/trend?period=D7 returns 7 day buckets aligned to ET calendar days; toggling to 'UTC' shifts the boundary day. Requires running app and live time_entries data near a midnight transition.
test expected why_human
Plan 5 codebase-wide spot-check (highest-traffic pages) With user.timezone='America/New_York' and device tz=UTC, open /admin/audit/audit-log-table consumer (audit log timestamps), /analyzer/queue (triggeredAt), /dashboard (header 'description' date), and /quotes — every rendered timestamp displays in ET. Visual verification across multiple admin/analyzer/dashboard pages migrated by Plan 5; cannot be automated without rendering the React tree.

Phase 7.1: User Timezone Fix — Verification Report

Phase Goal: A user opening Pulse sees dashboards, filters, and "today/this week" date math computed in their own IANA timezone — not server UTC — so reports stop showing yesterday's data as today (and vice versa). Persistence layer remains UTC; only the read/display path changes.

Verified: 2026-05-07T13:30:00Z Status: human_needed Re-verification: No — initial verification

Goal Achievement

Observable Truths

# Truth Status Evidence
1 Each user has an IANA timezone persisted server-side; default = process.env.DEFAULT_TIMEZONE || 'UTC' for users with no value yet VERIFIED migrations/083_add_user_timezone.sql adds timezone TEXT NOT NULL DEFAULT 'UTC' with backfill UPDATE. lib/auth.ts:95-98 adds timezone to additionalFields with defaultValue: process.env.DEFAULT_TIMEZONE || "UTC". Storage tz unchanged for every other column (no ALTER on existing TIMESTAMPs).
2 Mobile + desktop dashboards, ticket filters, finance views, engagement period selectors compute day/week boundaries against viewer's tz — not UTC and not browser-local VERIFIED All 6 server routes (/api/mobile/dashboard, /api/dashboard/overview, /api/dashboard/trends, /api/mobile/finance, /api/mobile/engagement/summary, /api/mobile/engagement/trend) import getUserTimezone and use the two-step (value AT TIME ZONE 'UTC') AT TIME ZONE $1 idiom. AT TIME ZONE counts: dashboard=2, overview=7, trends=7, finance=11, engagement/summary=1, engagement/trend=6. All ::date = CURRENT_DATE and DATE_TRUNC('month', NOW()) patterns are gone from the migrated routes. Engagement snapshot bucketing left UTC by the documented TZ-02 carve-out (in REQUIREMENTS.md and code comment).
3 Authenticated GET /api/me/timezone returns user's tz; PUT /api/me/timezone accepts an IANA string and rejects anything not in Intl.supportedValuesOf('timeZone') VERIFIED app/api/me/timezone/route.ts:30-103 exports both handlers. GET returns {timezone, source}. PUT validates Intl.supportedValuesOf('timeZone').includes(tz) + 64-char length cap before SQL. Both gated by requireAuth() first; UPDATE WHERE uses session!.user.id (no userId from body). middleware.ts does not whitelist /api/me/* (grep returned 0 matches).
4 A shared client hook useUserTimezone() reads from useSession() so all components use a single source of truth VERIFIED (with documented deferral) lib/hooks/use-user-timezone.ts:35 exports useUserTimezone() reading useSession().data?.user.timezone, validating via Intl.supportedValuesOf, falling back to NEXT_PUBLIC_DEFAULT_TIMEZONE || 'UTC'. All 50 Plan 5 files import the hook (verified by per-file grep). The 2 mobile pages from Plan 4 plus 50 Plan 5 files = 52 client files migrated. ONE deferred: components/configuration-items/auvik-tab.tsx:26 (1 leak; file lacks 'use client' directive — Plan 5 deliberately did not silently add it). Documented in 07.1-05-MANIFEST.md Deferred section. Codebase-wide residue grep shows 0 actual leaks (the matched lines all have timeZone: tz either same-line or in a multi-line options block).
5 Existing UTC-stored data stays untouched — no destructive migration; only formatting and range-bucketing change VERIFIED migrations/083_add_user_timezone.sql only contains ADD COLUMN IF NOT EXISTS + defensive UPDATE on the new column + COMMENT ON COLUMN. No DROP/DELETE/TRUNCATE on existing data. No other migration was added. All Plan 3 SQL changes are WHERE-clause / SELECT-projection refactors that read existing UTC timestamps and shift them at query time — no UPDATE/INSERT. Verified: git diff of all migrated routes shows only WHERE/SELECT changes.

Score: 5/5 truths verified

Required Artifacts

Artifact Expected Status Details
migrations/083_add_user_timezone.sql TZ-01: ADD COLUMN IF NOT EXISTS, NOT NULL DEFAULT 'UTC', backfill, COMMENT VERIFIED 27 lines; contains all required clauses; non-destructive; commit 25e6b75.
lib/auth.ts TZ-01: additionalFields exposes timezone with env-driven default VERIFIED Lines 95-98 add timezone: { type: "string", defaultValue: process.env.DEFAULT_TIMEZONE || "UTC" }; User type via $Infer.Session.user automatically picks it up. Commit 061f266.
app/api/me/timezone/route.ts TZ-03: GET + PUT, requireAuth, IANA validation VERIFIED 103 lines; GET (line 30) + PUT (line 54) handlers; requireAuth() first in both; Intl.supportedValuesOf('timeZone') whitelist + 64-char cap; UPDATE uses WHERE id = $2 bound to session!.user.id. No userId parameter accepted. Commit f50215f.
lib/services/user-timezone.ts TZ-02: getUserTimezone(session) helper VERIFIED 40 lines; exports getUserTimezone + DEFAULT_TIMEZONE_FALLBACK; pure synchronous helper; validates via Intl.supportedValuesOf; no DB / no auth-utils import. Commit ea5532c.
lib/hooks/use-user-timezone.ts TZ-04: client hook reading useSession() VERIFIED 61 lines; "use client" first line; exports useUserTimezone and formatInUserTimezone; reads useSession().data?.user.timezone; validates and falls back to NEXT_PUBLIC_DEFAULT_TIMEZONE || 'UTC'. Commit 2ac2db7.
app/api/mobile/dashboard/route.ts TZ-02: AT TIME ZONE on KPI date filters VERIFIED Imports getUserTimezone; passes [tz] to query; rolling-now SLA + 24h queries preserved with comments. AT TIME ZONE count: 2.
app/api/dashboard/overview/route.ts TZ-02: today/yesterday/7d-avg in user-tz VERIFIED AT TIME ZONE count: 7. ::date = CURRENT_DATE removed. Migrated commit 8a9887f.
app/api/dashboard/trends/route.ts TZ-02: 30-day buckets in user-tz VERIFIED AT TIME ZONE count: 7. Bare CURRENT_DATE removed. Heatmap (open-only counts) preserved. Commit 04d036a.
app/api/mobile/finance/route.ts TZ-02 + requireAuth hardening VERIFIED Imports requireAuth + getUserTimezone; requireAuth() is first call in GET(). AT TIME ZONE count: 11. DATE_TRUNC('month'/'year', NOW()) patterns removed. Commit dc0b06b.
app/api/mobile/engagement/summary/route.ts TZ-02 rolling time-entries window VERIFIED Imports getUserTimezone; rolling time_entries WHERE migrated to two-step (te.entry_date AT TIME ZONE 'UTC' AT TIME ZONE $1) >= (NOW() …). Snapshot queries preserved with TZ-02 carve-out comment. AT TIME ZONE count: 1 (the migrated rolling window).
app/api/mobile/engagement/trend/route.ts TZ-02: day buckets in user-tz VERIFIED AT TIME ZONE count: 6. generate_series + daily_hours + entry_date filters all migrated. TZ-02 (Phase 7.1) comment added. Commit dc0b06b.
app/mobile/finance/page.tsx TZ-04 client side VERIFIED Imports useUserTimezone; calls hook; 3 toLocale* callsites all have timeZone: tz (positive grep: 3 timeZone: matches = 3 toLocale* callsites).
app/mobile/tickets/[id]/page.tsx TZ-04 client side VERIFIED Imports hook; fmtDate(ts, tz) signature change + TimelineCard tz prop threading. Single toLocaleString callsite has timeZone: tz.
.planning/phases/07.1-user-timezone-fix-inserted-urgent/07.1-04-AUDIT.md TZ-04 codebase-wide audit deliverable VERIFIED Contains all 6 required sections (Leak / Explicit-zone / Number-format / Server-side / Deliberate-UTC / Plan 05 dispatch / Summary). 81 leaks classified across 51 unique file paths.
.planning/phases/07.1-user-timezone-fix-inserted-urgent/07.1-05-MANIFEST.md TZ-04 SC#4: codebase-scale single source of truth VERIFIED Contains ## Files to migrate with 51 entries (all [x] checked), ## Per-file migration plan with before/after snippets, ## Deferred (auvik-tab, .backup), and Coverage check vs audit.
Plan 5 migrated client files (50 files) TZ-04 codebase-wide adoption VERIFIED All 50 files in 07.1-05-MANIFEST.md ## Files to migrate checklist contain useUserTimezone. Verified by per-file grep loop — zero MISSING_HOOK reports.
app/admin/data-browser/time-entries/page.tsx.backup Plan 5: deletion of orphaned backup VERIFIED File no longer exists (test -f returns false). Commit b417988.
From To Via Status Details
Better Auth session user.timezone column additionalFields in lib/auth.ts:95-98 WIRED additionalFields.timezone.defaultValue = process.env.DEFAULT_TIMEZONE || "UTC"; field type "string". The exported User = typeof auth.$Infer.Session.user automatically includes the field.
app/api/me/timezone/route.ts requireAuth() import { requireAuth } from '@/lib/auth-utils' WIRED Both GET and PUT call requireAuth() as first statement.
PUT handler UPDATE user SET timezone WHERE id = session.user.id session-scoped UPDATE WIRED Line 86 'UPDATE "user" SET timezone = $1, updated_at = NOW() WHERE id = $2 RETURNING timezone' with [candidate, session!.user.id]. No userId parameter accepted from body or query.
PUT validation Intl.supportedValuesOf timeZone whitelist runtime IANA whitelist WIRED Line 23 Intl.supportedValuesOf('timeZone') returns the whitelist; .includes(tz) is the membership check; 64-char cap before.
Each migrated server route session.user.timezone via lib/services/user-timezone.ts import { getUserTimezone } after requireAuth() WIRED All 6 routes verified; pattern const { session, error } = await requireAuth(); if (error) return error; const tz = getUserTimezone(session);.
SQL queries Postgres timezone-aware day boundaries (value AT TIME ZONE 'UTC') AT TIME ZONE $tz WIRED Two-step idiom across all migrated queries. tz parametrized as $1/$N, never string-interpolated.
lib/hooks/use-user-timezone.ts useSession() from @/lib/auth-client additionalField propagated by Better Auth Plan 01 config WIRED Line 36 const { data } = useSession();; line 39 reads data?.user.timezone.
Mobile + Plan 5 client files useUserTimezone hook import { useUserTimezone } from '@/lib/hooks/use-user-timezone' WIRED 52 client files (2 mobile + 50 Plan 5) all import and call the hook.
Each leaking toLocale call (post-migration) tz from useUserTimezone() { ...options, timeZone: tz } WIRED 80 of 81 audit-classified leaks now thread timeZone: tz; 1 deferred (auvik-tab.tsx, documented).

Data-Flow Trace (Level 4)

Artifact Data Variable Source Produces Real Data Status
app/api/me/timezone/route.ts (GET) result.rows[0]?.timezone SELECT timezone FROM "user" WHERE id = $1 YES — real Postgres query, parametrized to session.user.id FLOWING
lib/services/user-timezone.ts (getUserTimezone) session?.user?.timezone Better Auth session payload (populated from user table by additionalFields wiring) YES — flows from DB column through Better Auth additionalField FLOWING
lib/hooks/use-user-timezone.ts (useUserTimezone) data?.user.timezone from useSession() Better Auth client SDK reading server-side session YES — same field as server-side getUserTimezone, just on the client transport FLOWING
Migrated server routes tz parameter passed to SQL $1 getUserTimezone(session) after requireAuth() YES — real session-derived value flowing into parametrized queries FLOWING
Mobile finance / tickets pages tz from useUserTimezone() useSession() reactive subscription YES — useSession() returns real session data; tz is threaded into every toLocale* options object FLOWING
Plan 5 client files (50 files) tz from useUserTimezone() Same source YES — same wiring; threaded into every previously-leaking callsite FLOWING

Behavioral Spot-Checks

Behavior Command Result Status
TypeScript compile across whole codebase npx tsc --noEmit --pretty No output (zero errors) PASS
Vitest test suite (excluding pre-existing itglue-search failures noted in prompt) npm test 14 of 15 test files pass; 182 of 184 tests pass. The 2 failures are in lib/services/analyzer/itglue-search.test.ts and pre-date Phase 7.1 (file untouched in this phase). PASS (no new failures)
/api/me/* not in middleware publicRoutes grep -nE '"/api/me' middleware.ts exit 1 (no matches) PASS
All 50 Plan 5 files import useUserTimezone per-file grep loop Zero MISSING_HOOK reports PASS
Server routes have getUserTimezone + requireAuth grep getUserTimezone|requireAuth × 6 routes All 6 import + call both PASS
::date = CURRENT_DATE patterns removed grep -nE '::date = CURRENT_DATE' on dashboard/overview/dashboard 0 matches PASS
DATE_TRUNC('month'/'year', NOW()) removed from finance grep 0 matches PASS
Bare CURRENT_DATE removed from trends + engagement/trend grep -wnE "CURRENT_DATE" 0 matches PASS
Orphaned backup file deleted test -f app/admin/data-browser/time-entries/page.tsx.backup DELETED PASS
Phase task commits in git history git log --all --oneline filtered All 18 task commits found PASS

Requirements Coverage

Requirement Source Plan Description Status Evidence
TZ-01 Plan 01 Better Auth users table extended with IANA timezone field; default process.env.DEFAULT_TIMEZONE || 'UTC'; existing rows backfill; UTC remains storage timezone for all date columns SATISFIED migrations/083_add_user_timezone.sql adds the column non-destructively; lib/auth.ts:95-98 adds the additionalField with the env-driven default; no other timestamp columns altered.
TZ-02 Plan 03 + 04 + 05 Date math for dashboards, ticket filters, finance, engagement period selectors uses viewer's tz; engagement_snapshots-derived metrics carve-out documented SATISFIED 6 server routes migrated to two-step AT TIME ZONE 'UTC' AT TIME ZONE $1 idiom; engagement_snapshots TZ-02 carve-out documented in REQUIREMENTS.md and code comment above latestResult in /api/mobile/engagement/summary/route.ts; client-side via Plan 4 + 5 (52 files migrated, 1 deferred).
TZ-03 Plan 02 GET /api/me/timezone (auth required) returns {timezone, source}; PUT validates against Intl.supportedValuesOf('timeZone'), persists, returns new value SATISFIED app/api/me/timezone/route.ts exports both handlers with required behavior; verified by code inspection (full file read).
TZ-04 Plan 04 + 05 Shared client hook useUserTimezone() reads tz from useSession(); all date-formatting and range-bucketing in mobile + desktop pages goes through this hook — no scattered Intl.DateTimeFormat instantiations with hardcoded zones SATISFIED (with 1 documented deferral) Hook exists at lib/hooks/use-user-timezone.ts; 52 client files (2 Plan 4 + 50 Plan 5) consume it; 1 file (auvik-tab.tsx) deferred because it lacks 'use client' directive (Plan 5 deliberately did not add it; rationale documented in 07.1-05-MANIFEST.md ## Deferred).

Orphaned requirements check: REQUIREMENTS.md TZ section (lines 86-91) defines TZ-01 through TZ-04. All four are claimed by plans in this phase. The Traceability table in REQUIREMENTS.md (lines 137-185) does not include TZ-* rows — these are tracked in the phase's plan frontmatter only. Not a gap; the table predates the urgent insertion of Phase 7.1 and was not updated as part of this phase's plans.

Anti-Patterns Found

File Line Pattern Severity Impact
components/configuration-items/auvik-tab.tsx 26 new Date(dateString).toLocaleString() (no timeZone: option) Info Documented deferral in 07.1-05-MANIFEST.md ## Deferred. File lacks 'use client' directive; Plan 5's threat-model rule explicitly forbids silently adding the directive. Single-callsite leak; non-blocking for SC#4 because the deferral is intentional and the rationale is recorded for v2 follow-up.
lib/services/analyzer/itglue-search.test.ts (test failures) 2 pre-existing test failures Info Pre-date Phase 7.1; Phase 7.1 did not modify itglue-search.ts or its test. Excluded from regression count per prompt's <known_pre_existing_failures> block.

No blockers found. Other patterns scanned (TODO/FIXME, empty handlers, hardcoded empty arrays, console.log-only impls): none introduced by this phase's changes.

Human Verification Required

See frontmatter human_verification section. 8 items requiring human/runtime testing:

  1. Two-browser-same-user timezone consistency — verifies the SC#4 single-source-of-truth claim end-to-end across actual browsers with different system zones.
  2. Day-boundary fix on dashboard KPIs — confirms TZ-02 fix at the user-visible bucket level (the bug that motivated the phase).
  3. PUT /api/me/timezone end-to-end — confirms TZ-03 with real curl + cookie + UI re-render.
  4. /api/mobile/finance auth gate — confirms the auth-gate hardening landed without breaking existing browser callers.
  5. Dashboard trends day buckets — confirms TZ-02 on /api/dashboard/trends (the route the original plan missed).
  6. Engagement summary D7/D30/D90 rolling time-entries window — confirms the rolling window migration AND the snapshot carve-out non-shift behavior.
  7. Engagement trend sparkline buckets — confirms TZ-02 sparkline alignment.
  8. Plan 5 codebase-wide spot-check — visual verification that high-traffic admin/analyzer/dashboard pages render dates in user-tz.

Gaps Summary

No gaps found. All 5 must-haves are verified by code inspection, static greps, type-check, and the available test suite. The phase satisfies its stated goal at the static-analysis level: per-user IANA timezone is persisted, all 6 server routes compute day/week/month boundaries against the viewer's tz, the GET/PUT endpoint is gated and validated, the client hook is the single source of truth (with 1 documented v2 deferral on auvik-tab.tsx and the engagement_snapshots carve-out explicitly recorded in REQUIREMENTS.md), and storage UTC is untouched.

The phase requires human verification on 8 runtime/visual items because the goal manifests as user-visible date strings and bucket boundaries that cannot be confirmed without a running app, real Postgres data, and observation across browsers/system timezones. Static verification is complete and passing.


Verified: 2026-05-07T13:30:00Z Verifier: Claude (gsd-verifier)