- Typography: remove text-base (16px) reference for Card titles; map
them to the declared 20px Heading role (text-xl) to keep scale at
exactly 4 sizes. Add explicit note that section header labels use
the 12px Label role, not a fifth size.
- Copywriting: add explicit "Save custom topic" row to Channels table
for the ntfy advanced-disclosure save button; also propagate the
label into the layout block and the save-model table so executor
has no ambiguity.
- Visuals (optional flag): declare page focal point — H1 is entry
anchor, Channels Card is conversion-weight section.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
The window scrolls (not <main>) on this layout, and the document content
height isn't fully laid out by the first rAF after rows render — so
window.scrollTo gets clamped to maxScroll, leaving the user near top.
Retry up to 30 frames (~500ms) until the actual scroll position matches
the target within 4px.
The mobile shell's <main> is overflow-y-auto, so Next.js's built-in
scrollRestoration (window-only) doesn't restore the list's inner scroll
when returning from /mobile/engagement/[userId]. Persist the scroll
position to sessionStorage on scroll (rAF-throttled) and restore once
after the first users page loads.
Restoration is gated to the first load only, so changing period/sort
doesn't yank the viewport — and uses a single sessionStorage key, so
returning to the list later still lands where you were.
Postgres returns NUMERIC columns as strings via pg, so calling
.toFixed(1) on time_entries.hours_worked from /api/engagement/user/[userId]
threw at runtime. The period-level hours in the same response are already
parseFloat'd; the recentEntries and matchedEntries arrays pass rows through
verbatim, so wrap with Number() at render.
- New EngagementProfileBreakdown: Time/Communication/Meetings subsections, after-hours
and Zoom conditional rows, py-2 per UI-SPEC override
- New EngagementRecentEntries: collapsible list up to 10, Billable badge, Set<string>
expand state, empty-state copy
- New EngagementRecentMeetings: collapsible list up to 10, matched entries + attendees
in expanded view, (no subject) fallback, Set<string> expand state
- Page updated: 3 new component imports + breakdown/entries/meetings mounted in order
- No dangerouslySetInnerHTML; D-01/D-22 guard rails untouched
- New app/mobile/engagement/[userId]/page.tsx with fetch + error states + retryNonce
- New EngagementProfileSkeleton with header/metric/breakdown/list skeletons
- 404 renders 'User not found' + back link; 500 renders sonner toast + Retry
- D-04 comment: relies on App Router default scrollRestoration
- D-01/D-22 guard rails: EngagementUserRow.tsx and data endpoint untouched
- Proxies Microsoft Graph user photo bytes to authenticated mobile clients
- requireAuth() is first call — unauthenticated requests get 401 before Graph
- 503 when MSGRAPH_* env not configured (isMsgraphConfigured gate, D-26)
- 400 for malformed userId (path traversal denylist, permissive per VARCHAR(255))
- 404 neutral response when user has no photo (no userId oracle)
- 200 with Cache-Control: private, max-age=3600 on success (D-25)
- 502 neutral response on Graph upstream errors (no token/user leakage)
- New public method fetches binary photo from Graph /users/{id}/photo/$value
- Returns { bytes, contentType } on 200, null on 404 (no photo)
- Throws on other non-2xx for upstream caller to map to 502/503
- Reuses getToken() OAuth2 cache; no retry (best-effort per D-26)
- Existing methods (getToken, fetchJson, getUsers, etc.) untouched
- Collapse 6 font sizes to 4 (12, 14, 20, 24px): promote display name
from text-lg (18px) to text-xl (20px); promote avatar initials and
period chip text from text-[10px] to text-xs (12px)
- Remove non-standard "Additional fixed sizes" block; contract now
declares exactly 4 canonical sizes
- Drop font-medium (500); breakdown subsection headers move to
font-semibold (600) — two weights only: 400 + 600
- Replace py-1.5 (6px) with py-2 (8px) throughout breakdown rows;
note D-16 override with rationale; clarify min-h-[44px] as WCAG
floor only (not a spacing/padding value)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- zabbix-wan, rmm-overshell, itglue-writes, ticket-digest,
device-link-conflicts, workflow/history, workflow/pipelines/[id]:
each gets useUserTimezone() at the component entry; threads tz into
every inline toLocaleString call.
Migrates 11 of 81 audit leak callsites.
- app/engagement/page.tsx: useUserTimezone in EngagementPage; thread tz
into 7 toLocale* callsites (lines 844, 1012, 1108, 1227, 1255 — last
two have 2 calls per line for date+time).
- app/engagement/profile/page.tsx: useUserTimezone in EngagementProfilePage;
add tz prop to ActivityHeatmap; convert module-scope monthLabel(m) to
monthLabel(m, tz); update 2 callsites of monthLabel.
Migrates 9 of 81 audit leak callsites.
- 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.