feat: timezone setting in the profile editor (WEB-155) - #1148
Conversation
Let users set a `timezone` text record via the profile editor and show it on
the public profile header next to location, as a viewer-relative, DST-aware
offset (e.g. `Europe/London (+02:00)`).
- Add `timezone` to `supportedGeneralRecordKeys` so it lands in the General
group, categorises as general (not "other"), and flows through the existing
`setRecords` transaction unchanged.
- New pure `utils/getTimezoneOffset.ts` (native `Intl`, no date dep) computing
the offset between the profile's zone and the viewer's zone at render time.
- Render the timezone beside location in `ProfileSnippet` with a clock icon;
unset/invalid zones render nothing.
- New `constants/timezoneOptions.ts` (from `Intl.supportedValuesOf('timeZone')`)
and `TimezonePicker.tsx` (thorin `Select` autocomplete via a react-hook-form
`Controller`), injected at the `key === 'timezone'` branch in the editor.
- New `validators/validateTimezone.ts` wired into `validatorForRecord`; i18n
label + placeholder under `groups.general`.
- Unit tests for the offset util, validator, and categorisation; e2e covering
add → save → viewer-relative display → clear (pinned `timezoneId`).
Ref: WEB-155
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KDLdHpvQQRizSgichgGFKS
Remediation from the /code-review pass (all non-gating, but real): - Render the searchable TimezonePicker wherever a `timezone` General record can be added — the registration profile step, create-subname, and reclaim flows, not just the profile editor — so it is never a free-text box (criterion 2). - Open the thorin Select before typing in the e2e test: the autocomplete search input only mounts when the dropdown is open, so the previous `fill` would have timed out. - Don't block saving over a pre-existing non-IANA `timezone` value the user hasn't changed (validatorForRecord escape hatch), and surface a current value that isn't in the runtime's `supportedValuesOf` list in the picker so it displays and round-trips instead of appearing unset. - Make `isValidTimezone` a type predicate (drops an `as string` cast); remove an unused `id` on the timezone Typography. - Tests: assert the i18n label/placeholder, the multi-word IANA value/label transform, and viewer-side DST in the relative offset. Ref: WEB-155 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KDLdHpvQQRizSgichgGFKS
Closes the test-coverage gaps the second /code-review pass flagged on the remediation commit (all non-gating): - Extract the picker's "surface a non-canonical / pre-existing value" logic into a pure `timezoneOptionsWithValue` helper and unit-test its branches (prepend for an unlisted value, passthrough for a listed/empty value). - Add hook tests for the `timezone` validator branch: valid IANA -> true, empty -> true, invalid -> error, and the pre-existing-value bypass. - Add a TimezonePicker render test asserting it renders the searchable Select (not a free-text record input) and a delete control. Ref: WEB-155 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KDLdHpvQQRizSgichgGFKS
Deploying ens-app-v3 with
|
| Latest commit: |
6e649b1
|
| Status: | ✅ Deploy successful! |
| Preview URL: | https://d5217543.ens-app-v3.pages.dev |
| Branch Preview URL: | https://feature-web-155-review-timez.ens-app-v3.pages.dev |
…he-profile-editor
Manual QA report — Timezone in Profile Editor (Claude in Chrome)Env: Nav note: the live profile route is Results — 13/14 PASS, 1 FAIL
❌ TC11 — invalid timezone renders an offset instead of nothingSet a raw Root cause (in this PR): Suggested fix: validate against the canonical list the picker already uses (
|
| if (!timeZone || typeof timeZone !== 'string') return false | ||
| try { | ||
| // Throws RangeError for unknown time zones. | ||
| Intl.DateTimeFormat(undefined, { timeZone }) |
There was a problem hiding this comment.
QA finding (TC11, FAIL): this treats "anything Intl.DateTimeFormat doesn't throw on" as a valid zone. V8's ICU resolves legacy aliases (PST, EST, GMT+5…), so an informal timezone: PST record slips through and the header renders PST (-09:00) instead of nothing.
Since this function also backs validateTimezone, these values aren't rejected on the free-text/programmatic path either.
Consider validating against the canonical set the picker already builds from Intl.supportedValuesOf('timeZone') (see timezoneOptions.ts) and returning false for anything not in it — keeping this try/catch as a secondary guard, and falling back to the permissive check only where supportedValuesOf is unavailable.
…a permissive parse Reuse the picker's Intl.supportedValuesOf allowlist so informal aliases like PST no longer pass validation or render an offset. Ref: WEB-155
Declare fill: currentColor on the Timezone icon's svg rule so the clock renders from the component's own styles instead of relying solely on the asset's inline fill. Ref: WEB-155
The `timezone` general record had no entry in DynamicTextIcon's icon map, so its option/row in the profile step fell back to QuestionCircleSVG — a "?" placeholder — instead of an icon. Register a clock for it. - New TextTimezone.svg (outline clock, matches the Text* record-icon family). - Map `timezone` to it in DynamicTextIcon. - Test that every supported general record key has a dedicated icon, so a new general record can't ship without one. Ref: WEB-155
…eader" This reverts commit 09c1c1b. The profile-header clock was never the missing icon: it renders from the asset's own fill="currentColor", so the added svg-root rule was a no-op, and the reverted test only asserted that an <svg> element exists (always true for an SVGR icon). The icon that was actually missing is the timezone record icon in the editor, fixed separately in this branch. Ref: WEB-155
…feature/web-155-timezone-icon
|



Summary
Implements WEB-155 — Timezone setting in the profile editor. Users can now set a
timezonetext record via the profile editor and see it on the public profile header, next to location, as a viewer-relative, DST-aware UTC offset (e.g.Europe/London (+02:00)= how far ahead/behind the profile's zone is from the viewer's).Design reused from the closed PR #568, updated to current code and using native
Intl(no new date dependency — luxon was unnecessary).What changed
Display
timezoneadded tosupportedGeneralRecordKeys— so it lands in the General group, categorises as general (not "other"), and flows through the existingsetRecordsmulticall unchanged.utils/getTimezoneOffset.ts(nativeIntl, DST-aware) — computes the offset between the profile's zone and the viewer's zone at render time; returnsnullfor unset/invalid zones.ProfileSnippet.tsxrenders the timezone beside location with a clock icon:<zone> (±HH:MM). Unset/invalid renders nothing.Editor
constants/timezoneOptions.tsbuilt fromIntl.supportedValuesOf('timeZone').TimezonePicker.tsx— a searchable IANA picker (thorinSelectautocomplete) wired through a react-hook-formController. The picker is rendered wherever atimezoneGeneral record can be added — the profile editor and the registration profile step, create-subname, and reclaim flows (all share the record editor) — so it's never a free-text box.validators/validateTimezone.ts, wired intovalidatorForRecord; i18n label + placeholder undergroups.generalinregister.json.timezonerecord that isn't a canonical IANA id (e.g. an informalPSTset before this feature, or a zone the runtime'sIntlbuild doesn't list) is surfaced in the picker and never blocks saving other edits.Untouched (by design):
getSocialData,supportedSocialRecordKeys, the transaction layer, andpackage.json.Tests
getTimezoneOffset.test.ts(pins the viewer zone; covers positive/negative/same-zone, quarter-hour+05:45/+03:15, and DST via Jan vs July London),validateTimezone.test.ts, and acategoriseProfileTextRecordscase proving timezone lands ingeneralnotother.ProfileSnippet.test.tsxcovers render + graceful unset/invalid.profileEditor.spec.ts): add → pick from the searchable picker → save → assert the written record and the viewer-relative header offset → clear → assert deletion. Uses a pinnedtimezoneId(viewerAsia/Kolkata+05:30, picksAsia/Tokyo+09:00 → deterministic, DST-free+03:30).Decisions on the ticket's open questions
(+00:00)(consistent with the±HH:MMformat) rather than a "same as you" string.timezoneas free text (unchanged), per the ticket notes.profileEditor.spec.ts(the stateless editor spec withmakeName), the only place that can drive add → save → public display end-to-end.Notes
timezoneis an app-level convention, not a standardized ENSIP-5 key.Intl.supportedValuesOfis ES2022 (fine under the repo'sesnextlib);timezoneOptions.tsdegrades to an empty picker on older runtimes rather than throwing.Ref: WEB-155
🤖 Generated with Claude Code
https://claude.ai/code/session_01KDLdHpvQQRizSgichgGFKS