22 KiB
| 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. |
|
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. |
Key Link Verification
| 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:
- 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.
- Day-boundary fix on dashboard KPIs — confirms TZ-02 fix at the user-visible bucket level (the bug that motivated the phase).
- PUT /api/me/timezone end-to-end — confirms TZ-03 with real curl + cookie + UI re-render.
- /api/mobile/finance auth gate — confirms the auth-gate hardening landed without breaking existing browser callers.
- Dashboard trends day buckets — confirms TZ-02 on
/api/dashboard/trends(the route the original plan missed). - Engagement summary D7/D30/D90 rolling time-entries window — confirms the rolling window migration AND the snapshot carve-out non-shift behavior.
- Engagement trend sparkline buckets — confirms TZ-02 sparkline alignment.
- 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)