diff --git a/api_server.py b/api_server.py index 9f9168e..6229fae 100644 --- a/api_server.py +++ b/api_server.py @@ -52,7 +52,8 @@ get_current_timezone_info, get_timezone_from_env, normalize_timezone_input, - list_supported_abbreviations + list_supported_abbreviations, + get_all_timezones, ) # Global variable to track server start time @@ -5419,14 +5420,23 @@ async def sync_collection(franchise_name: str): @app.get("/api/timezone/supported") async def get_supported_timezones(): - """Get list of all supported timezone abbreviations organized by region""" + """Get all IANA timezones plus supported abbreviations organized by region""" try: + timezones = get_all_timezones() abbreviations = list_supported_abbreviations() + total_abbreviations = sum( + len(abbrevs) for abbrevs in abbreviations.values() + ) return { "success": True, + "timezones": timezones, + "total_timezones": len(timezones), "regions": abbreviations, - "total_abbreviations": sum(len(abbrevs) for abbrevs in abbreviations.values()), - "note": "Use these abbreviations in the TZ environment variable" + "total_abbreviations": total_abbreviations, + "note": ( + "Use IANA names (preferred) or these abbreviations " + "in the TZ environment variable" + ), } except Exception as e: raise HTTPException(status_code=500, detail=str(e)) diff --git a/docs/api.md b/docs/api.md index 555118f..8ea5175 100644 --- a/docs/api.md +++ b/docs/api.md @@ -540,7 +540,26 @@ Returns Overseerr configuration details. GET /api/timezone/supported ``` -Returns list of supported timezones. +Returns all IANA timezones available on the server (sorted by UTC offset), plus supported abbreviations organized by region. + +**Response**: +```json +{ + "success": true, + "timezones": [ + { + "value": "America/Sao_Paulo", + "label": "America/Sao_Paulo (-03)", + "offset": "UTC-03:00" + } + ], + "total_timezones": 598, + "regions": { + "North America": ["EST", "PST"] + }, + "total_abbreviations": 100 +} +``` ### Get Current Timezone ```http diff --git a/docs/configuration.md b/docs/configuration.md index b68cc5b..a5dea2a 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -290,9 +290,11 @@ TZ=GMT 3. Copy the webhook URL to `DISCORD_WEBHOOK_URL` **Timezone Configuration:** -- Use standard timezone identifiers (e.g., `GMT+1`, `UTC-5`) +- Use IANA timezone names (e.g., `America/Sao_Paulo`, `Europe/London`), common abbreviations (e.g., `BRT`, `EST`, `CET`) or UTC/GMT offsets (e.g., `GMT+1`, `UTC-5`) +- All IANA timezones are supported and can also be selected in the dashboard (Settings → Sync Settings) and in the setup wizard +- Set `TIMEZONE_REGION` (e.g., `US`, `EU`, `BR`, `ASIA`, `AU`, `NZ`) to resolve ambiguous abbreviations like `AMT` or `IST` - Affects scheduling and log timestamps -- Default: `GMT` +- Default: `UTC` ### Performance Tuning diff --git a/list_sync/utils/timezone_utils.py b/list_sync/utils/timezone_utils.py index c233a8f..6f1aa1e 100644 --- a/list_sync/utils/timezone_utils.py +++ b/list_sync/utils/timezone_utils.py @@ -4,8 +4,8 @@ import os import logging -from typing import Optional, Dict -from datetime import datetime, timezone +from typing import List, Optional, Dict +from datetime import datetime, timedelta, timezone import zoneinfo # Comprehensive mapping of common timezone abbreviations to their full timezone names @@ -183,6 +183,9 @@ "BRT": "America/Sao_Paulo", # Brasília Time "BRST": "America/Sao_Paulo", # Brasília Summer Time "BST": "America/Sao_Paulo", # Brazil Summer Time (conflicts with British Summer Time) + "AMT": "America/Manaus", # Amazon Time (conflicts with Armenia Time) + "ACT": "America/Rio_Branco", # Acre Time (conflicts with ASEAN Common Time) + "FNT": "America/Noronha", # Fernando de Noronha Time # South America - Argentina "ART": "America/Argentina/Buenos_Aires", @@ -285,6 +288,27 @@ "Z": "UTC", # Zulu Time Zone (UTC+0) } +# South American zones (used to categorize abbreviations by region, +# since they share the "America/" prefix with North American zones) +SOUTH_AMERICAN_TIMEZONES = { + "America/Argentina/Buenos_Aires", + "America/Asuncion", + "America/Bogota", + "America/Caracas", + "America/Cayenne", + "America/Guayaquil", + "America/Guyana", + "America/La_Paz", + "America/Lima", + "America/Manaus", + "America/Montevideo", + "America/Noronha", + "America/Paramaribo", + "America/Rio_Branco", + "America/Santiago", + "America/Sao_Paulo", +} + # Regional preference mapping for conflicting abbreviations REGIONAL_PREFERENCES: Dict[str, Dict[str, str]] = { "US": { @@ -323,6 +347,13 @@ "NZ": { "NZST": "Pacific/Auckland", "NZDT": "Pacific/Auckland", + }, + "BR": { + "BRT": "America/Sao_Paulo", + "AMT": "America/Manaus", + "ACT": "America/Rio_Branco", + "FNT": "America/Noronha", + "BST": "America/Sao_Paulo", } } @@ -556,6 +587,54 @@ def get_current_timezone_info() -> Dict[str, str]: } +def get_all_timezones() -> List[Dict[str, str]]: + """ + Get all IANA timezones available on the system with display metadata. + + Each entry contains the IANA name (``value``), a human-readable label + with the current abbreviation (``label``), and the current UTC offset + (``offset``). Zones are sorted by UTC offset, then alphabetically. + + Returns: + List of dicts with ``value``, ``label`` and ``offset`` keys, e.g. + ``{"value": "America/Sao_Paulo", "label": "America/Sao_Paulo (-03)", + "offset": "UTC-03:00"}`` + """ + now = datetime.now(timezone.utc) + entries = [] + + for name in zoneinfo.available_timezones(): + try: + local_now = now.astimezone(zoneinfo.ZoneInfo(name)) + except Exception as e: + logging.debug(f"Skipping unloadable timezone '{name}': {e}") + continue + + utc_offset = local_now.utcoffset() or timedelta(0) + total_minutes = int(utc_offset.total_seconds() // 60) + sign = "+" if total_minutes >= 0 else "-" + hours, minutes = divmod(abs(total_minutes), 60) + offset_str = f"UTC{sign}{hours:02d}:{minutes:02d}" + + abbreviation = local_now.strftime("%Z") + label = f"{name} ({abbreviation})" if abbreviation else name + + entries.append( + ( + total_minutes, + name, + { + "value": name, + "label": label, + "offset": offset_str, + }, + ) + ) + + entries.sort(key=lambda entry: (entry[0], entry[1])) + return [entry[2] for entry in entries] + + def list_supported_abbreviations() -> Dict[str, list]: """ Get a list of all supported timezone abbreviations organized by region. @@ -577,28 +656,26 @@ def list_supported_abbreviations() -> Dict[str, list]: # Categorize abbreviations for abbrev, tz_name in TIMEZONE_ABBREVIATIONS.items(): - if tz_name.startswith("America/"): + if len(abbrev) == 1: # Military single-letter codes + regions["Military"].append(abbrev) + elif abbrev in ["UTC", "GMT"]: + regions["Universal"].append(abbrev) + elif tz_name in SOUTH_AMERICAN_TIMEZONES: + regions["South America"].append(abbrev) + elif tz_name.startswith("America/"): regions["North America"].append(abbrev) elif tz_name.startswith("Europe/"): regions["Europe"].append(abbrev) elif tz_name.startswith("Asia/"): regions["Asia"].append(abbrev) - elif tz_name.startswith("Australia/") or tz_name.startswith("Pacific/Auckland"): + elif tz_name.startswith("Australia/") or tz_name == "Pacific/Auckland": regions["Australia/New Zealand"].append(abbrev) elif tz_name.startswith("Africa/"): regions["Africa"].append(abbrev) elif tz_name.startswith("Pacific/"): regions["Pacific"].append(abbrev) - elif len(abbrev) == 1: # Military single-letter codes - regions["Military"].append(abbrev) - elif abbrev in ["UTC", "GMT", "Z"]: - regions["Universal"].append(abbrev) else: - # Determine by timezone name patterns - if any(continent in tz_name for continent in ["America/Argentina", "America/Sao_Paulo", "America/Santiago"]): - regions["South America"].append(abbrev) - else: - regions["Universal"].append(abbrev) + regions["Universal"].append(abbrev) # Sort each region's abbreviations for region in regions: diff --git a/listsync-nuxt/components/settings/SyncSettings.vue b/listsync-nuxt/components/settings/SyncSettings.vue index d168e86..4d275b7 100644 --- a/listsync-nuxt/components/settings/SyncSettings.vue +++ b/listsync-nuxt/components/settings/SyncSettings.vue @@ -177,27 +177,29 @@ const showTimezoneDropdown = ref(false) const supportedTimezones = ref([]) const currentTimezoneInfo = ref(null) -// Fallback timezones if API fails -const fallbackTimezones = [ - { label: 'UTC', value: 'UTC', offset: '+00:00' }, - { label: 'America/New_York (EST/EDT)', value: 'America/New_York', offset: 'UTC-05:00' }, - { label: 'America/Chicago (CST/CDT)', value: 'America/Chicago', offset: 'UTC-06:00' }, - { label: 'America/Denver (MST/MDT)', value: 'America/Denver', offset: 'UTC-07:00' }, - { label: 'America/Los_Angeles (PST/PDT)', value: 'America/Los_Angeles', offset: 'UTC-08:00' }, - { label: 'Europe/London (GMT/BST)', value: 'Europe/London', offset: 'UTC+00:00' }, - { label: 'Europe/Paris (CET/CEST)', value: 'Europe/Paris', offset: 'UTC+01:00' }, - { label: 'Asia/Tokyo (JST)', value: 'Asia/Tokyo', offset: 'UTC+09:00' }, - { label: 'Australia/Sydney (AEDT)', value: 'Australia/Sydney', offset: 'UTC+11:00' }, -] +// Fallback: IANA timezones provided by the browser if the API fails +const getBrowserTimezones = () => { + try { + return Intl.supportedValuesOf('timeZone').map((tz) => ({ + label: tz, + value: tz, + offset: '', + })) + } catch { + return [{ label: 'UTC', value: 'UTC', offset: 'UTC+00:00' }] + } +} // Load supported timezones const loadTimezones = async () => { try { const response: any = await api.getSupportedTimezones() - supportedTimezones.value = response.timezones || fallbackTimezones + supportedTimezones.value = response.timezones?.length + ? response.timezones + : getBrowserTimezones() } catch (error) { - console.error('Error loading timezones, using fallback:', error) - supportedTimezones.value = fallbackTimezones + console.error('Error loading timezones, using browser fallback:', error) + supportedTimezones.value = getBrowserTimezones() } } @@ -247,19 +249,6 @@ onMounted(() => { timezoneSearch.value = localValue.value.timezone || '' }) -// Timezone options (common timezones) -const timezoneOptions = [ - { label: 'UTC', value: 'UTC' }, - { label: 'America/New_York (EST/EDT)', value: 'America/New_York' }, - { label: 'America/Chicago (CST/CDT)', value: 'America/Chicago' }, - { label: 'America/Denver (MST/MDT)', value: 'America/Denver' }, - { label: 'America/Los_Angeles (PST/PDT)', value: 'America/Los_Angeles' }, - { label: 'Europe/London (GMT/BST)', value: 'Europe/London' }, - { label: 'Europe/Paris (CET/CEST)', value: 'Europe/Paris' }, - { label: 'Asia/Tokyo (JST)', value: 'Asia/Tokyo' }, - { label: 'Australia/Sydney (AEDT/AEST)', value: 'Australia/Sydney' }, -] - // Watch for external changes watch( () => props.modelValue, diff --git a/listsync-nuxt/components/setup/Step2Configuration.vue b/listsync-nuxt/components/setup/Step2Configuration.vue index 3fe1630..c6e9494 100644 --- a/listsync-nuxt/components/setup/Step2Configuration.vue +++ b/listsync-nuxt/components/setup/Step2Configuration.vue @@ -95,8 +95,8 @@ class="w-full px-3 py-2.5 sm:py-2 h-11 sm:h-10 bg-black/30 border border-purple-500/25 rounded-lg text-base sm:text-sm text-foreground focus:outline-none focus:ring-2 focus:ring-purple-500/50 transition-all touch-manipulation" :disabled="isValidating" > - @@ -274,27 +274,34 @@ watch(() => localValue.value.trakt_client_id, () => { traktValidated.value = false }) -// Common timezones for quick selection -const commonTimezones = [ - 'UTC', - 'America/New_York', - 'America/Chicago', - 'America/Denver', - 'America/Los_Angeles', - 'America/Toronto', - 'America/Vancouver', - 'Europe/London', - 'Europe/Paris', - 'Europe/Berlin', - 'Europe/Amsterdam', - 'Asia/Tokyo', - 'Asia/Shanghai', - 'Asia/Hong_Kong', - 'Asia/Singapore', - 'Australia/Sydney', - 'Australia/Melbourne', - 'Pacific/Auckland', -] +// Fallback: IANA timezones provided by the browser if the API fails +const getBrowserTimezones = () => { + try { + return Intl.supportedValuesOf('timeZone').map((tz) => ({ + value: tz, + label: tz, + })) + } catch { + return [{ value: 'UTC', label: 'UTC' }] + } +} + +// All IANA timezones, loaded from the API +const timezoneOptions = ref(getBrowserTimezones()) + +onMounted(async () => { + try { + const response: any = await api.getSupportedTimezones() + if (response?.timezones?.length) { + timezoneOptions.value = response.timezones.map((tz: any) => ({ + value: tz.value, + label: `${tz.value} (${tz.offset})`, + })) + } + } catch (error) { + console.error('Error loading timezones, using browser fallback:', error) + } +}) // Check if we can proceed (basic validation) const canProceed = computed(() => { diff --git a/tests/unit/test_timezone_utils.py b/tests/unit/test_timezone_utils.py new file mode 100644 index 0000000..b1b370a --- /dev/null +++ b/tests/unit/test_timezone_utils.py @@ -0,0 +1,128 @@ +"""Unit tests for timezone utilities.""" + +import re +import zoneinfo + +import pytest + +from list_sync.utils.timezone_utils import ( + TIMEZONE_ABBREVIATIONS, + REGIONAL_PREFERENCES, + get_all_timezones, + list_supported_abbreviations, + normalize_timezone_input, +) + + +class TestBrazilianTimezones: + """Tests for Brazilian timezone abbreviations and IANA names.""" + + @pytest.mark.parametrize( + "abbreviation,expected", + [ + ("BRT", "America/Sao_Paulo"), + ("AMT", "America/Manaus"), + ("ACT", "America/Rio_Branco"), + ("FNT", "America/Noronha"), + ], + ) + def test_abbreviation_resolves_to_iana_name(self, abbreviation, expected): + assert normalize_timezone_input(abbreviation) == expected + + @pytest.mark.parametrize( + "abbreviation,expected", + [ + ("BRT", "America/Sao_Paulo"), + ("AMT", "America/Manaus"), + ("ACT", "America/Rio_Branco"), + ("FNT", "America/Noronha"), + ("BST", "America/Sao_Paulo"), + ], + ) + def test_br_region_hint_resolves_conflicts(self, abbreviation, expected): + assert normalize_timezone_input(abbreviation, region_hint="BR") == expected + + @pytest.mark.parametrize( + "iana_name", + [ + "America/Sao_Paulo", + "America/Manaus", + "America/Rio_Branco", + "America/Noronha", + ], + ) + def test_iana_names_pass_through_unchanged(self, iana_name): + assert normalize_timezone_input(iana_name) == iana_name + + def test_brazilian_mappings_are_valid_zones(self): + for abbreviation in ("BRT", "AMT", "ACT", "FNT"): + zone_name = TIMEZONE_ABBREVIATIONS[abbreviation] + zoneinfo.ZoneInfo(zone_name) # raises if invalid + + def test_br_regional_preferences_registered(self): + assert "BR" in REGIONAL_PREFERENCES + for zone_name in REGIONAL_PREFERENCES["BR"].values(): + zoneinfo.ZoneInfo(zone_name) # raises if invalid + + +class TestGetAllTimezones: + """Tests for the automatic IANA timezone listing.""" + + @pytest.fixture(scope="class") + def timezones(self): + return get_all_timezones() + + def test_returns_all_available_timezones(self, timezones): + assert len(timezones) == len(zoneinfo.available_timezones()) + + def test_includes_common_zones(self, timezones): + values = {tz["value"] for tz in timezones} + for expected in ("UTC", "America/Sao_Paulo", "Europe/London", "Asia/Tokyo"): + assert expected in values + + def test_entries_have_expected_shape(self, timezones): + offset_pattern = re.compile(r"^UTC[+-]\d{2}:\d{2}$") + for tz in timezones: + assert set(tz.keys()) == {"value", "label", "offset"} + assert tz["label"].startswith(tz["value"]) + assert offset_pattern.match(tz["offset"]), tz + + def test_values_are_valid_zone_names(self, timezones): + for tz in timezones: + zoneinfo.ZoneInfo(tz["value"]) # raises if invalid + + def test_sorted_by_offset_then_name(self, timezones): + def sort_key(tz): + sign = 1 if tz["offset"][3] == "+" else -1 + hours, minutes = tz["offset"][4:].split(":") + return (sign * (int(hours) * 60 + int(minutes)), tz["value"]) + + assert [tz["value"] for tz in timezones] == [ + tz["value"] for tz in sorted(timezones, key=sort_key) + ] + + +class TestListSupportedAbbreviations: + """Tests for the regional categorization of abbreviations.""" + + @pytest.fixture(scope="class") + def regions(self): + return list_supported_abbreviations() + + def test_south_american_abbreviations_categorized(self, regions): + for abbrev in ("BRT", "AMT", "ACT", "FNT", "ART", "CLT"): + assert abbrev in regions["South America"], abbrev + assert abbrev not in regions["North America"], abbrev + + def test_north_american_abbreviations_categorized(self, regions): + for abbrev in ("ET", "PT", "CT"): + assert abbrev in regions["North America"], abbrev + + def test_military_codes_categorized(self, regions): + for abbrev in ("A", "R", "Z"): + assert abbrev in regions["Military"], abbrev + + def test_every_abbreviation_appears_exactly_once(self, regions): + all_abbrevs = [abbrev for group in regions.values() for abbrev in group] + assert len(all_abbrevs) == len(set(all_abbrevs)) + assert set(all_abbrevs) == set(TIMEZONE_ABBREVIATIONS.keys())