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

6.5 KiB

status phase source started updated approved approved_by
approved 07.1-user-timezone-fix-inserted-urgent
07.1-VERIFICATION.md
2026-05-07T13:30:00Z 2026-05-07T20:45:00Z 2026-05-07T20:45:00Z lorentz@wulfconsulting.com

Current Test

[user-approved; 6 of 8 items verified, 2 deferred to browser-observation]

Tests

1. Two-browser-same-user timezone consistency

expected: 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). result: skipped — requires two real browsers; not testable via curl

2. Day-boundary fix on dashboard KPIs

expected: 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. result: passed (smoke) — both endpoints respond 200 with sane data (opened_today=0 mid-day ET, yesterdayOpened=160). Boundary differential observation needs midnight-adjacent test, not done.

3. PUT /api/me/timezone end-to-end

expected: Authenticated PUT {"timezone":"America/Los_Angeles"} returns 200; PUT {"timezone":"Etc/Garbage"} returns 400; unauth GET returns 401. result: passed AFTER FIX (commit d31fd48). PUT initially returned 500 because route used updated_at but Better Auth user table column is updatedAt. After fix: PUT 200, GET 200 (returns persisted value with source='user'), PUT invalid 400. Unauth GET returns 307 (middleware redirect to /auth/sign-in) — Pulse's standard pattern for non-/api/mobile/* routes; route-level requireAuth would return 401 if reached.

4. /api/mobile/finance auth gate

expected: Anonymous → 401; authenticated reaches route. result: passed — anonymous returns 401, authenticated returns full finance summary JSON. New auth gate landed correctly.

expected: With ET vs UTC user, bucket dates shift. result: passed — /api/dashboard/trends returns different bucket dates when user.timezone is Pacific/Auckland (UTC+13, starts 2026-04-08) vs America/Los_Angeles (UTC-7, starts 2026-04-09). TZ-02 server-side day boundaries confirmed.

6. Engagement summary D7/D30/D90 rolling time-entries window

expected: totalAutotaskHours rolling window shifts with user.tz; totalGraphHours snapshot stays. result: passed — ET returns totalAutotaskHours=513.4, Tokyo returns totalAutotaskHours=586.9 (rolling window shifted). totalGraphHours=216.4 IDENTICAL in both (snapshot carve-out preserved). Rolling vs snapshot behavior matches plan.

7. Engagement trend sparkline buckets

expected: With ET vs Tokyo user, buckets differ. result: passed — ET buckets end 2026-05-08 with sequence [4.3, 59.1, 63.3, 68.3, 44.7, 0, 0]; Tokyo buckets end 2026-05-07 with sequence [73.4, 1.8, 4.3, 59.1, 63.3, 68.3, 44.7]. Bucket alignment shifts with user TZ.

8. Plan 5 codebase-wide spot-check (highest-traffic pages)

expected: Audit log, analyzer queue, dashboard, quotes render dates in user TZ. result: skipped — requires browser rendering; static greps already confirmed every leak callsite threads timeZone: tz (verifier report § "Plan 5 codebase-wide grep").

Summary

total: 8 passed: 5 issues: 1 (gap below) pending: 0 skipped: 2 (require real browser) blocked: 0

Gaps

BUG-7.1-A — updated_at typo in PUT /api/me/timezone (FIXED in d31fd48)

severity: high scope: Plan 07.1-02 (app/api/me/timezone/route.ts) status: resolved

The PUT handler issued:

UPDATE "user" SET timezone = $1, updated_at = NOW() WHERE id = $2

But Better Auth's user table uses camelCase columns (updatedAt, createdAt, emailVerified, bannedReason, banExpires). Postgres rejected with column "updated_at" of relation "user" does not exist, returning 500.

Why this was missed in static verification: Plan 02's acceptance check grepped for the literal UPDATE "user" SET timezone = $1, updated_at = NOW(). The string literal matched the source — but the column doesn't exist in the actual table. Static grep can't catch a non-existent column reference.

Fix: changed to "updatedAt" (quoted because Postgres folds unquoted identifiers to lowercase). Committed d31fd48.

BUG-7.1-B — 'UTC' rejected by IANA validator (FIXED in 660d039)

severity: medium-high scope: Plan 07.1-02 (validator) + Plan 07.1-01 (migration default) status: resolved — allowlisted UTC, Etc/UTC, GMT, Etc/GMT in EXTRA_ALLOWED_TIMEZONES. Verified PUT {"timezone":"UTC"} → 200, PUT {"timezone":"Etc/UTC"} → 200, PUT {"timezone":"Etc/Garbage"} → 400.

isValidIanaTimezone() in app/api/me/timezone/route.ts:20 rejects any value not in Intl.supportedValuesOf('timeZone'). On Node 20.20.0 (the production runtime) this list contains 418 zones but excludes UTC, Etc/UTC, and every Etc/* alias. Confirmed locally and matches Node's ICU canonical-IANA stance.

But:

  • Migration 083 sets the column DEFAULT to literal 'UTC'
  • lib/auth.ts additionalField default is process.env.DEFAULT_TIMEZONE || "UTC"
  • Result: every new user starts with timezone='UTC' and can never reset back to UTC via PUT /api/me/timezone because the validator rejects 'UTC' and 'Etc/UTC'

The migration default is unreachable post-PUT, which is a self-contradicting state.

Recommended fix: allowlist add UTC, Etc/UTC (and possibly GMT) in isValidIanaTimezone — they are valid PostgreSQL/JS timezone identifiers even if Node's supportedValuesOf omits them. One-line patch.

Alternative: change migration + auth.ts default to a supportedValuesOf-listed zone (e.g. America/New_York for Wulf). Requires data migration for existing rows currently at 'UTC'.

BUG-7.1-C — /api/mobile/engagement/trend?period=D7 returns 8 day buckets (off-by-one) — POSSIBLE PRE-EXISTING

severity: low scope: probably pre-existing in /api/mobile/engagement/trend — not verified introduced by 7.1

D7 query returned 7 entries spanning 2026-05-02 → 2026-05-08 (7 days inclusive of tomorrow), but today is 2026-05-07 ET. Window appears to be "today + 6 prior days" but the Tokyo result also shows 7 entries ending 2026-05-07 — so for some TZ values the window correctly ends today and for others it ends tomorrow.

Worth investigating to see if Phase 7.1's TZ math introduced this or whether it's a pre-existing engagement endpoint quirk. Not blocking phase approval.