Skip to content

feat: timezone setting in the profile editor (WEB-155) - #1148

Merged
sugh01 merged 9 commits into
mainfrom
feature/web-155-review-timezone-setting-in-the-profile-editor
Jul 13, 2026
Merged

feat: timezone setting in the profile editor (WEB-155)#1148
sugh01 merged 9 commits into
mainfrom
feature/web-155-review-timezone-setting-in-the-profile-editor

Conversation

@Claus36868

Copy link
Copy Markdown
Contributor

Summary

Implements WEB-155 — Timezone setting in the profile editor. Users can now set a timezone text 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

  • timezone added to supportedGeneralRecordKeys — so it lands in the General group, categorises as general (not "other"), and flows through the existing setRecords multicall unchanged.
  • New pure util utils/getTimezoneOffset.ts (native Intl, DST-aware) — computes the offset between the profile's zone and the viewer's zone at render time; returns null for unset/invalid zones.
  • ProfileSnippet.tsx renders the timezone beside location with a clock icon: <zone> (±HH:MM). Unset/invalid renders nothing.

Editor

  • New constants/timezoneOptions.ts built from Intl.supportedValuesOf('timeZone').
  • New TimezonePicker.tsx — a searchable IANA picker (thorin Select autocomplete) wired through a react-hook-form Controller. The picker is rendered wherever a timezone General 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.
  • New validators/validateTimezone.ts, wired into validatorForRecord; i18n label + placeholder under groups.general in register.json.
  • Graceful with pre-existing data: a timezone record that isn't a canonical IANA id (e.g. an informal PST set before this feature, or a zone the runtime's Intl build doesn't list) is surfaced in the picker and never blocks saving other edits.

Untouched (by design): getSocialData, supportedSocialRecordKeys, the transaction layer, and package.json.

Tests

  • Unit: 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 a categoriseProfileTextRecords case proving timezone lands in general not other. ProfileSnippet.test.tsx covers render + graceful unset/invalid.
  • e2e (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 pinned timezoneId (viewer Asia/Kolkata +05:30, picks Asia/Tokyo +09:00 → deterministic, DST-free +03:30).

Decisions on the ticket's open questions

  • Same-zone display: shows (+00:00) (consistent with the ±HH:MM format) rather than a "same as you" string.
  • Header vs records list: header-only for the viewer-relative offset; the raw record is still editable in the records list like any general record.
  • AdvancedEditor: keeps timezone as free text (unchanged), per the ticket notes.
  • e2e location: the editor add/save/display flow is exercised in profileEditor.spec.ts (the stateless editor spec with makeName), the only place that can drive add → save → public display end-to-end.

Notes

  • timezone is an app-level convention, not a standardized ENSIP-5 key.
  • Intl.supportedValuesOf is ES2022 (fine under the repo's esnext lib); timezoneOptions.ts degrades to an empty picker on older runtimes rather than throwing.

Ref: WEB-155

🤖 Generated with Claude Code

https://claude.ai/code/session_01KDLdHpvQQRizSgichgGFKS

Claus36868 and others added 3 commits June 29, 2026 09:20
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
@Claus36868
Claus36868 requested review from laurgk and sugh01 as code owners June 29, 2026 09:40
@cloudflare-workers-and-pages

cloudflare-workers-and-pages Bot commented Jun 29, 2026

Copy link
Copy Markdown

Deploying ens-app-v3 with  Cloudflare Pages  Cloudflare Pages

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

View logs

@sugh01

sugh01 commented Jul 7, 2026

Copy link
Copy Markdown
Contributor

Manual QA report — Timezone in Profile Editor (Claude in Chrome)

Env: localhost:3000, localhost testnet, wallet 0xf39…92266. Viewer TZ: Europe/Stockholm (CEST/+02:00 on 2026-07-07). Names: other-controller.eth, other-registrant.eth.

Nav note: the live profile route is /<name> (e.g. /other-controller.eth), not /profile/<name> as the plan assumed — adjusted accordingly.

Results — 13/14 PASS, 1 FAIL

TC Result Notes
TC1 Add via picker ✅ PASS Timezone under General; open-then-search worked; Asia/Tokyo selected; tx completed
TC2 Record written ✅ PASS Records tab: timezone: Asia/Tokyo exactly
TC3 Header offset ✅ PASS Asia/Tokyo (+07:00) = Tokyo +09:00 − Stockholm +02:00
TC4 Viewer-relative ✅ PASS Simulated viewer America/New_York(+13:00), stored value unchanged
TC5 Same-zone ✅ PASS Viewer Asia/Tokyo(+00:00)
TC6 Quarter-hour ✅ PASS* Offset padding correct (Asia/Katmandu (+05:45)). Caveat below.
TC7 DST ✅ PASS Europe/London (+01:00) (BST) for today
TC8 Search UX ✅ PASS New York → label America/New York, round-trips to America/New_York; gibberish → "No results", no crash
TC9 Clear/delete ✅ PASS delete: timezone tx; record count → 0; header element gone
TC10 Unset ✅ PASS No clock/text
TC11 Invalid value FAIL (part 1) See below. Part 2 (graceful editing) passed.
TC12 Layout ✅ PASS (desktop) Location + clock + url on one row, no overlap. Mobile viewport not verified (tooling limitation)
TC13 Other flows ✅ PASS (2/3) Picker confirmed in registration and create-subname; reclaim not reached
TC14 Cancel ✅ PASS Unsaved edit + Cancel → no write

❌ TC11 — invalid timezone renders an offset instead of nothing

Set a raw timezone: PST record via the Advanced editor. The spec says an invalid/unresolvable zone should render nothing; instead the header rendered PST (-09:00).

Root cause (in this PR): isValidTimezone treats "anything Intl.DateTimeFormat doesn't throw on" as valid. V8's ICU data resolves legacy aliases like PST, EST, GMT+5 etc., so they slip through validation and render a bogus label. The same permissive check backs validateTimezone, so these values also aren't rejected on the free-text/programmatic path.

Suggested fix: validate against the canonical list the picker already uses (Intl.supportedValuesOf('timeZone') in timezoneOptions.ts) rather than "didn't throw". Render null for anything not in that set. (Keep the existing try/catch as a secondary guard.) Note this must degrade gracefully where supportedValuesOf is unavailable — falling back to the current permissive check is acceptable there.

⚠️ TC6 caveat — canonical modern zone names missing from the picker

Searching Kathmandu / Kolkata returns no results; only the legacy aliases Asia/Katmandu and Asia/Calcutta are searchable. The picker is populated from Intl.supportedValuesOf('timeZone'), whose output depends on the runtime's ICU build — on this V8 it surfaces the deprecated spellings. Worth deciding whether to (a) accept runtime-dependent naming, or (b) normalize/alias common modern names so users searching Kolkata/Kathmandu find them. Not a blocker, but a discoverability gap.

Not verified

  • Mobile/narrow viewport for TC12 (resize didn't take effect — tester tooling limitation, not a confirmed app issue).
  • Reclaim flow picker (TC13).
  • TC4/TC5 used a JS-level Intl override rather than DevTools Sensors (Sensors panel not reachable via the tester's tools) — functionally equivalent for this logic.

Comment thread src/utils/getTimezoneOffset.ts Outdated
if (!timeZone || typeof timeZone !== 'string') return false
try {
// Throws RangeError for unknown time zones.
Intl.DateTimeFormat(undefined, { timeZone })

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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
@sonarqubecloud

sonarqubecloud Bot commented Jul 8, 2026

Copy link
Copy Markdown

@sugh01
sugh01 merged commit 62a53f8 into main Jul 13, 2026
48 checks passed
@sugh01
sugh01 deleted the feature/web-155-review-timezone-setting-in-the-profile-editor branch July 13, 2026 14:19
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants