- volumeRes / resolutionRes generate_series and join keys converted from
CURRENT_DATE / *_date::date = days.d to user-tz two-step idiom.
- engineersRes WHERE filter te.entry_date::date = CURRENT_DATE migrated
to user-tz on both sides.
- queueHeatmap (open-only counts) preserved unchanged — no day-boundary
math; comment added explaining why.
- requireAuth() session destructured; tz passed as $1 to all three
migrated queries.
- /api/mobile/finance: add requireAuth() (aligns with all other /api/mobile/*
handlers) + getUserTimezone(); migrate paid_mtd / paid_ytd to user-tz
DATE_TRUNC, six aging-bucket comparisons to user-tz CURRENT_DATE, and
days_overdue arithmetic. Preserved unchanged: 12-month rolling
monthlyRevenue (rolling — not a calendar boundary).
- /api/mobile/engagement/summary: destructure session, resolve tz; migrate
rolling time_entries WHERE clause to user-tz on both sides of >=. Added
TZ-02 carve-out comment above the snapshot queries documenting why
engagement_snapshots remain UTC-bucketed (deferred per REQUIREMENTS.md).
- /api/mobile/engagement/trend: replace every bare CURRENT_DATE with
(NOW() AT TIME ZONE 'UTC' AT TIME ZONE $1)::date; pass [tz] as params
to postgresClient.query. Day buckets now align to user-tz days.
- Switch opened_today / resolved_today / yesterday / 7d-avg buckets from
CURRENT_DATE to ((value AT TIME ZONE 'UTC') AT TIME ZONE $1)::date.
- Both routes destructure session from requireAuth() and resolve tz via
getUserTimezone(); tz parameterized as $1 (no SQL interpolation).
- Preserved unchanged: due_date_time < NOW() (rolling SLA, tz-independent),
the INTERVAL '24h/5min/1h' rolling-window queries (failed backups,
stalled workflows, analyzer/RMM 1h fail counts, backup-success 24h).
- Added a code comment above the 24h failed-backups query explaining why
it stays UTC-NOW relative.
- app/mobile/finance/page.tsx: thread tz through fmtDate, setLastSync, monthLabel — 3 formatter callsites now pass timeZone
- app/mobile/tickets/[id]/page.tsx: thread tz through fmtDate (5 callsites) and TimelineCard prop
- All toLocaleDateString / toLocaleString calls in both files now render in user.timezone, not browser local zone
- Resolves TZ-02 on the directly-reported bug surface (mobile finance + ticket detail)
- New lib/hooks/use-user-timezone.ts exporting useUserTimezone() and formatInUserTimezone()
- Reads user.timezone from Better Auth useSession() additionalField (Plan 01)
- Validates against Intl.supportedValuesOf('timeZone') with safe fallback to NEXT_PUBLIC_DEFAULT_TIMEZONE || 'UTC'
- Pure formatInUserTimezone helper safe to call inside loops (not a hook)
- Resolves TZ-04
- Adds `timezone` to additionalFields on the auth `user` config
- Default value reads process.env.DEFAULT_TIMEZONE (falls back to "UTC")
- session.user.timezone now available on every authenticated request
- Inferred User type automatically picks up the new field — no type changes needed
- New app/api/me/timezone/route.ts with GET + PUT handlers
- requireAuth() gate on both methods (401 unauthenticated)
- IANA whitelist via Intl.supportedValuesOf('timeZone') + 64-char cap
- PUT writes only session.user.id — no userId body/query param
- Updates audit column updated_at = NOW() on write
- Resolves TZ-03
- Adds `timezone TEXT NOT NULL DEFAULT 'UTC'` to "user" table (TZ-01)
- Backfills any NULL rows defensively
- Idempotent: ADD COLUMN IF NOT EXISTS, no destructive ops
- Storage timezone of existing TIMESTAMP columns unchanged
Three independent changes that together stop the mobile re-auth churn:
- app/layout.tsx: add appleWebApp metadata so iOS "Add to Home Screen"
launches Pulse in true standalone mode (own cookie jar, persists
across Safari memory pressure)
- components/auth/sign-in-form.tsx: when /auth/sign-in mounts and
?callbackUrl starts with /mobile, auto-call authClient.signIn.social
for Microsoft. With an active M365 browser session this redirect is
silent — the user lands on /mobile/* with no tap.
- app/auth/sign-in/page.tsx: wrap SignInForm in <Suspense> (required
by Next.js 16 because SignInForm now uses useSearchParams)
Pairs with operator-side env bump SESSION_TIMEOUT_SECONDS=2592000
(30 days, .env files are gitignored — applied on the running container
via docker compose up -d --force-recreate app).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Insert Phase 7.1 between Phase 7 and Phase 8 to address dashboards/filters
rendering wrong dates because day/week boundary math runs in server UTC
instead of the viewing user's timezone. Persistence stays UTC; only the
read/display path changes.
5 plans in 3 waves:
- 07.1-01 (Wave 1): migration 083 + Better Auth additionalField timezone
- 07.1-02 (Wave 1): /api/me/timezone GET+PUT with IANA validation
- 07.1-03 (Wave 2): server-side AT TIME ZONE migration across 6 routes,
including auth-gate fix on /api/mobile/finance and trends route
- 07.1-04 (Wave 2): useUserTimezone() hook + 2 mobile pages + codebase audit
- 07.1-05 (Wave 3): codebase-wide useUserTimezone() adoption per audit
Add Phase 9 stub (User Profile & Preferences) to roadmap for the picker UI
that reuses 7.1's hook + endpoint.
REQUIREMENTS.md TZ-02 carves out engagement_snapshots UTC bucketing as a
documented exception (≤24h drift acceptable for admin overview).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- GET handler with requireAuth() gate before any DB query (T-07-01)
- Period whitelist ['D7','D30','D90'] with 400 for invalid values (T-07-02)
- generate_series ensures continuous daily series (D-15: no gaps)
- Returns EngagementTrendResponse with D7→7, D30→30, D90→90 SparklinePoints
- Bounded result set: whitelist caps to max 90 rows (T-07-03)
- Exports SparklinePoint and EngagementTrendResponse for Plan 03 import
- GET handler with requireAuth() gate before any DB query (T-07-01)
- Period whitelist ['D7','D30','D90'] with 400 for invalid values (T-07-02)
- Returns MobileEngagementSummary: configured, activeUsers, totalGraphHours, totalAutotaskHours, hoursPerActiveUser
- Reuses notAutomatedFilter and wulfconsulting email scope from desktop summary
- Exports MobileEngagementSummary interface for Plan 03 page import
- Replaces 'coming soon' placeholder with full read-only feed
- useState/useEffect/fetch only (no SWR/react-query per CLAUDE.md D-38)
- IntersectionObserver sentinel with rootMargin 200px for auto-load
- Load more fallback button with aria-label, min-h-[44px] touch target
- 5 AnalyzerRowSkeleton instances on initial load (D-28)
- Empty state with dashed border, Sparkles icon, Open desktop Analyzer link
- toast.error on load failures; Load more flips to Retry on error
- No edit/re-run/prompt-tuning controls (ANL-05)
- AnalyzerStagePips: 3-dot stage indicator with caret separators, sr-only accessibility label
- ConfidenceBadge: High/Medium/Low buckets (0.85/0.65) with green/amber/slate tones, dark mode
- AnalyzerRowSkeleton: Card-wrapped skeleton matching row shape (no border-l-4 per D-11)
- AnalyzerFeedRow: Full card row with header/title/summary/footer, Link to /mobile/analyzer/[id]
- Create cursor-paginated analyzer feed endpoint for mobile
- Export AnalyzerFeedRow and AnalyzerFeedResponse types (D-26)
- Implement DISTINCT ON CTE for latest-per-ticket analysis (D-02)
- Apply kiosk_settings company scoping via getMobileCompanyFilter() (D-04)
- Cursor keyset pagination on (completed_at, id) with base64 JSON encoding (D-06)
- Server-side limit cap at 25 (D-05); LIMIT n+1 trick for hasMore detection
- Ordering: completed_at DESC NULLS LAST, id DESC (D-03)
- Manual snake_case to camelCase transform per CLAUDE.md conventions
- Payload whitelist: only 12 AnalyzerFeedRow fields; no model_traces, itglue_docs_referenced, or human_review_reasons (T-06-05)
- requireAuth() gate before any DB query (T-06-01)