Skip to content

fix: improve country code detection for phone input#28175

Open
yuvrajangadsingh wants to merge 9 commits into
calcom:mainfrom
yuvrajangadsingh:fix/country-code-detection
Open

fix: improve country code detection for phone input#28175
yuvrajangadsingh wants to merge 9 commits into
calcom:mainfrom
yuvrajangadsingh:fix/country-code-detection

Conversation

@yuvrajangadsingh
Copy link
Copy Markdown
Contributor

@yuvrajangadsingh yuvrajangadsingh commented Feb 25, 2026

What does this PR do?

the phone number field's country auto-detection only checks x-vercel-ip-country header. self-hosted deployments behind Cloudflare get no country detection, falling back to navigator.language which gives browser locale (language preference), not physical location.

this adds cf-ipcountry header support (matching the pattern already in app/layout.tsx) and replaces the unreliable navigator.language fallback with timezone-based country detection using Intl.DateTimeFormat().resolvedOptions().timeZone.

Visual Demo (For contributors especially)

N/A — no UI changes, only the pre-selected country code in the phone input changes based on better detection logic. the phone input component itself is unchanged.

Mandatory Tasks (DO NOT REMOVE)

  • I have self-reviewed the code (A decent size PR without self-review might be rejected).
  • I have updated the developer docs in /docs if this PR makes changes that would require a documentation change. N/A
  • I confirm automated tests are in place that prove my fix is effective or that my feature works.

How should this be tested?

  1. deploy behind Cloudflare (or set cf-ipcountry header manually via dev proxy)
  2. open any page with a phone number input (e.g. booking form with phone field)
  3. verify the country code auto-selects based on the Cloudflare header instead of defaulting to US

for the timezone fallback:

  1. clear the cf-ipcountry and x-vercel-ip-country headers (self-hosted without reverse proxy geolocation)
  2. set OS timezone to e.g. Asia/Kolkata
  3. verify phone input defaults to India (+91) instead of US

Checklist

  • I have read the contributing guide
  • My code follows the style guidelines of this project
  • I have checked if my changes generate no new warnings

@github-actions github-actions Bot added foundation High priority Created by Linear-GitHub Sync 🐛 bug Something isn't working labels Feb 25, 2026
Copy link
Copy Markdown
Contributor

@cubic-dev-ai cubic-dev-ai Bot left a comment

Choose a reason for hiding this comment

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

No issues found across 3 files

@yuvrajangadsingh yuvrajangadsingh marked this pull request as ready for review February 25, 2026 15:47
@graphite-app graphite-app Bot added the community Created by Linear-GitHub Sync label Feb 25, 2026
@graphite-app
Copy link
Copy Markdown

graphite-app Bot commented Feb 25, 2026

Graphite Automations

"Send notification to Community team when High Priority PR opened" took an action on this PR • (02/25/26)

2 teammates were notified to this PR based on Keith Williams's automation.

@yuvrajangadsingh yuvrajangadsingh force-pushed the fix/country-code-detection branch 3 times, most recently from 3d492f5 to 92c8f0d Compare March 1, 2026 21:30
@yuvrajangadsingh
Copy link
Copy Markdown
Contributor Author

hey, this is a small fix for country code detection in the phone input. no reviewer assigned yet, would appreciate someone taking a look. pretty straightforward change.

@yuvrajangadsingh yuvrajangadsingh force-pushed the fix/country-code-detection branch 7 times, most recently from c263743 to 1455c76 Compare March 10, 2026 11:26
@yuvrajangadsingh yuvrajangadsingh force-pushed the fix/country-code-detection branch 2 times, most recently from 8f26af3 to cd07bb1 Compare March 14, 2026 18:46
@yuvrajangadsingh
Copy link
Copy Markdown
Contributor Author

rebased onto latest main. this fixes a high priority bug with country code detection for phone inputs. would appreciate a review if anyone on the team has bandwidth.

@yuvrajangadsingh yuvrajangadsingh force-pushed the fix/country-code-detection branch 3 times, most recently from 032576f to 61faff1 Compare March 19, 2026 11:17
@github-actions
Copy link
Copy Markdown
Contributor

This PR has been marked as stale due to inactivity. If you're still working on it or need any help, please let us know or update the PR to keep it active.

@github-actions github-actions Bot added the Stale label Mar 27, 2026
@yuvrajangadsingh
Copy link
Copy Markdown
Contributor Author

still active, waiting on review.

@github-actions github-actions Bot removed the Stale label Mar 28, 2026
@yuvrajangadsingh
Copy link
Copy Markdown
Contributor Author

bump, anything needed from my side on this one?

@yuvrajangadsingh
Copy link
Copy Markdown
Contributor Author

rebased on latest main via merge commit 4f25ea0 (no conflicts). should clear the 'required' check now.

@keithwillcode would appreciate a look. high priority bug, small fix scoped to PhoneInput country-code detection across 3 files.

@coderabbitai
Copy link
Copy Markdown
Contributor

coderabbitai Bot commented May 3, 2026

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review
📝 Walkthrough

Walkthrough

Three files were updated. The geolocation API route and public viewer handler now prefer the cf-ipcountry header before x-vercel-ip-country; the route falls back to "Unknown" and the handler falls back to an empty string when neither header is present. The geolocation route’s Cache-Control was changed to private, max-age=3600 and now documents per-request header variation. The phone input adds TIMEZONE_COUNTRY_MAP, a safe getResolvedTimezone() helper, and reworks its effect to wait for the TRPC countryCode query to settle, use a supported countryCode when present, otherwise derive a fallback from the resolved timezone, and preserve the initial default if unmapped.

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 0.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The title 'fix: improve country code detection for phone input' accurately reflects the main change—enhancing country code detection logic across multiple files.
Description check ✅ Passed The description provides relevant context about the fix, linked issue #23062, technical details about added header support and timezone-based fallback, and clear testing instructions.
Linked Issues check ✅ Passed Changes directly address issue #23062 by adding cf-ipcountry header support, replacing navigator.language with timezone-based detection, and improving country code auto-selection accuracy.
Out of Scope Changes check ✅ Passed All changes are scoped to country code detection: server-side headers, timezone mapping, cache control, and consumer-side fallback logic. No unrelated modifications detected.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 1

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
apps/web/components/phone-input/PhoneInput.tsx (1)

257-269: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

getCountryFromTimezone() is unreachable — this is the consumer-side manifestation of the handler bug.

When the server returns "" (no CF/Vercel headers), !data?.countryCode is true at line 257, so the effect returns early and lines 264-269 are never executed.

If you prefer to fix this here instead of (or in addition to) the handler, restructure the guard to distinguish "query still in-flight" from "query resolved with no usable country":

🐛 Proposed consumer-side fix
      const data = query.data;
-     if (!data?.countryCode) {
-       return;
-     }
-
-     if (isSupportedCountry(data?.countryCode)) {
+     if (!query.isSuccess && !query.isError) {
+       return;
+     }
+
+     if (data?.countryCode && isSupportedCountry(data.countryCode)) {
        setDefaultCountry(data.countryCode.toLowerCase() as CountryCode);
      } else {
        const tzCountry = getCountryFromTimezone();

Also add query.isSuccess, query.isError to the dependency array:

-   [query.data, defaultPhoneCountryFromStore]
+   [query.data, query.isSuccess, query.isError, defaultPhoneCountryFromStore]
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/web/components/phone-input/PhoneInput.tsx` around lines 257 - 269, The
effect returns early when data?.countryCode is falsy, making
getCountryFromTimezone() unreachable; change the guard to only return early when
the query is still loading or unsettled (use the query's status flags) so you
can handle the case where data resolved but countryCode is empty — if
data?.countryCode exists and isSupportedCountry(data.countryCode) call
setDefaultCountry(data.countryCode.toLowerCase()), else if the query has
succeeded check getCountryFromTimezone() and
isSupportedCountry(tzCountry.toUpperCase()) to setDefaultCountry(tzCountry),
otherwise fall back to "us"; also include query.isSuccess and query.isError in
the effect dependency array and reference functions/vars like
getCountryFromTimezone, isSupportedCountry, setDefaultCountry, and data so the
logic runs correctly.
🧹 Nitpick comments (1)
apps/web/components/phone-input/PhoneInput.tsx (1)

161-229: ⚡ Quick win

Prefer Record<string, CountryCode> over Record<string, string> to eliminate the as CountryCode cast.

Typing the map more precisely removes the unsafe cast at line 266 and lets the compiler catch any invalid country code values added to the map in the future.

♻️ Proposed refactor
-const TIMEZONE_COUNTRY_MAP: Record<string, string> = {
+const TIMEZONE_COUNTRY_MAP: Record<string, CountryCode> = {
-  setDefaultCountry(tzCountry as CountryCode);
+  setDefaultCountry(tzCountry);
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/web/components/phone-input/PhoneInput.tsx` around lines 161 - 229,
TIMEZONE_COUNTRY_MAP is typed as Record<string, string>; change its type to
Record<string, CountryCode> and remove the unsafe "as CountryCode" cast where
the map is read; ensure you import or reference the CountryCode type used
elsewhere (e.g., from libphonenumber-js or your project types) so the map values
are validated by the compiler and any invalid country codes are flagged.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@packages/trpc/server/routers/publicViewer/countryCode.handler.ts`:
- Around line 10-11: The handler currently falls back to an empty string which
is falsy and prevents the timezone-detection branch in PhoneInput.tsx from
running; change the fallback in the countryCode extraction to the same sentinel
used elsewhere (e.g. "Unknown") so the value is truthy and allows
isSupportedCountry/getCountryFromTimezone to behave as intended—update the line
that defines countryCode in countryCode.handler (the const countryCode: string |
string[] = ...) to use "Unknown" instead of "" as the final fallback.

---

Outside diff comments:
In `@apps/web/components/phone-input/PhoneInput.tsx`:
- Around line 257-269: The effect returns early when data?.countryCode is falsy,
making getCountryFromTimezone() unreachable; change the guard to only return
early when the query is still loading or unsettled (use the query's status
flags) so you can handle the case where data resolved but countryCode is empty —
if data?.countryCode exists and isSupportedCountry(data.countryCode) call
setDefaultCountry(data.countryCode.toLowerCase()), else if the query has
succeeded check getCountryFromTimezone() and
isSupportedCountry(tzCountry.toUpperCase()) to setDefaultCountry(tzCountry),
otherwise fall back to "us"; also include query.isSuccess and query.isError in
the effect dependency array and reference functions/vars like
getCountryFromTimezone, isSupportedCountry, setDefaultCountry, and data so the
logic runs correctly.

---

Nitpick comments:
In `@apps/web/components/phone-input/PhoneInput.tsx`:
- Around line 161-229: TIMEZONE_COUNTRY_MAP is typed as Record<string, string>;
change its type to Record<string, CountryCode> and remove the unsafe "as
CountryCode" cast where the map is read; ensure you import or reference the
CountryCode type used elsewhere (e.g., from libphonenumber-js or your project
types) so the map values are validated by the compiler and any invalid country
codes are flagged.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 60f18eb2-98b6-4758-bd93-52b0dd33d916

📥 Commits

Reviewing files that changed from the base of the PR and between e64de00 and 4f25ea0.

📒 Files selected for processing (3)
  • apps/web/app/api/geolocation/route.ts
  • apps/web/components/phone-input/PhoneInput.tsx
  • packages/trpc/server/routers/publicViewer/countryCode.handler.ts

Comment on lines +10 to +11
const countryCode: string | string[] =
req?.headers?.["cf-ipcountry"] || req?.headers?.["x-vercel-ip-country"] || "";
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.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Empty-string fallback makes the timezone detection unreachable in PhoneInput.tsx.

The countryCodeHandler returns "" when neither header is present. In PhoneInput.tsx (context snippet 1, line 257), the consumer guard is:

if (!data?.countryCode) {
  return;          // empty string is falsy → returns here
}
// …
} else {
  const tzCountry = getCountryFromTimezone();   // ← never reached

!"" is true, so the effect returns early and the timezone-detection branch (getCountryFromTimezone()) is never reached for self-hosted deployments without either header. That is precisely the use-case this PR is intended to fix.

geolocation/route.ts still returns "Unknown" as its no-header fallback; using the same sentinel here restores the intended flow (truthy → isSupportedCountry("Unknown") is falseelse branch executes):

🐛 Proposed fix
  const countryCode: string | string[] =
-   req?.headers?.["cf-ipcountry"] || req?.headers?.["x-vercel-ip-country"] || "";
+   req?.headers?.["cf-ipcountry"] || req?.headers?.["x-vercel-ip-country"] || "Unknown";

Note: Cloudflare uses "XX" for clients without country-code data and "T1" for Tor-network clients. Both are already handled correctly (truthy, isSupportedCountry returns false → triggers the timezone fallback), so no extra handling is needed for those values.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/trpc/server/routers/publicViewer/countryCode.handler.ts` around
lines 10 - 11, The handler currently falls back to an empty string which is
falsy and prevents the timezone-detection branch in PhoneInput.tsx from running;
change the fallback in the countryCode extraction to the same sentinel used
elsewhere (e.g. "Unknown") so the value is truthy and allows
isSupportedCountry/getCountryFromTimezone to behave as intended—update the line
that defines countryCode in countryCode.handler (the const countryCode: string |
string[] = ...) to use "Unknown" instead of "" as the final fallback.

Address coderabbit review on PR calcom#28175:

1. The early return on `!data?.countryCode` made the timezone fallback
   unreachable when the server returned "" (no CF/Vercel headers — the
   exact case this PR was meant to handle). Restructured the guard to
   distinguish loading from resolved-with-no-country.

2. Tightened TIMEZONE_COUNTRY_MAP type from Record<string, string> to
   Record<string, CountryCode>, removing the unsafe `as CountryCode` cast.
@pull-request-size pull-request-size Bot added size/L and removed size/M labels May 3, 2026
@yuvrajangadsingh
Copy link
Copy Markdown
Contributor Author

thanks @coderabbitai, both valid. fixed in f11d9be:

  1. the major one: you're right, the early return on !data?.countryCode made the timezone fallback unreachable for the exact case the PR was meant to handle (no CF/Vercel headers → server returns ""). went with the consumer-side fix you suggested using query.isSuccess/query.isError to distinguish loading from resolved-with-no-country, and added both flags to the dep array. left the handler returning "" since that's a real "unknown" signal and the consumer is the right place to decide what to do with it.

  2. the nitpick: applied. TIMEZONE_COUNTRY_MAP is now Record<string, CountryCode> and getCountryFromTimezone() returns CountryCode | undefined. removed the unsafe cast at the call site.

@coderabbitai
Copy link
Copy Markdown
Contributor

coderabbitai Bot commented May 3, 2026

Tip

For best results, initiate chat on the files or code changes.

@yuvrajangadsingh: Both fixes look great — thanks for the thorough follow-through!

  1. Consumer-side guard: Using query.isSuccess / query.isError to distinguish "still loading" from "resolved with no country" is exactly the right separation of concerns. The handler returning "" as a well-defined "unknown" signal keeps the server logic clean, while the consumer now correctly reaches the timezone fallback path when no geo header is present. Adding both flags to the dep array ensures the effect re-runs at the right time too.

  2. Type safety: Record<string, CountryCode> on the map + CountryCode | undefined as the return type of getCountryFromTimezone() is the proper way to express this — no need for the unsafe cast downstream.

The logic flow now correctly handles all three cases (CF header → Vercel header → timezone → "us") without any unreachable branches.


If you found this review helpful, would you consider giving us a shout-out on X?

Thank you for using CodeRabbit!

Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

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

🧹 Nitpick comments (1)
apps/web/components/phone-input/PhoneInput.tsx (1)

161-229: ⚡ Quick win

TIMEZONE_COUNTRY_MAP has significant coverage gaps that produce incorrect "us" defaults

For users whose IANA timezone isn't in the map — and who are behind neither a Cloudflare nor Vercel edge — the code falls through to setDefaultCountry("us") (line 275). This is correct for missing US timezone variants like America/Phoenix, but it's wrong for the many non-US timezones absent from the map. For a global scheduling product, the most impactful gaps are:

Timezone Country
Europe/Kyiv / Europe/Kiev "ua" (Ukraine)
Europe/Budapest "hu"
Europe/Sofia "bg"
Europe/Vilnius "lt"
Europe/Riga "lv"
Europe/Tallinn "ee"
Europe/Zagreb "hr"
Europe/Belgrade "rs"
Europe/Bratislava "sk"
Europe/Minsk "by"
America/Caracas "ve"
America/Montevideo "uy"
America/La_Paz "bo"
Asia/Kabul "af"
Asia/Almaty "kz"
Asia/Tashkent "uz"
Asia/Yangon "mm"
Asia/Muscat "om"
Asia/Kuwait "kw"
Asia/Baku "az"
Asia/Tbilisi "ge"
Asia/Yerevan "am"
Africa/Tunis "tn"
Africa/Algiers "dz"
Africa/Accra "gh"
Africa/Addis_Ababa "et"
Africa/Dar_es_Salaam "tz"
Africa/Harare "zw"
Africa/Kampala "ug"
Pacific/Fiji "fj"
✏️ Proposed additions to `TIMEZONE_COUNTRY_MAP`
   "Asia/Tehran": "ir",
+  // Europe — Eastern / Northern / Southern
+  "Europe/Kyiv": "ua",
+  "Europe/Kiev": "ua",
+  "Europe/Minsk": "by",
+  "Europe/Budapest": "hu",
+  "Europe/Sofia": "bg",
+  "Europe/Bucharest": "ro", // already present
+  "Europe/Vilnius": "lt",
+  "Europe/Riga": "lv",
+  "Europe/Tallinn": "ee",
+  "Europe/Zagreb": "hr",
+  "Europe/Belgrade": "rs",
+  "Europe/Bratislava": "sk",
+  "Europe/Ljubljana": "si",
+  "Europe/Sarajevo": "ba",
+  "Europe/Skopje": "mk",
+  "Europe/Podgorica": "me",
+  "Europe/Tirane": "al",
+  "Europe/Nicosia": "cy",
+  "Europe/Valletta": "mt",
+  "Europe/Riga": "lv",
+  // Asia — Central / South-East / Middle East
+  "Asia/Kabul": "af",
+  "Asia/Almaty": "kz",
+  "Asia/Tashkent": "uz",
+  "Asia/Bishkek": "kg",
+  "Asia/Dushanbe": "tj",
+  "Asia/Ashgabat": "tm",
+  "Asia/Baku": "az",
+  "Asia/Tbilisi": "ge",
+  "Asia/Yerevan": "am",
+  "Asia/Yangon": "mm",
+  "Asia/Rangoon": "mm",
+  "Asia/Muscat": "om",
+  "Asia/Kuwait": "kw",
+  "Asia/Qatar": "qa",
+  "Asia/Bahrain": "bh",
+  // Americas
+  "America/Caracas": "ve",
+  "America/Montevideo": "uy",
+  "America/La_Paz": "bo",
+  "America/Guayaquil": "ec",
+  "America/Asuncion": "py",
+  "America/Panama": "pa",
+  "America/Tegucigalpa": "hn",
+  "America/Managua": "ni",
+  "America/Costa_Rica": "cr",
+  "America/Guatemala": "gt",
+  "America/El_Salvador": "sv",
+  "America/Halifax": "ca",
+  "America/Winnipeg": "ca",
+  "America/Edmonton": "ca",
+  "America/St_Johns": "ca",
+  // Africa
+  "Africa/Tunis": "tn",
+  "Africa/Algiers": "dz",
+  "Africa/Accra": "gh",
+  "Africa/Addis_Ababa": "et",
+  "Africa/Dar_es_Salaam": "tz",
+  "Africa/Harare": "zw",
+  "Africa/Lusaka": "zm",
+  "Africa/Kampala": "ug",
+  "Africa/Kigali": "rw",
+  "Africa/Abidjan": "ci",
+  "Africa/Dakar": "sn",
+  "Africa/Maputo": "mz",
+  "Africa/Khartoum": "sd",
+  "Africa/Tripoli": "ly",
+  // Pacific / Oceania
+  "Pacific/Fiji": "fj",
+  "Pacific/Guam": "gu",
+  "Pacific/Port_Moresby": "pg",
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/web/components/phone-input/PhoneInput.tsx` around lines 161 - 229,
TIMEZONE_COUNTRY_MAP currently omits many IANA zones so code falls back to
setDefaultCountry("us") incorrectly; add the missing mappings (e.g.
"Europe/Kyiv"|"Europe/Kiev" -> "ua", "Europe/Budapest" -> "hu", "Europe/Sofia"
-> "bg", "Europe/Vilnius" -> "lt", "Europe/Riga" -> "lv", "Europe/Tallinn" ->
"ee", "Europe/Zagreb" -> "hr", "Europe/Belgrade" -> "rs", "Europe/Bratislava" ->
"sk", "Europe/Minsk" -> "by", "America/Caracas" -> "ve", "America/Montevideo" ->
"uy", "America/La_Paz" -> "bo", "Asia/Kabul" -> "af", "Asia/Almaty" -> "kz",
"Asia/Tashkent" -> "uz", "Asia/Yangon" -> "mm", "Asia/Muscat" -> "om",
"Asia/Kuwait" -> "kw", "Asia/Baku" -> "az", "Asia/Tbilisi" -> "ge",
"Asia/Yerevan" -> "am", "Africa/Tunis" -> "tn", "Africa/Algiers" -> "dz",
"Africa/Accra" -> "gh", "Africa/Addis_Ababa" -> "et", "Africa/Dar_es_Salaam" ->
"tz", "Africa/Harare" -> "zw", "Africa/Kampala" -> "ug", "Pacific/Fiji" -> "fj")
into TIMEZONE_COUNTRY_MAP and then change the fallback around the
setDefaultCountry call to only default to "us" for timezones that clearly map to
US regions (e.g. America/* variants) otherwise use the mapped value if present
or skip setting a hardcoded country so callers can fall back to other signals
(Cloudflare/Vercel or geolocation); reference TIMEZONE_COUNTRY_MAP and the
setDefaultCountry(...) invocation when making the change.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Nitpick comments:
In `@apps/web/components/phone-input/PhoneInput.tsx`:
- Around line 161-229: TIMEZONE_COUNTRY_MAP currently omits many IANA zones so
code falls back to setDefaultCountry("us") incorrectly; add the missing mappings
(e.g. "Europe/Kyiv"|"Europe/Kiev" -> "ua", "Europe/Budapest" -> "hu",
"Europe/Sofia" -> "bg", "Europe/Vilnius" -> "lt", "Europe/Riga" -> "lv",
"Europe/Tallinn" -> "ee", "Europe/Zagreb" -> "hr", "Europe/Belgrade" -> "rs",
"Europe/Bratislava" -> "sk", "Europe/Minsk" -> "by", "America/Caracas" -> "ve",
"America/Montevideo" -> "uy", "America/La_Paz" -> "bo", "Asia/Kabul" -> "af",
"Asia/Almaty" -> "kz", "Asia/Tashkent" -> "uz", "Asia/Yangon" -> "mm",
"Asia/Muscat" -> "om", "Asia/Kuwait" -> "kw", "Asia/Baku" -> "az",
"Asia/Tbilisi" -> "ge", "Asia/Yerevan" -> "am", "Africa/Tunis" -> "tn",
"Africa/Algiers" -> "dz", "Africa/Accra" -> "gh", "Africa/Addis_Ababa" -> "et",
"Africa/Dar_es_Salaam" -> "tz", "Africa/Harare" -> "zw", "Africa/Kampala" ->
"ug", "Pacific/Fiji" -> "fj") into TIMEZONE_COUNTRY_MAP and then change the
fallback around the setDefaultCountry call to only default to "us" for timezones
that clearly map to US regions (e.g. America/* variants) otherwise use the
mapped value if present or skip setting a hardcoded country so callers can fall
back to other signals (Cloudflare/Vercel or geolocation); reference
TIMEZONE_COUNTRY_MAP and the setDefaultCountry(...) invocation when making the
change.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: b462f000-e9e3-4147-8c9b-b1133c76711e

📥 Commits

Reviewing files that changed from the base of the PR and between 4f25ea0 and f11d9be.

📒 Files selected for processing (1)
  • apps/web/components/phone-input/PhoneInput.tsx

Address coderabbit follow-up nitpick on PR calcom#28175:

The map covers high-traffic timezones but isn't exhaustive. Hardcoding 'us'
for any unmapped zone produces wrong defaults for users in countries the
map doesn't cover (Ukraine, parts of Africa, Central Asia, etc.).

Now only force 'us' when the resolved IANA zone starts with 'America/'.
For other unmapped zones, keep the initial default state rather than
overriding with a wrong guess.

Refactored TZ resolution into getResolvedTimezone() to avoid duplicating
the Intl call and try/catch.
@yuvrajangadsingh
Copy link
Copy Markdown
Contributor Author

thanks for the follow-up @coderabbitai. fixed the fallback logic in f6df6cd — now only forces 'us' when the resolved IANA zone starts with 'America/'. for other unmapped zones the initial default (or whatever came from the store) sticks rather than overriding with a wrong guess. extracted getResolvedTimezone() so we don't duplicate the Intl call.

skipping the 50-entry map expansion in this pr to keep the diff focused. the current ~70 entries cover high-traffic zones, and with the fallback fix, unmapped non-american users now get the initial default instead of being forced to 'us'. happy to follow up with a coverage pr backed by traffic data later.

@coderabbitai
Copy link
Copy Markdown
Contributor

coderabbitai Bot commented May 3, 2026

Tip

For best results, initiate chat on the files or code changes.

@yuvrajangadsingh: The America/ prefix guard is a meaningful improvement over the previous blanket "us" fallback — for the vast majority of real users, the ~70 mapped entries will match before the prefix check is ever reached, so the residual cases are genuinely edge-y.

One thing worth keeping in mind for the follow-up PR: the America/ prefix spans the entire Western Hemisphere, not just the US — America/Sao_Paulo, America/Toronto, America/Mexico_City, America/Buenos_Aires, etc. will all match the prefix. If any of those high-traffic non-US zones are missing from the map today, users there would still get a "us" pre-selection. A quick audit of the ~70 entries against the top America/ locales by traffic volume when you do the expansion PR would be worth including as a sanity check.

Extracting getResolvedTimezone() to avoid duplicating the Intl call is a clean touch — no concerns there.

Overall the incremental approach (fix the critical fallback logic now, expand coverage with data later) is exactly the right call for a focused, reviewable diff. The follow-up coverage PR backed by traffic data sounds solid.


✏️ Learnings added
Learnt from: yuvrajangadsingh
URL: https://github.com/calcom/cal.diy/pull/28175

Timestamp: 2026-05-03T11:48:13.174Z
Learning: In `apps/web/components/phone-input/PhoneInput.tsx`, the timezone-based country fallback in `useDefaultCountry` uses `getResolvedTimezone()` (extracted helper to avoid duplicating the `Intl.DateTimeFormat().resolvedOptions().timeZone` call). The fallback only forces `"us"` when the resolved IANA timezone starts with `"America/"` and is not found in `TIMEZONE_COUNTRY_MAP`; all other unmapped zones preserve the initial default rather than overriding with `"us"`. A follow-up PR expanding the ~70-entry map with traffic-data-backed coverage is planned.

Note: Learnings are effective only in the context of similar code segments. To apply general coding guidelines, please configure review instructions. You can manage existing learnings in the app.

Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

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

🧹 Nitpick comments (1)
apps/web/components/phone-input/PhoneInput.tsx (1)

273-286: 💤 Low value

Consider computing the timezone once to avoid duplicate Intl calls.

getResolvedTimezone() is called inside getCountryFromTimezone() (line 240) and again at line 283. While the performance impact is negligible, you could refactor to compute the timezone once at the start of the fallback path.

♻️ Suggested refactor
-      const tzCountry = getCountryFromTimezone();
-      if (tzCountry && isSupportedCountry(tzCountry.toUpperCase())) {
-        setDefaultCountry(tzCountry);
-        return;
-      }
-
-      // The map covers high-traffic timezones, not every IANA zone.
-      // Only force "us" when the resolved zone is clearly North American.
-      // Otherwise keep the initial default ("us" or whatever came from the
-      // store) rather than overriding with a wrong guess.
-      const tz = getResolvedTimezone();
-      if (tz?.startsWith("America/")) {
+      const tz = getResolvedTimezone();
+      const tzCountry = tz ? TIMEZONE_COUNTRY_MAP[tz] : undefined;
+      if (tzCountry && isSupportedCountry(tzCountry.toUpperCase())) {
+        setDefaultCountry(tzCountry);
+        return;
+      }
+
+      // The map covers high-traffic timezones, not every IANA zone.
+      // Only force "us" when the resolved zone is clearly North American.
+      // Otherwise keep the initial default ("us" or whatever came from the
+      // store) rather than overriding with a wrong guess.
+      if (tz?.startsWith("America/")) {
         setDefaultCountry("us");
       }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/web/components/phone-input/PhoneInput.tsx` around lines 273 - 286,
getResolvedTimezone() is invoked twice via getCountryFromTimezone() and again
directly, so compute the resolved timezone once and reuse it: call
getResolvedTimezone() once at the start of the fallback path, store it (e.g.,
resolvedTz), pass that value into getCountryFromTimezone(resolvedTz) or modify
getCountryFromTimezone to accept an optional timezone parameter, then use the
same resolvedTz for the tz?.startsWith("America/") check before calling
setDefaultCountry("us"); this avoids duplicate Intl calls while preserving the
existing logic that sets tzCountry and the "America/" fallback.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Nitpick comments:
In `@apps/web/components/phone-input/PhoneInput.tsx`:
- Around line 273-286: getResolvedTimezone() is invoked twice via
getCountryFromTimezone() and again directly, so compute the resolved timezone
once and reuse it: call getResolvedTimezone() once at the start of the fallback
path, store it (e.g., resolvedTz), pass that value into
getCountryFromTimezone(resolvedTz) or modify getCountryFromTimezone to accept an
optional timezone parameter, then use the same resolvedTz for the
tz?.startsWith("America/") check before calling setDefaultCountry("us"); this
avoids duplicate Intl calls while preserving the existing logic that sets
tzCountry and the "America/" fallback.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: f3954a46-1c28-407d-ac26-b969fae3990b

📥 Commits

Reviewing files that changed from the base of the PR and between f11d9be and f6df6cd.

📒 Files selected for processing (1)
  • apps/web/components/phone-input/PhoneInput.tsx

Address coderabbit nitpick on PR calcom#28175 — was calling getResolvedTimezone()
twice (once via getCountryFromTimezone, once for the America/* check).

Resolve once in the fallback path, look up the map inline. Drops the
now-unused getCountryFromTimezone() helper.
@yuvrajangadsingh
Copy link
Copy Markdown
Contributor Author

applied in 4d1b2ac — resolve the tz once at the top of the fallback path and inline the map lookup. dropped getCountryFromTimezone() since nothing uses it anymore.

@Qodo-Free-For-OSS
Copy link
Copy Markdown

Hi, useDefaultCountry sets the default country to "us" for any timezone starting with "America/" when the map/header lookup fails, but America/* includes many non‑US regions (Latin America/Caribbean). This will mis-detect users in unmapped America/* zones as US and preselect the wrong phone country code.

Severity: action required | Category: correctness

How to fix: Whitelist US timezones only

Agent prompt to fix - you can give this to your LLM of choice:

Issue description

useDefaultCountry currently forces "us" for any timezone with prefix "America/", which includes many non-US countries. This causes incorrect phone default country selection.

Issue Context

The timezone map is intentionally partial; for unmapped zones we should avoid incorrect guesses.

Fix Focus Areas

  • apps/web/components/phone-input/PhoneInput.tsx[268-281]
    • Replace tz?.startsWith("America/") with a whitelist of known US timezones (or remove the forced-US fallback entirely).
    • If keeping a heuristic, prefer something that cannot misclassify Latin America/Caribbean users.

We noticed a couple of other issues in this PR as well - happy to share if helpful.


Qodo code review - free for open-source.

@Qodo-Free-For-OSS
Copy link
Copy Markdown

Hi, /api/geolocation returns different JSON based on cf-ipcountry / x-vercel-ip-country headers but is marked public and s-maxage cacheable without a Vary header. In shared caching/CDN setups this can cause one user’s country to be served to other users.

Severity: remediation recommended | Category: security

How to fix: Add Vary header

Agent prompt to fix - you can give this to your LLM of choice:

Issue description

/api/geolocation is cached publicly but varies by geolocation headers; missing Vary can lead to cache collisions behind shared caches/CDNs.

Issue Context

The route sets Cache-Control: public, s-maxage=3600... and computes country from cf-ipcountry/x-vercel-ip-country.

Fix Focus Areas

  • apps/web/app/api/geolocation/route.ts[7-10]
    • Set Vary to include cf-ipcountry and x-vercel-ip-country (and ensure it appends if other Vary values exist).

We noticed a couple of other issues in this PR as well - happy to share if helpful.


Found by Qodo code review

…rowser

Address Qodo bot review on PR calcom#28175:

1. PhoneInput: removed the `tz.startsWith("America/")` -> "us" fallback.
   The IANA `America/*` namespace covers Latin America and the Caribbean,
   so this was mis-detecting unmapped LATAM users as US. The block was
   also redundant — the React initial state is already "us", so deleting
   it changes nothing for unmapped zones in the no-store case.

2. /api/geolocation: changed Cache-Control from
   `public, max-age=3600, s-maxage=3600, stale-while-revalidate=86400`
   to `private, max-age=3600`. The previous header allowed shared CDN
   caching of a response that varies by request headers (no Vary key),
   so one user's country could be served to other users. The only
   consumer (GTM.tsx) already does 24h localStorage + react-query
   caching, so per-user browser caching is sufficient.
@yuvrajangadsingh
Copy link
Copy Markdown
Contributor Author

thanks @Qodo-Free-For-OSS, both valid. fixed in 8cb2901:

  1. america/ fallback*: dropped the tz.startsWith("America/") -> setDefaultCountry("us") block entirely. you're right that america/* spans latin america + caribbean too. the block was also redundant since the react initial state is already "us", so the unmapped-zone behavior is unchanged for actual us users while no longer mis-detecting unmapped latam users.

  2. geolocation cache header: changed from public, max-age=3600, s-maxage=3600, stale-while-revalidate=86400 to private, max-age=3600. the only consumer (GTM.tsx) already does its own 24h localStorage + react-query cache, so per-user browser caching is enough and the cdn cache poisoning risk is gone.

We noticed a couple of other issues in this PR as well - happy to share if helpful.

yes please, go ahead and post them.

Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 2

🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@apps/web/app/api/geolocation/route.ts`:
- Line 7: The country value derived from headersList.get("cf-ipcountry") /
"x-vercel-ip-country" may be lower/mixed-case and should be normalized before
returning; update the logic in the route handler (the line that sets the country
variable in apps/web/app/api/geolocation/route.ts) to trim and convert any
header value to uppercase (e.g., use .trim().toUpperCase()) and fall back to
"Unknown" only if there's no header or the trimmed value is empty, so downstream
checks like apps/web/components/GTM.tsx comparing to "US" work reliably.

In `@apps/web/components/phone-input/PhoneInput.tsx`:
- Around line 262-265: Normalize the header-derived country code before checking
support: read query.data.countryCode into a local variable, coerce to a string
and call .toUpperCase() for the isSupportedCountry check (e.g., let cc =
String(query.data.countryCode || '').toUpperCase()), then if
isSupportedCountry(cc) call setDefaultCountry(cc.toLowerCase() as CountryCode);
update references to query.data.countryCode, isSupportedCountry, and
setDefaultCountry accordingly and guard empty/null values.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 2e1154c9-50e3-4cc6-8dfb-71d337477bab

📥 Commits

Reviewing files that changed from the base of the PR and between 4d1b2ac and 8cb2901.

📒 Files selected for processing (2)
  • apps/web/app/api/geolocation/route.ts
  • apps/web/components/phone-input/PhoneInput.tsx

Comment thread apps/web/app/api/geolocation/route.ts Outdated
Comment thread apps/web/components/phone-input/PhoneInput.tsx Outdated
Address coderabbit round 4 on PR calcom#28175. Headers should already be
uppercase ISO 3166-1 alpha-2 codes, but normalize defensively at every
boundary so consumers can rely on stable casing for strict equality
checks like `data.country === "US"` (apps/web/components/GTM.tsx).

Three sites:
- apps/web/app/api/geolocation/route.ts (REST)
- packages/trpc/server/routers/publicViewer/countryCode.handler.ts (tRPC)
- apps/web/components/phone-input/PhoneInput.tsx (consumer)
@yuvrajangadsingh
Copy link
Copy Markdown
Contributor Author

thanks @coderabbitai. fixed at all 3 boundaries in f2218ab:

  1. /api/geolocation routeraw.trim().toUpperCase() || "Unknown" so GTM.tsx's data.country === "US" strict check stays reliable
  2. trpc countryCode handler — same normalization applied here too since this is the actual endpoint PhoneInput consumes
  3. PhoneInput consumer — local cc var with .trim().toUpperCase() before isSupportedCountry(), then lowercase before set

defense in depth at every boundary so casing stays consistent end to end.

@coderabbitai
Copy link
Copy Markdown
Contributor

coderabbitai Bot commented May 4, 2026

Tip

For best results, initiate chat on the files or code changes.

@yuvrajangadsingh: The defense-in-depth normalization is exactly the right pattern here — normalizing at ingress (server handlers) so the wire format is always canonical uppercase, then lowercasing at the point of use in the consumer, means no layer depends on any other layer being well-behaved about casing.

A couple of quick observations on the three boundaries:

  1. /api/geolocationraw.trim().toUpperCase() || "Unknown" keeps the data.country === "US" strict-equality check in GTM.tsx reliable. Worth noting this changes the sentinel from "" to "Unknown" — if any consumer was checking for === "" to detect the missing-country case, that check would now need updating, but from the context it looks like GTM.tsx only checks === "US" so this should be fine.

  2. trpc handler — consistent with the REST route. PhoneInput now gets a canonical uppercase string (or "" / "Unknown") regardless of which header was present.

  3. PhoneInput consumer — the local cc normalize-then-lowercase pattern (cc.trim().toUpperCase()isSupportedCountry()cc.toLowerCase() → set) is the correct place to convert back to the lowercase format that react-phone-number-input expects for country codes.

The whole chain is consistent and clearly reasoned.


✏️ Learnings added
Learnt from: yuvrajangadsingh
URL: https://github.com/calcom/cal.diy/pull/28175

Timestamp: 2026-05-04T09:24:08.415Z
Learning: In PR `#28175` (`fix/country-code-detection`), casing normalization was applied at all 3 boundaries in commit f2218abc5d: (1) `/api/geolocation` route uses `raw.trim().toUpperCase() || "Unknown"` to keep GTM.tsx's `data.country === "US"` strict check reliable; (2) the trpc `countryCode` handler applies the same normalization; (3) the PhoneInput consumer uses a local `cc` variable with `.trim().toUpperCase()` before `isSupportedCountry()`, then `.toLowerCase()` before calling `setDefaultCountry()`, since `react-phone-number-input` expects lowercase country codes. This "defense in depth" ensures casing is canonical at ingress and converted to the expected format only at point of use.

Note: Learnings are effective only in the context of similar code segments. To apply general coding guidelines, please configure review instructions. You can manage existing learnings in the app.

@Qodo-Free-For-OSS
Copy link
Copy Markdown

Hi, /api/geolocation uses ?? when choosing between cf-ipcountry and x-vercel-ip-country, so an empty cf-ipcountry value prevents falling back to the Vercel header and results in "Unknown" instead of a real country.

Severity: remediation recommended | Category: correctness

How to fix: Use || or trim-check

Agent prompt to fix - you can give this to your LLM of choice:

Issue description

/api/geolocation uses nullish coalescing (??) to choose between cf-ipcountry and x-vercel-ip-country. If cf-ipcountry is present but empty (or whitespace), the code will not fall back to x-vercel-ip-country, potentially returning "Unknown" incorrectly.

Issue Context

Other parts of the codebase already use || for these headers (e.g. app/layout.tsx), which falls back correctly when the first header is empty. This PR should keep the behavior consistent.

Fix Focus Areas

  • apps/web/app/api/geolocation/route.ts[7-10]

Suggested change

Use a fallback that treats empty/whitespace as missing, e.g.:

  • const raw = headersList.get("cf-ipcountry") || headersList.get("x-vercel-ip-country") || "";

or explicitly trim-check before deciding which header to use.


Found by Qodo. Free code review for open-source maintainers.

Per qodo bot review: `??` only falls back on null/undefined, so an
empty cf-ipcountry header would prevent fallback to x-vercel-ip-country
and return "Unknown" even when Vercel had a valid country code.

In practice cloudflare returns `XX` for unknown country and `T1` for
Tor (not empty string), so the runtime impact is speculative. But the
defensive fix is correct and matches the trpc handler at
packages/trpc/server/routers/publicViewer/countryCode.handler.ts which
already uses ||.
@yuvrajangadsingh
Copy link
Copy Markdown
Contributor Author

thanks @Qodo-Free-For-OSS, real bug. fixed in 74a1fa3c76 — reverted ?? back to || so an empty cf-ipcountry header doesn't trap us in "Unknown" when vercel might have the country. matches the trpc handler at packages/trpc/server/routers/publicViewer/countryCode.handler.ts which already uses ||.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

🐛 bug Something isn't working community Created by Linear-GitHub Sync foundation High priority Created by Linear-GitHub Sync size/L

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Inaccurate country code dedection

2 participants