# SystemTimezone Module Documentation **Module:** `SystemTimezone` **File:** `lib/system_timezone.ex` **Purpose:** Cross-platform system timezone detection and DateTime utilities --- ## Overview `SystemTimezone` provides automatic timezone detection and DateTime conversion utilities for Elixir applications. It works across different operating systems (Linux, macOS, BSD) by checking multiple sources to determine the system's timezone. ### Key Features - ✅ **Automatic Detection** - Detects system timezone from multiple sources - ✅ **Cross-Platform** - Works on Linux, macOS, and BSD systems - ✅ **Tzdata Integration** - Uses Tzdata.TimeZoneDatabase for accurate conversions - ✅ **Process Caching** - Caches timezone per process for performance - ✅ **Configurable** - Override detection via application config - ✅ **Validation** - Validates timezone strings before use - ✅ **Formatting** - Multiple DateTime formatting options --- ## Detection Order The module attempts to detect the timezone in this order: 1. **Application Configuration** - `config :system_timezone, :timezone` 2. **TZ Environment Variable** - `$TZ` shell variable 3. **`/etc/timezone` File** - Linux systems 4. **`/etc/localtime` Symlink** - Linux/macOS systems 5. **UTC Fallback** - If all detection methods fail ### Detection Diagram ``` ┌─────────────────────────┐ │ 1. Check Application │ │ Config │ └───────┬─────────────────┘ │ Not found ▼ ┌─────────────────────────┐ │ 2. Check $TZ │ │ Environment Variable │ └───────┬─────────────────┘ │ Not found ▼ ┌─────────────────────────┐ │ 3. Read /etc/timezone │ └───────┬─────────────────┘ │ Not found ▼ ┌─────────────────────────┐ │ 4. Read /etc/localtime │ │ Symlink │ └───────┬─────────────────┘ │ Not found ▼ ┌─────────────────────────┐ │ 5. Default to UTC │ └─────────────────────────┘ ``` --- ## Installation The module uses the `tzdata` package for timezone data. ### Add Dependency In `mix.exs`: ```elixir defp deps do [ {:tzdata, "~> 1.1"} ] end ``` Then run: ```bash mix deps.get ``` --- ## Configuration ### Override Timezone Detection In `config/config.exs`: ```elixir config :system_timezone, :timezone, "America/Los_Angeles" ``` ### Runtime Configuration ```elixir # Set via environment variable export TZ="Europe/London" # Or create /etc/timezone echo "Asia/Tokyo" | sudo tee /etc/timezone # Or symlink /etc/localtime sudo ln -sf /usr/share/zoneinfo/America/New_York /etc/localtime ``` --- ## API Reference ### get_timezone/0 Returns the system's timezone name. #### Spec ```elixir @spec get_timezone() :: String.t() ``` #### Returns - `String.t()` - Timezone name (e.g., `"America/Lima"`, `"UTC"`) #### Examples ```elixir iex> SystemTimezone.get_timezone() "America/Lima" iex> SystemTimezone.get_timezone() "Europe/Paris" # If no timezone detected iex> SystemTimezone.get_timezone() "UTC" ``` #### Behavior - First call: Detects and caches timezone - Subsequent calls: Returns cached value - Cache is per-process - Use `clear_cache/0` to force re-detection --- ### now/0 Returns the current DateTime in the system timezone. #### Spec ```elixir @spec now() :: DateTime.t() ``` #### Returns - `DateTime.t()` - Current DateTime with timezone information #### Examples ```elixir iex> SystemTimezone.now() #DateTime<2026-01-31 16:30:00.123456-05:00 -05 America/Lima> iex> SystemTimezone.now() #DateTime<2026-01-31 21:30:00.123456+00:00 UTC UTC> # Accessing fields iex> dt = SystemTimezone.now() iex> dt.year 2026 iex> dt.time_zone "America/Lima" iex> dt.zone_abbr "-05" iex> dt.utc_offset -18000 # -5 hours in seconds ``` #### Use Cases - Logging with local time - Displaying timestamps to users - Scheduling based on local time - Time-based calculations --- ### to_local/1 Converts a DateTime to the system timezone. #### Spec ```elixir @spec to_local(DateTime.t()) :: {:ok, DateTime.t()} | {:error, atom()} ``` #### Parameters - `datetime` :: `DateTime.t()` - DateTime to convert #### Returns - `{:ok, DateTime.t()}` - Successfully converted DateTime - `{:error, atom()}` - Conversion failed #### Examples ```elixir iex> utc = ~U[2026-01-31 21:30:00Z] iex> SystemTimezone.to_local(utc) {:ok, #DateTime<2026-01-31 16:30:00-05:00 -05 America/Lima>} # Converting from one timezone to system timezone iex> tokyo = DateTime.from_naive!(~N[2026-01-31 12:00:00], "Asia/Tokyo") iex> SystemTimezone.to_local(tokyo) {:ok, #DateTime<2026-01-31 03:00:00-05:00 -05 America/Lima>} # Error case - invalid datetime iex> invalid_dt = %DateTime{...} # malformed iex> SystemTimezone.to_local(invalid_dt) {:error, :invalid_datetime} ``` #### Pattern Matching ```elixir case SystemTimezone.to_local(utc_datetime) do {:ok, local} -> IO.puts("Local time: #{local}") {:error, reason} -> IO.puts("Conversion failed: #{reason}") end ``` --- ### to_local!/1 Converts a DateTime to the system timezone, raising on error. #### Spec ```elixir @spec to_local!(DateTime.t()) :: DateTime.t() ``` #### Parameters - `datetime` :: `DateTime.t()` - DateTime to convert #### Returns - `DateTime.t()` - Converted DateTime #### Raises - `ArgumentError` if conversion fails #### Examples ```elixir iex> utc = ~U[2026-01-31 21:30:00Z] iex> SystemTimezone.to_local!(utc) #DateTime<2026-01-31 16:30:00-05:00 -05 America/Lima> # Error case - raises iex> SystemTimezone.to_local!(invalid_datetime) ** (ArgumentError) Failed to convert timezone: invalid_datetime ``` #### When to Use - When you're confident the conversion will succeed - In pipelines where you want failures to propagate - When error handling is done at a higher level ```elixir # Pipeline usage utc_datetime |> SystemTimezone.to_local!() |> format_for_display() |> send_to_user() ``` --- ### fetch_timezone/0 Returns the timezone with explicit success/error tuple. #### Spec ```elixir @spec fetch_timezone() :: {:ok, String.t()} | {:error, String.t()} ``` #### Returns - `{:ok, String.t()}` - Detected timezone - `{:error, String.t()}` - Could not detect, using UTC #### Examples ```elixir # Successfully detected iex> SystemTimezone.fetch_timezone() {:ok, "America/Lima"} # Detection failed, using UTC iex> SystemTimezone.fetch_timezone() {:error, "Could not detect system timezone, using UTC"} # Check if explicitly configured iex> Application.put_env(:system_timezone, :timezone, "Europe/Berlin") iex> SystemTimezone.fetch_timezone() {:ok, "Europe/Berlin"} ``` #### Use Case ```elixir case SystemTimezone.fetch_timezone() do {:ok, tz} -> Logger.info("Using timezone: #{tz}") {:error, msg} -> Logger.warning(msg) send_admin_alert("Timezone detection failed") end ``` --- ### format/2 Formats a DateTime for display. #### Spec ```elixir @spec format(DateTime.t(), keyword()) :: String.t() ``` #### Parameters - `datetime` :: `DateTime.t()` - DateTime to format - `opts` :: `keyword()` - Options (default: `[]`) #### Options - `:format` - Format type (default: `:default`) - `:default` - `"2026-01-31 16:30:00 -05"` - `:date` - `"2026-01-31"` - `:time` - `"16:30:00"` - `:iso8601` - `"2026-01-31T16:30:00-05:00"` #### Returns - `String.t()` - Formatted datetime string #### Examples ```elixir iex> dt = SystemTimezone.now() # Default format iex> SystemTimezone.format(dt) "2026-01-31 16:30:00 -05" # Date only iex> SystemTimezone.format(dt, format: :date) "2026-01-31" # Time only iex> SystemTimezone.format(dt, format: :time) "16:30:00" # ISO8601 iex> SystemTimezone.format(dt, format: :iso8601) "2026-01-31T16:30:00-05:00" ``` #### Use Cases ```elixir # Logging Logger.info("Request at #{SystemTimezone.format(SystemTimezone.now())}") # User display def display_timestamp(datetime) do SystemTimezone.format(datetime, format: :default) end # API responses %{ date: SystemTimezone.format(dt, format: :date), time: SystemTimezone.format(dt, format: :time), iso: SystemTimezone.format(dt, format: :iso8601) } ``` --- ### utc_offset/0 Returns the UTC offset for the system timezone as a string. #### Spec ```elixir @spec utc_offset() :: String.t() ``` #### Returns - `String.t()` - UTC offset (e.g., `"-05:00"`, `"+09:00"`, `"+00:00"`) #### Examples ```elixir # Lima, Peru (UTC-5) iex> SystemTimezone.utc_offset() "-05:00" # Tokyo, Japan (UTC+9) iex> SystemTimezone.utc_offset() "+09:00" # London, UK (UTC+0) iex> SystemTimezone.utc_offset() "+00:00" # Los Angeles during DST (UTC-7) iex> SystemTimezone.utc_offset() "-07:00" ``` #### Use Cases ```elixir # Display timezone info IO.puts("Server timezone: #{SystemTimezone.get_timezone()}") IO.puts("UTC offset: #{SystemTimezone.utc_offset()}") # API metadata %{ timezone: SystemTimezone.get_timezone(), offset: SystemTimezone.utc_offset() } ``` --- ### valid_timezone?/1 Checks if a timezone string is valid. #### Spec ```elixir @spec valid_timezone?(String.t()) :: boolean() ``` #### Parameters - `timezone` :: `String.t()` - Timezone name to validate #### Returns - `true` if timezone is valid - `false` if timezone is invalid #### Examples ```elixir iex> SystemTimezone.valid_timezone?("America/Lima") true iex> SystemTimezone.valid_timezone?("Europe/Paris") true iex> SystemTimezone.valid_timezone?("Invalid/Zone") false iex> SystemTimezone.valid_timezone?("America/Foo") false iex> SystemTimezone.valid_timezone?("") false ``` #### Use Cases ```elixir # Validate user input def set_user_timezone(user, timezone) do if SystemTimezone.valid_timezone?(timezone) do {:ok, %{user | timezone: timezone}} else {:error, "Invalid timezone: #{timezone}"} end end # Configuration validation def validate_config do tz = Application.get_env(:my_app, :timezone) unless SystemTimezone.valid_timezone?(tz) do raise "Invalid timezone in config: #{tz}" end end ``` --- ### clear_cache/0 Clears the cached timezone, forcing re-detection on next call. #### Spec ```elixir @spec clear_cache() :: :ok ``` #### Returns - `:ok` - Always succeeds #### Examples ```elixir iex> SystemTimezone.get_timezone() "America/Lima" iex> SystemTimezone.clear_cache() :ok # Next call will re-detect iex> SystemTimezone.get_timezone() "America/New_York" # If timezone changed ``` #### Use Cases ```elixir # After timezone configuration change def update_timezone_config(new_tz) do Application.put_env(:system_timezone, :timezone, new_tz) SystemTimezone.clear_cache() :ok end # Testing different timezones test "handles multiple timezones" do Application.put_env(:system_timezone, :timezone, "Asia/Tokyo") SystemTimezone.clear_cache() assert SystemTimezone.get_timezone() == "Asia/Tokyo" Application.put_env(:system_timezone, :timezone, "Europe/London") SystemTimezone.clear_cache() assert SystemTimezone.get_timezone() == "Europe/London" end ``` --- ## Common Usage Patterns ### Get Current Local Time ```elixir # Simple local_now = SystemTimezone.now() # With formatting formatted = SystemTimezone.format(SystemTimezone.now()) IO.puts("Current time: #{formatted}") ``` ### Convert UTC to Local ```elixir # From database (UTC timestamp) utc_datetime = DateTime.from_unix!(timestamp, :second) # Convert to local case SystemTimezone.to_local(utc_datetime) do {:ok, local} -> display_to_user(local) {:error, _} -> display_to_user(utc_datetime) # Fallback end ``` ### Display Timestamps ```elixir def format_timestamp(utc_datetime) do utc_datetime |> SystemTimezone.to_local!() |> SystemTimezone.format(format: :default) end # Usage format_timestamp(~U[2026-01-31 21:30:00Z]) #=> "2026-01-31 16:30:00 -05" ``` ### Logging with Local Time ```elixir defmodule MyApp.Logger do def log(level, message) do timestamp = SystemTimezone.format(SystemTimezone.now()) IO.puts("[#{timestamp}] [#{level}] #{message}") end end # Usage MyApp.Logger.log(:info, "User logged in") #=> [2026-01-31 16:30:00 -05] [info] User logged in ``` ### Schedule Based on Local Time ```elixir def schedule_daily_task(hour, minute) do next_run = fn -> now = SystemTimezone.now() target = %{now | hour: hour, minute: minute, second: 0} if DateTime.compare(target, now) == :lt do # Add 1 day if target time already passed today DateTime.add(target, 24 * 60 * 60, :second) else target end end schedule_at(next_run.()) end ``` ### API Response with Timezone ```elixir def render_event(event) do %{ id: event.id, title: event.title, created_at: SystemTimezone.format(event.inserted_at, format: :iso8601), server_timezone: SystemTimezone.get_timezone(), server_offset: SystemTimezone.utc_offset() } end # Response: # %{ # id: 123, # title: "Meeting", # created_at: "2026-01-31T16:30:00-05:00", # server_timezone: "America/Lima", # server_offset: "-05:00" # } ``` ### Validate Configuration on Startup ```elixir defmodule MyApp.Application do def start(_type, _args) do # Log timezone info case SystemTimezone.fetch_timezone() do {:ok, tz} -> Logger.info("Server timezone: #{tz} (#{SystemTimezone.utc_offset()})") {:error, msg} -> Logger.warning(msg) end # Start supervision tree... end end ``` --- ## Implementation Details ### Process Cache The module uses process dictionary to cache the detected timezone: ```elixir # First call in process SystemTimezone.get_timezone() # Detects: config → env → /etc/timezone → /etc/localtime → UTC # Caches in Process dictionary # Subsequent calls in same process SystemTimezone.get_timezone() # Returns cached value immediately (no detection) ``` ### Why Per-Process Cache? - **Thread-safe** - Each process has its own cache - **No coordination** - No need for ETS or GenServer - **Fast** - Simple dictionary lookup - **Automatic cleanup** - Cache cleared when process terminates ### Timezone Validation Before using any detected timezone, it's validated: ```elixir defp from_etc_timezone do case File.read("/etc/timezone") do {:ok, content} -> trimmed = String.trim(content) # Validates before returning if valid_timezone?(trimmed), do: trimmed, else: nil {:error, _} -> nil end end ``` This prevents using invalid timezone strings that would cause runtime errors. --- ## Platform-Specific Behavior ### Linux **Detection sources:** 1. `/etc/timezone` - Standard timezone file 2. `/etc/localtime` - Symlink to zoneinfo **Example:** ```bash # Check current timezone cat /etc/timezone #=> America/Lima # Or check symlink ls -la /etc/localtime #=> /etc/localtime -> /usr/share/zoneinfo/America/Lima ``` ### macOS **Detection sources:** 1. `/etc/localtime` - Symlink to zoneinfo 2. `$TZ` environment variable **Example:** ```bash ls -la /etc/localtime #=> /etc/localtime -> /var/db/timezone/zoneinfo/America/Los_Angeles ``` ### BSD Similar to macOS, primarily uses `/etc/localtime` symlink. --- ## Troubleshooting ### Timezone Always Returns UTC **Problem:** `get_timezone()` always returns `"UTC"` **Solutions:** 1. **Set TZ environment variable:** ```bash export TZ="America/New_York" ``` 2. **Create /etc/timezone file:** ```bash echo "Europe/London" | sudo tee /etc/timezone ``` 3. **Symlink /etc/localtime:** ```bash sudo ln -sf /usr/share/zoneinfo/Asia/Tokyo /etc/localtime ``` 4. **Configure in app:** ```elixir # config/config.exs config :system_timezone, :timezone, "America/Lima" ``` ### Check Current Detection ```elixir # Clear cache and re-detect SystemTimezone.clear_cache() # Check what was detected case SystemTimezone.fetch_timezone() do {:ok, tz} -> IO.puts("Detected: #{tz}") {:error, msg} -> IO.puts("Failed: #{msg}") end ``` ### Validate Timezone String ```elixir # Check if timezone is valid tz = "America/Lima" if SystemTimezone.valid_timezone?(tz) do IO.puts("✓ Valid timezone") else IO.puts("✗ Invalid timezone") end ``` ### Debug Detection Process Add some temporary logging: ```elixir # Temporarily modify detection (for debugging) defp detect_and_cache_timezone do IO.puts("Checking config...") tz = from_config() || IO.inspect("not found") IO.puts("Checking env...") tz = tz || from_env() || IO.inspect("not found") IO.puts("Checking /etc/timezone...") tz = tz || from_etc_timezone() || IO.inspect("not found") # ... etc end ``` --- ## Performance ### Cache Performance | Operation | First Call | Cached Call | |-----------|-----------|-------------| | `get_timezone/0` | ~10-50ms | <1ms | | `now/0` | ~5-10ms | ~5-10ms | | `to_local/1` | ~5-10ms | ~5-10ms | **Note:** First call includes timezone detection and validation. ### Memory Usage - **Process dictionary** - ~100 bytes per process - **No shared state** - No memory overhead across processes ### Optimization Tips ```elixir # Good - Cache in module attribute (compile-time) @timezone SystemTimezone.get_timezone() # Good - Cache in process timezone = SystemTimezone.get_timezone() # Reuse `timezone` variable # Avoid - Repeated calls (unnecessary) for _ <- 1..1000 do SystemTimezone.get_timezone() # Only first call detects end ``` --- ## Testing ### Mock Timezone in Tests ```elixir defmodule MyAppTest do use ExUnit.Case setup do # Set test timezone Application.put_env(:system_timezone, :timezone, "America/New_York") # Clear cache to use test timezone SystemTimezone.clear_cache() # Cleanup on_exit(fn -> Application.delete_env(:system_timezone, :timezone) SystemTimezone.clear_cache() end) end test "uses configured timezone" do assert SystemTimezone.get_timezone() == "America/New_York" end end ``` ### Test Multiple Timezones ```elixir test "handles different timezones" do timezones = ["America/Lima", "Asia/Tokyo", "Europe/London"] for tz <- timezones do Application.put_env(:system_timezone, :timezone, tz) SystemTimezone.clear_cache() assert SystemTimezone.get_timezone() == tz assert SystemTimezone.valid_timezone?(tz) end end ``` ### Test Time Conversion ```elixir test "converts UTC to local time" do Application.put_env(:system_timezone, :timezone, "America/Lima") SystemTimezone.clear_cache() # UTC: 2026-01-31 21:30:00 utc = ~U[2026-01-31 21:30:00Z] # Lima is UTC-5 {:ok, local} = SystemTimezone.to_local(utc) assert local.hour == 16 # 21 - 5 = 16 assert local.time_zone == "America/Lima" end ``` --- ## Dependencies ### Required - **tzdata** (~> 1.1) - IANA timezone database ### Optional None - works standalone with just `tzdata` --- ## Examples ### Complete Application Setup ```elixir # config/config.exs config :system_timezone, :timezone, "America/Los_Angeles" # lib/my_app/application.ex defmodule MyApp.Application do def start(_type, _args) do # Log timezone on startup log_timezone_info() children = [ # ... your children ] Supervisor.start_link(children, strategy: :one_for_one) end defp log_timezone_info do tz = SystemTimezone.get_timezone() offset = SystemTimezone.utc_offset() now = SystemTimezone.format(SystemTimezone.now()) Logger.info(""" System Timezone Information: Timezone: #{tz} UTC Offset: #{offset} Current Time: #{now} """) end end ``` ### Logger with Local Timestamps ```elixir defmodule MyApp.CustomLogger do def info(message), do: log(:info, message) def warning(message), do: log(:warning, message) def error(message), do: log(:error, message) defp log(level, message) do timestamp = SystemTimezone.now() |> SystemTimezone.format(format: :default) IO.puts("[#{timestamp}] [#{level}] #{message}") end end ``` ### User Timezone Preferences ```elixir defmodule MyApp.User do def display_time(user, utc_datetime) do # Use user's preferred timezone or system default timezone = user.timezone || SystemTimezone.get_timezone() case DateTime.shift_zone(utc_datetime, timezone) do {:ok, local} -> SystemTimezone.format(local) {:error, _} -> # Fallback to system timezone utc_datetime |> SystemTimezone.to_local!() |> SystemTimezone.format() end end end ``` --- ## See Also - [DateTime — Elixir Docs](https://hexdocs.pm/elixir/DateTime.html) - [Tzdata — Hex Docs](https://hexdocs.pm/tzdata) - [IANA Time Zone Database](https://www.iana.org/time-zones) --- **Documentation Generated:** 2026-01-31 **Module Version:** 1.0.0 **Elixir Version:** 1.19.5