Replaces every em-dash and en-dash with regular ASCII hyphens across comments, JSX strings, and dev-facing logs. Mostly cosmetic but stops the inconsistent mix that crept in over the last few months (some files used em-dashes in comments, others didn't, some used both). Bundles two small dashboard-layout tweaks that touch a couple of already-modified files: - (dashboard)/layout.tsx main padding goes from p-6 to pt-3 px-6 pb-6 so page content sits closer to the topbar. - Sidebar now receives the ports list it needs for the footer port switcher. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
412 lines
10 KiB
TypeScript
412 lines
10 KiB
TypeScript
/**
|
|
* Country → IANA timezone mapping.
|
|
*
|
|
* For single-zone countries, value is one IANA string.
|
|
* For multi-zone countries, value is the primary IANA string followed
|
|
* by all valid alternates. Source: IANA tzdb (current as of 2026-04).
|
|
*
|
|
* The full ~600-entry IANA list comes from `Intl.supportedValuesOf('timeZone')`
|
|
* at runtime; this map only handles the country→default lookup.
|
|
*/
|
|
|
|
import type { CountryCode } from './countries';
|
|
|
|
type TimezoneList = readonly [primary: string, ...alternates: string[]];
|
|
|
|
// Multi-zone countries - list every IANA zone.
|
|
const MULTI_ZONE: Partial<Record<CountryCode, TimezoneList>> = {
|
|
AU: [
|
|
'Australia/Sydney',
|
|
'Australia/Melbourne',
|
|
'Australia/Brisbane',
|
|
'Australia/Adelaide',
|
|
'Australia/Perth',
|
|
'Australia/Hobart',
|
|
'Australia/Darwin',
|
|
],
|
|
BR: [
|
|
'America/Sao_Paulo',
|
|
'America/Manaus',
|
|
'America/Rio_Branco',
|
|
'America/Belem',
|
|
'America/Recife',
|
|
'America/Cuiaba',
|
|
'America/Fortaleza',
|
|
'America/Bahia',
|
|
'America/Noronha',
|
|
],
|
|
CA: [
|
|
'America/Toronto',
|
|
'America/Vancouver',
|
|
'America/Edmonton',
|
|
'America/Winnipeg',
|
|
'America/Halifax',
|
|
'America/St_Johns',
|
|
'America/Regina',
|
|
'America/Whitehorse',
|
|
],
|
|
CD: ['Africa/Kinshasa', 'Africa/Lubumbashi'],
|
|
ID: ['Asia/Jakarta', 'Asia/Pontianak', 'Asia/Makassar', 'Asia/Jayapura'],
|
|
KZ: [
|
|
'Asia/Almaty',
|
|
'Asia/Atyrau',
|
|
'Asia/Aqtau',
|
|
'Asia/Aqtobe',
|
|
'Asia/Oral',
|
|
'Asia/Qostanay',
|
|
'Asia/Qyzylorda',
|
|
],
|
|
MN: ['Asia/Ulaanbaatar', 'Asia/Hovd', 'Asia/Choibalsan'],
|
|
MX: [
|
|
'America/Mexico_City',
|
|
'America/Cancun',
|
|
'America/Merida',
|
|
'America/Monterrey',
|
|
'America/Mazatlan',
|
|
'America/Chihuahua',
|
|
'America/Hermosillo',
|
|
'America/Tijuana',
|
|
],
|
|
RU: [
|
|
'Europe/Moscow',
|
|
'Europe/Kaliningrad',
|
|
'Europe/Samara',
|
|
'Asia/Yekaterinburg',
|
|
'Asia/Omsk',
|
|
'Asia/Novosibirsk',
|
|
'Asia/Krasnoyarsk',
|
|
'Asia/Irkutsk',
|
|
'Asia/Yakutsk',
|
|
'Asia/Vladivostok',
|
|
'Asia/Magadan',
|
|
'Asia/Kamchatka',
|
|
],
|
|
US: [
|
|
'America/New_York',
|
|
'America/Chicago',
|
|
'America/Denver',
|
|
'America/Los_Angeles',
|
|
'America/Phoenix',
|
|
'America/Anchorage',
|
|
'America/Honolulu',
|
|
'America/Indianapolis',
|
|
'America/Detroit',
|
|
'America/Boise',
|
|
'America/Adak',
|
|
'America/Juneau',
|
|
'America/Nome',
|
|
'Pacific/Guam',
|
|
'America/Puerto_Rico',
|
|
],
|
|
};
|
|
|
|
// Single-zone primary lookup. Sourced from IANA tzdb's zone1970.tab.
|
|
const SINGLE_ZONE: Partial<Record<CountryCode, string>> = {
|
|
AD: 'Europe/Andorra',
|
|
AE: 'Asia/Dubai',
|
|
AF: 'Asia/Kabul',
|
|
AG: 'America/Antigua',
|
|
AI: 'America/Anguilla',
|
|
AL: 'Europe/Tirane',
|
|
AM: 'Asia/Yerevan',
|
|
AO: 'Africa/Luanda',
|
|
AQ: 'Antarctica/McMurdo',
|
|
AR: 'America/Argentina/Buenos_Aires',
|
|
AS: 'Pacific/Pago_Pago',
|
|
AT: 'Europe/Vienna',
|
|
AW: 'America/Aruba',
|
|
AX: 'Europe/Mariehamn',
|
|
AZ: 'Asia/Baku',
|
|
BA: 'Europe/Sarajevo',
|
|
BB: 'America/Barbados',
|
|
BD: 'Asia/Dhaka',
|
|
BE: 'Europe/Brussels',
|
|
BF: 'Africa/Ouagadougou',
|
|
BG: 'Europe/Sofia',
|
|
BH: 'Asia/Bahrain',
|
|
BI: 'Africa/Bujumbura',
|
|
BJ: 'Africa/Porto-Novo',
|
|
BL: 'America/St_Barthelemy',
|
|
BM: 'Atlantic/Bermuda',
|
|
BN: 'Asia/Brunei',
|
|
BO: 'America/La_Paz',
|
|
BQ: 'America/Kralendijk',
|
|
BS: 'America/Nassau',
|
|
BT: 'Asia/Thimphu',
|
|
BV: 'Antarctica/Syowa',
|
|
BW: 'Africa/Gaborone',
|
|
BY: 'Europe/Minsk',
|
|
BZ: 'America/Belize',
|
|
CC: 'Indian/Cocos',
|
|
CF: 'Africa/Bangui',
|
|
CG: 'Africa/Brazzaville',
|
|
CH: 'Europe/Zurich',
|
|
CI: 'Africa/Abidjan',
|
|
CK: 'Pacific/Rarotonga',
|
|
CL: 'America/Santiago',
|
|
CM: 'Africa/Douala',
|
|
CN: 'Asia/Shanghai',
|
|
CO: 'America/Bogota',
|
|
CR: 'America/Costa_Rica',
|
|
CU: 'America/Havana',
|
|
CV: 'Atlantic/Cape_Verde',
|
|
CW: 'America/Curacao',
|
|
CX: 'Indian/Christmas',
|
|
CY: 'Asia/Nicosia',
|
|
CZ: 'Europe/Prague',
|
|
DE: 'Europe/Berlin',
|
|
DJ: 'Africa/Djibouti',
|
|
DK: 'Europe/Copenhagen',
|
|
DM: 'America/Dominica',
|
|
DO: 'America/Santo_Domingo',
|
|
DZ: 'Africa/Algiers',
|
|
EC: 'America/Guayaquil',
|
|
EE: 'Europe/Tallinn',
|
|
EG: 'Africa/Cairo',
|
|
EH: 'Africa/El_Aaiun',
|
|
ER: 'Africa/Asmara',
|
|
ES: 'Europe/Madrid',
|
|
ET: 'Africa/Addis_Ababa',
|
|
FI: 'Europe/Helsinki',
|
|
FJ: 'Pacific/Fiji',
|
|
FK: 'Atlantic/Stanley',
|
|
FM: 'Pacific/Pohnpei',
|
|
FO: 'Atlantic/Faroe',
|
|
FR: 'Europe/Paris',
|
|
GA: 'Africa/Libreville',
|
|
GB: 'Europe/London',
|
|
GD: 'America/Grenada',
|
|
GE: 'Asia/Tbilisi',
|
|
GF: 'America/Cayenne',
|
|
GG: 'Europe/Guernsey',
|
|
GH: 'Africa/Accra',
|
|
GI: 'Europe/Gibraltar',
|
|
GL: 'America/Godthab',
|
|
GM: 'Africa/Banjul',
|
|
GN: 'Africa/Conakry',
|
|
GP: 'America/Guadeloupe',
|
|
GQ: 'Africa/Malabo',
|
|
GR: 'Europe/Athens',
|
|
GS: 'Atlantic/South_Georgia',
|
|
GT: 'America/Guatemala',
|
|
GU: 'Pacific/Guam',
|
|
GW: 'Africa/Bissau',
|
|
GY: 'America/Guyana',
|
|
HK: 'Asia/Hong_Kong',
|
|
HM: 'Antarctica/Mawson',
|
|
HN: 'America/Tegucigalpa',
|
|
HR: 'Europe/Zagreb',
|
|
HT: 'America/Port-au-Prince',
|
|
HU: 'Europe/Budapest',
|
|
IE: 'Europe/Dublin',
|
|
IL: 'Asia/Jerusalem',
|
|
IM: 'Europe/Isle_of_Man',
|
|
IN: 'Asia/Kolkata',
|
|
IO: 'Indian/Chagos',
|
|
IQ: 'Asia/Baghdad',
|
|
IR: 'Asia/Tehran',
|
|
IS: 'Atlantic/Reykjavik',
|
|
IT: 'Europe/Rome',
|
|
JE: 'Europe/Jersey',
|
|
JM: 'America/Jamaica',
|
|
JO: 'Asia/Amman',
|
|
JP: 'Asia/Tokyo',
|
|
KE: 'Africa/Nairobi',
|
|
KG: 'Asia/Bishkek',
|
|
KH: 'Asia/Phnom_Penh',
|
|
KI: 'Pacific/Tarawa',
|
|
KM: 'Indian/Comoro',
|
|
KN: 'America/St_Kitts',
|
|
KP: 'Asia/Pyongyang',
|
|
KR: 'Asia/Seoul',
|
|
KW: 'Asia/Kuwait',
|
|
KY: 'America/Cayman',
|
|
LA: 'Asia/Vientiane',
|
|
LB: 'Asia/Beirut',
|
|
LC: 'America/St_Lucia',
|
|
LI: 'Europe/Vaduz',
|
|
LK: 'Asia/Colombo',
|
|
LR: 'Africa/Monrovia',
|
|
LS: 'Africa/Maseru',
|
|
LT: 'Europe/Vilnius',
|
|
LU: 'Europe/Luxembourg',
|
|
LV: 'Europe/Riga',
|
|
LY: 'Africa/Tripoli',
|
|
MA: 'Africa/Casablanca',
|
|
MC: 'Europe/Monaco',
|
|
MD: 'Europe/Chisinau',
|
|
ME: 'Europe/Podgorica',
|
|
MF: 'America/Marigot',
|
|
MG: 'Indian/Antananarivo',
|
|
MH: 'Pacific/Majuro',
|
|
MK: 'Europe/Skopje',
|
|
ML: 'Africa/Bamako',
|
|
MM: 'Asia/Yangon',
|
|
MO: 'Asia/Macau',
|
|
MP: 'Pacific/Saipan',
|
|
MQ: 'America/Martinique',
|
|
MR: 'Africa/Nouakchott',
|
|
MS: 'America/Montserrat',
|
|
MT: 'Europe/Malta',
|
|
MU: 'Indian/Mauritius',
|
|
MV: 'Indian/Maldives',
|
|
MW: 'Africa/Blantyre',
|
|
MY: 'Asia/Kuala_Lumpur',
|
|
MZ: 'Africa/Maputo',
|
|
NA: 'Africa/Windhoek',
|
|
NC: 'Pacific/Noumea',
|
|
NE: 'Africa/Niamey',
|
|
NF: 'Pacific/Norfolk',
|
|
NG: 'Africa/Lagos',
|
|
NI: 'America/Managua',
|
|
NL: 'Europe/Amsterdam',
|
|
NO: 'Europe/Oslo',
|
|
NP: 'Asia/Kathmandu',
|
|
NR: 'Pacific/Nauru',
|
|
NU: 'Pacific/Niue',
|
|
NZ: 'Pacific/Auckland',
|
|
OM: 'Asia/Muscat',
|
|
PA: 'America/Panama',
|
|
PE: 'America/Lima',
|
|
PF: 'Pacific/Tahiti',
|
|
PG: 'Pacific/Port_Moresby',
|
|
PH: 'Asia/Manila',
|
|
PK: 'Asia/Karachi',
|
|
PL: 'Europe/Warsaw',
|
|
PM: 'America/Miquelon',
|
|
PN: 'Pacific/Pitcairn',
|
|
PR: 'America/Puerto_Rico',
|
|
PS: 'Asia/Gaza',
|
|
PT: 'Europe/Lisbon',
|
|
PW: 'Pacific/Palau',
|
|
PY: 'America/Asuncion',
|
|
QA: 'Asia/Qatar',
|
|
RE: 'Indian/Reunion',
|
|
RO: 'Europe/Bucharest',
|
|
RS: 'Europe/Belgrade',
|
|
RW: 'Africa/Kigali',
|
|
SA: 'Asia/Riyadh',
|
|
SB: 'Pacific/Guadalcanal',
|
|
SC: 'Indian/Mahe',
|
|
SD: 'Africa/Khartoum',
|
|
SE: 'Europe/Stockholm',
|
|
SG: 'Asia/Singapore',
|
|
SH: 'Atlantic/St_Helena',
|
|
SI: 'Europe/Ljubljana',
|
|
SJ: 'Arctic/Longyearbyen',
|
|
SK: 'Europe/Bratislava',
|
|
SL: 'Africa/Freetown',
|
|
SM: 'Europe/San_Marino',
|
|
SN: 'Africa/Dakar',
|
|
SO: 'Africa/Mogadishu',
|
|
SR: 'America/Paramaribo',
|
|
SS: 'Africa/Juba',
|
|
ST: 'Africa/Sao_Tome',
|
|
SV: 'America/El_Salvador',
|
|
SX: 'America/Lower_Princes',
|
|
SY: 'Asia/Damascus',
|
|
SZ: 'Africa/Mbabane',
|
|
TC: 'America/Grand_Turk',
|
|
TD: 'Africa/Ndjamena',
|
|
TF: 'Indian/Kerguelen',
|
|
TG: 'Africa/Lome',
|
|
TH: 'Asia/Bangkok',
|
|
TJ: 'Asia/Dushanbe',
|
|
TK: 'Pacific/Fakaofo',
|
|
TL: 'Asia/Dili',
|
|
TM: 'Asia/Ashgabat',
|
|
TN: 'Africa/Tunis',
|
|
TO: 'Pacific/Tongatapu',
|
|
TR: 'Europe/Istanbul',
|
|
TT: 'America/Port_of_Spain',
|
|
TV: 'Pacific/Funafuti',
|
|
TW: 'Asia/Taipei',
|
|
TZ: 'Africa/Dar_es_Salaam',
|
|
UA: 'Europe/Kyiv',
|
|
UG: 'Africa/Kampala',
|
|
UM: 'Pacific/Wake',
|
|
UY: 'America/Montevideo',
|
|
UZ: 'Asia/Tashkent',
|
|
VA: 'Europe/Vatican',
|
|
VC: 'America/St_Vincent',
|
|
VE: 'America/Caracas',
|
|
VG: 'America/Tortola',
|
|
VI: 'America/St_Thomas',
|
|
VN: 'Asia/Ho_Chi_Minh',
|
|
VU: 'Pacific/Efate',
|
|
WF: 'Pacific/Wallis',
|
|
WS: 'Pacific/Apia',
|
|
YE: 'Asia/Aden',
|
|
YT: 'Indian/Mayotte',
|
|
ZA: 'Africa/Johannesburg',
|
|
ZM: 'Africa/Lusaka',
|
|
ZW: 'Africa/Harare',
|
|
};
|
|
|
|
/**
|
|
* Returns the IANA zone(s) for a country. Always returns at least one
|
|
* entry; the first entry is the primary/most-populous zone.
|
|
*/
|
|
export function timezonesForCountry(country: CountryCode): readonly string[] {
|
|
const multi = MULTI_ZONE[country];
|
|
if (multi) return multi;
|
|
const single = SINGLE_ZONE[country];
|
|
return single ? [single] : [];
|
|
}
|
|
|
|
/**
|
|
* Returns the single best-default IANA timezone for a country, or null
|
|
* when the dataset has no entry (caller should fall back to a generic
|
|
* default like 'UTC').
|
|
*/
|
|
export function primaryTimezoneFor(country: CountryCode): string | null {
|
|
const list = timezonesForCountry(country);
|
|
return list[0] ?? null;
|
|
}
|
|
|
|
/** True when the country has more than one valid zone (UI shows a sub-select). */
|
|
export function isMultiZone(country: CountryCode): boolean {
|
|
const list = timezonesForCountry(country);
|
|
return list.length > 1;
|
|
}
|
|
|
|
/**
|
|
* Master IANA timezone list - uses Intl when available (modern browsers
|
|
* + Node 21+). Falls back to a small bundled list when missing.
|
|
*/
|
|
export function listAllTimezones(): readonly string[] {
|
|
if (typeof Intl !== 'undefined' && 'supportedValuesOf' in Intl) {
|
|
try {
|
|
const supported = Intl.supportedValuesOf('timeZone') as string[];
|
|
if (Array.isArray(supported) && supported.length > 0) return supported;
|
|
} catch {
|
|
// fall through
|
|
}
|
|
}
|
|
// Tiny fallback drawn from our country map - covers ~250 entries and
|
|
// never less than the timezones we'd otherwise reference.
|
|
const set = new Set<string>();
|
|
for (const tz of Object.values(SINGLE_ZONE)) set.add(tz!);
|
|
for (const list of Object.values(MULTI_ZONE)) {
|
|
for (const tz of list ?? []) set.add(tz);
|
|
}
|
|
return Array.from(set).sort();
|
|
}
|
|
|
|
/**
|
|
* Pretty-format a timezone for display: `'Europe/London (UTC+1)'`.
|
|
* The offset is computed against `now` so it follows DST.
|
|
*/
|
|
export function formatTimezoneLabel(tz: string, now: Date = new Date()): string {
|
|
try {
|
|
const parts = new Intl.DateTimeFormat('en', {
|
|
timeZone: tz,
|
|
timeZoneName: 'shortOffset',
|
|
}).formatToParts(now);
|
|
const offset = parts.find((p) => p.type === 'timeZoneName')?.value ?? '';
|
|
return offset ? `${tz} (${offset})` : tz;
|
|
} catch {
|
|
return tz;
|
|
}
|
|
}
|