Files
pn-new-crm/docs/superpowers/specs/2026-04-28-country-phone-timezone-design.md
Matt Ciaccio 4036c16f39 test(infra): vitest globalSetup teardown purges test-port-* leaks
Integration tests use makePort() which writes ports with slug 'test-port-{rand}'
and never cleans up. Result: 17,564 leaked rows in dev that slowed every page
load fetching the port-switcher list (and was contributing to smoke flakes).

Adds tests/global-setup.ts with a teardown() that DELETEs every 'test-port-%'
row plus its dependent rows across 30+ tables in one CTE. Wires it into
vitest.config.ts via globalSetup. Adds closeDb() helper so the teardown can
end the postgres-js pool cleanly (kills the 'Tests closed but Vite server
won't exit' warning).

Also lands docs/superpowers/specs/2026-04-28-country-phone-timezone-design.md
— full-scope agenda for the country dropdown / E.164 phone input /
country-driven timezone autofill work, ~7 dev days across 10 PRs. Per
user request: 'let's do this full-fledged if we're gonna do it'.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-28 13:28:15 +02:00

11 KiB
Raw Permalink Blame History

Country / Phone / Timezone — i18n form polish

Status: Agenda — awaiting prioritization (likely Phase B or B.5) Date: 2026-04-28 Phase: Cross-cutting; touches every form that captures contact data

Why

Today every CRM form takes free-text strings for nationality, phone, and timezone. That's fine for a marina with one operator typing it in once, but it leaks operator inconsistencies into reports and breaks any later system that consumes these fields (Documenso prefill, public website inquiry, portal sync, exports). For a multi-port platform that's about to onboard non-Polish-speaking residential clients, the data quality matters.

Three coupled UX upgrades:

  1. Nationality → ISO-3166 country dropdown. Searchable. Stores ISO alpha-2 code ('GB'), displays localized country name.
  2. Phone → country-code dropdown + format-as-you-type. E.164 storage on the wire, formatted display per country.
  3. Timezone → autofilled from country with override dropdown. Most countries are single-zone; the few that aren't (US, RU, AU, BR, CA, ID, KZ, MN, MX, CD) get a sub-select. Stores IANA TZ string ('Europe/Warsaw').

Scope

In scope

  • New shared primitives: <CountryCombobox>, <PhoneInput>, <TimezoneCombobox>
  • ISO-3166 country list bundled (no API call); names from Intl.DisplayNames with locale fallback to English
  • Country → primary IANA timezone map (~250 entries, JSON)
  • Phone parsing/validation/formatting via libphonenumber-js (server + client)
  • Wire into every form that captures contact data:
    • <ClientForm> (name, nationality, phone)
    • <ResidentialClientDetail> inline editor (nationality, phone, place_of_residence — country-aware)
    • <CompanyForm> (incorporation_country)
    • <PortalActivateForm> (phone)
    • public inquiry form (form-template renderer, when phone field present)
  • DB migration: store ISO codes (countries, nationality_iso), E.164 phone (phone_e164), IANA timezone (timezone)
  • Backfill: best-effort parse existing free-text into the new columns; keep originals as _legacy for one release cycle
  • Display: localized country name in tables/detail pages; phone formatted per country (e.g. +44 20 7946 0958); timezone shown as friendly 'London (UTC+1)' when current
  • Tests: unit (parser edge cases), integration (form submit → E.164 storage), smoke (typing + selecting flows)

Out of scope (deferred)

  • Multilingual UI surface (only the country names localize via Intl.DisplayNames; rest of the UI stays English for now)
  • Subdivision picker (states/provinces) — only top-level country
  • Phone number geocoding / carrier lookup
  • Address autocomplete (Google Places, etc.)
  • Currency localization
  • RTL layout

Library choices

Concern Library Why
Phone input + flag dropdown omeralpi/shadcn-phone-input Built on shadcn-ui's Input primitive (zero styling friction with our component library), wraps libphonenumber-js, ships with country dropdown + format-as-you-type. Small bundle.
Phone parsing/validation libphonenumber-js Google's library, ~88 benchmark, used by every popular React phone input. Server-side validation in zod.
Country list Bundled JSON of ISO-3166 alpha-2 codes + 3-letter codes + display names (English baseline) No need for the heavier country-state-city databases — we don't need cities or states yet.
Country → timezone Hand-curated country-timezones.json (250 entries, ~10kb) sourced from country-tz or moment-timezone's data Static, no network call. For multi-zone countries, expose a sub-select.
Timezone formatting Intl.DateTimeFormat (built-in) Browser API; renders 'Europe/Warsaw (UTC+1)'-style labels.
Timezone list Intl.supportedValuesOf('timeZone') (built-in, ~600 entries) Used as the override dropdown when a user wants a non-primary zone.

Bundle impact: libphonenumber-js mobile build is ~80 KB gz; shadcn-phone-input is ~5 KB; country/timezone JSONs ~30 KB. All client-side, lazy-loaded on first form render via next/dynamic.

Schema deltas

-- clients
ALTER TABLE clients ADD COLUMN nationality_iso text;          -- 'GB'
ALTER TABLE clients ADD COLUMN timezone text;                  -- 'Europe/London'
-- existing 'nationality' free-text column stays for a release; new code reads ISO

-- client_contacts (or wherever phone lives)
ALTER TABLE client_contacts ADD COLUMN value_e164 text;        -- '+442079460958'
ALTER TABLE client_contacts ADD COLUMN value_country text;     -- 'GB' (where the number was parsed against)
-- existing 'value' stays as the human-displayable formatted form

-- residential_clients — same pattern
ALTER TABLE residential_clients ADD COLUMN nationality_iso text;
ALTER TABLE residential_clients ADD COLUMN timezone text;
ALTER TABLE residential_clients ADD COLUMN phone_e164 text;
ALTER TABLE residential_clients ADD COLUMN phone_country text;

-- companies
ALTER TABLE companies ADD COLUMN incorporation_country_iso text;

Indexes: idx_clients_nationality_iso, idx_clients_timezone (cheap; powers analytics filters later).

Component primitives

<CountryCombobox
  value={iso}                    // 'GB' | undefined
  onChange={(iso) => }
  locale="en"                    // for name lookup; default to navigator.language
  variant="default" | "compact"  // compact = icon-only flag, default = name
/>

<PhoneInput
  value={e164}                   // '+442079460958'
  onChange={({ e164, country }) => }
  defaultCountry={'GB'}          // pre-selects the dropdown
  required={false}
/>

<TimezoneCombobox
  value={iana}                   // 'Europe/London'
  onChange={(iana) => }
  countryHint={'GB'}             // when set, narrows the dropdown to matching zones first
/>

All three are shadcn-styled, keyboard-accessible, support form integration with react-hook-form + zod.

Validators

// src/lib/validators/contact.ts
import { isValidPhoneNumber } from 'libphonenumber-js';

export const phoneE164Schema = z
  .string()
  .refine((v) => isValidPhoneNumber(v), 'Invalid phone number');

export const isoCountrySchema = z
  .string()
  .length(2)
  .toUpperCase()
  .refine((c) => ISO_COUNTRIES.has(c), 'Unknown country');

export const ianaTimezoneSchema = z
  .string()
  .refine((tz) => Intl.supportedValuesOf('timeZone').includes(tz), 'Unknown timezone');

Backfill plan

A migration script (scripts/backfill-iso-and-e164.ts) that:

  1. For each client/residential_client, attempt libphonenumber-js parsePhoneNumber(rawPhone, { defaultCountry: 'PL' }) → if valid, write phone_e164 + phone_country.
  2. For each free-text nationality, fuzzy-match against the country name list (exact match first, then Levenshtein ≤2). Write nationality_iso if confident.
  3. For each timezone, exact-match against IANA list. Otherwise leave null and let user fill it.
  4. Log unparseable rows to backfill-iso-report.csv for manual review.

Run on staging first; require dry-run flag.

Build sequence

# PR Effort Depends on
1 Country list JSON + ISO sets + <CountryCombobox> primitive 0.5d
2 libphonenumber-js integration + <PhoneInput> primitive 1d
3 Country → timezone JSON + <TimezoneCombobox> primitive 0.5d 1
4 Schema deltas + drizzle migrations + zod validators 0.5d
5 Wire into ClientForm + ClientDetail inline editors 1d 1, 2, 3, 4
6 Wire into ResidentialClientDetail 0.5d 5
7 Wire into CompanyForm 0.5d 1
8 Public inquiry form template renderer support 0.5d 2
9 Backfill script + dry-run runbook 1d 4
10 Smoke + integration tests 1d 59

Total: ~7 dev days. Self-contained; no external dependencies on Phase B (analytics/alerts).

Risk register

Risk Mitigation
Bundle bloat from libphonenumber data Use the mobile metadata build, lazy-import via next/dynamic
Existing free-text data is too messy to backfill Keep the legacy column for one release; expose a "needs review" badge in admin
Multi-zone country UX confusion Sub-select only appears when country is multi-zone; otherwise zone is hidden behind "Override"
Public inquiry form breaks if phone is required and user can't find their country Default to PL, search by country name and dial code

Open questions for the user

  • Which port's locale should drive the default country in <PhoneInput> (Poland for now, or detect from browser)?
  • Should existing free-text nationality field be removed once backfilled, or kept indefinitely as a fallback?
  • Is there an appetite for adding the same treatment to subdivision (state/region/voivodship) selectors, or strictly country-level for now?