feat: add timezone handling
This commit is contained in:
parent
349cee0ce6
commit
e8ec620d57
13 changed files with 128 additions and 28 deletions
|
|
@ -10,6 +10,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||||
## [1.1.0] - 2026-03-13
|
## [1.1.0] - 2026-03-13
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
- **Browser timezone for datetime display** – Date/time values (e.g. join request submitted at, approved at, rejected at) are shown in the user’s local timezone.
|
||||||
- **Registration toggle** – New global setting to disable direct registration (`/register`). When disabled, visitors are redirected to sign-in and the register link is hidden; join form remains available.
|
- **Registration toggle** – New global setting to disable direct registration (`/register`). When disabled, visitors are redirected to sign-in and the register link is hidden; join form remains available.
|
||||||
- **Configurable SMTP in global settings** – SMTP host, port, user, password, and TLS options configurable via Admin → Global Settings. Test-email action to verify delivery. Join confirmation and other transactional emails use this configuration.
|
- **Configurable SMTP in global settings** – SMTP host, port, user, password, and TLS options configurable via Admin → Global Settings. Test-email action to verify delivery. Join confirmation and other transactional emails use this configuration.
|
||||||
- **Theme and language selector on unauthenticated pages** – Sign-in and join pages now offer theme (light/dark) and locale (e.g. German/English) controls in the header.
|
- **Theme and language selector on unauthenticated pages** – Sign-in and join pages now offer theme (light/dark) and locale (e.g. German/English) controls in the header.
|
||||||
|
|
|
||||||
|
|
@ -25,6 +25,14 @@ import Sortable from "../vendor/sortable"
|
||||||
|
|
||||||
let csrfToken = document.querySelector("meta[name='csrf-token']").getAttribute("content")
|
let csrfToken = document.querySelector("meta[name='csrf-token']").getAttribute("content")
|
||||||
|
|
||||||
|
function getBrowserTimezone() {
|
||||||
|
try {
|
||||||
|
return Intl.DateTimeFormat().resolvedOptions().timeZone || null
|
||||||
|
} catch (_e) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Hooks for LiveView components
|
// Hooks for LiveView components
|
||||||
let Hooks = {}
|
let Hooks = {}
|
||||||
|
|
||||||
|
|
@ -312,7 +320,10 @@ Hooks.SidebarState = {
|
||||||
|
|
||||||
let liveSocket = new LiveSocket("/live", Socket, {
|
let liveSocket = new LiveSocket("/live", Socket, {
|
||||||
longPollFallbackMs: 2500,
|
longPollFallbackMs: 2500,
|
||||||
params: {_csrf_token: csrfToken},
|
params: {
|
||||||
|
_csrf_token: csrfToken,
|
||||||
|
timezone: getBrowserTimezone()
|
||||||
|
},
|
||||||
hooks: Hooks
|
hooks: Hooks
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -46,6 +46,9 @@ config :spark,
|
||||||
]
|
]
|
||||||
]
|
]
|
||||||
|
|
||||||
|
# IANA timezone database for DateTime.shift_zone (browser timezone display)
|
||||||
|
config :elixir, :time_zone_database, Tz.TimeZoneDatabase
|
||||||
|
|
||||||
config :mv,
|
config :mv,
|
||||||
ecto_repos: [Mv.Repo],
|
ecto_repos: [Mv.Repo],
|
||||||
generators: [timestamp_type: :utc_datetime],
|
generators: [timestamp_type: :utc_datetime],
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@ defmodule MvWeb.Helpers.DateFormatter do
|
||||||
@moduledoc """
|
@moduledoc """
|
||||||
Centralized date formatting helper for the application.
|
Centralized date formatting helper for the application.
|
||||||
Formats dates in European format (dd.mm.yyyy).
|
Formats dates in European format (dd.mm.yyyy).
|
||||||
|
DateTime can be shown in UTC or in a given IANA timezone (e.g. from browser).
|
||||||
"""
|
"""
|
||||||
|
|
||||||
use Gettext, backend: MvWeb.Gettext
|
use Gettext, backend: MvWeb.Gettext
|
||||||
|
|
@ -28,19 +29,40 @@ defmodule MvWeb.Helpers.DateFormatter do
|
||||||
@doc """
|
@doc """
|
||||||
Formats a DateTime struct to European format (dd.mm.yyyy HH:MM).
|
Formats a DateTime struct to European format (dd.mm.yyyy HH:MM).
|
||||||
|
|
||||||
|
When `timezone` is a valid IANA timezone string (e.g. from the browser),
|
||||||
|
the datetime is converted to that zone before formatting. When `timezone` is
|
||||||
|
nil or invalid, the datetime is formatted in UTC.
|
||||||
|
|
||||||
## Examples
|
## Examples
|
||||||
|
|
||||||
iex> MvWeb.Helpers.DateFormatter.format_datetime(~U[2024-03-15 10:30:00Z])
|
iex> MvWeb.Helpers.DateFormatter.format_datetime(~U[2024-03-15 10:30:00Z])
|
||||||
"15.03.2024 10:30"
|
"15.03.2024 10:30"
|
||||||
|
|
||||||
|
iex> MvWeb.Helpers.DateFormatter.format_datetime(~U[2024-03-15 10:30:00Z], "Europe/Berlin")
|
||||||
|
"15.03.2024 11:30"
|
||||||
|
|
||||||
iex> MvWeb.Helpers.DateFormatter.format_datetime(nil)
|
iex> MvWeb.Helpers.DateFormatter.format_datetime(nil)
|
||||||
""
|
""
|
||||||
"""
|
"""
|
||||||
def format_datetime(%DateTime{} = dt) do
|
def format_datetime(%DateTime{} = dt), do: format_datetime(dt, nil)
|
||||||
Calendar.strftime(dt, "%d.%m.%Y %H:%M")
|
def format_datetime(nil), do: ""
|
||||||
|
def format_datetime(_), do: "Invalid datetime"
|
||||||
|
|
||||||
|
def format_datetime(%DateTime{} = dt, nil), do: format_datetime_utc(dt)
|
||||||
|
def format_datetime(%DateTime{} = dt, ""), do: format_datetime_utc(dt)
|
||||||
|
|
||||||
|
def format_datetime(%DateTime{} = dt, tz) when is_binary(tz) do
|
||||||
|
case DateTime.shift_zone(dt, tz, Tz.TimeZoneDatabase) do
|
||||||
|
{:ok, shifted} -> Calendar.strftime(shifted, "%d.%m.%Y %H:%M")
|
||||||
|
{:error, _} -> format_datetime_utc(dt)
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
def format_datetime(nil), do: ""
|
def format_datetime(nil, _timezone), do: ""
|
||||||
|
|
||||||
def format_datetime(_), do: "Invalid datetime"
|
def format_datetime(_, _timezone), do: "Invalid datetime"
|
||||||
|
|
||||||
|
defp format_datetime_utc(%DateTime{} = dt) do
|
||||||
|
Calendar.strftime(dt, "%d.%m.%Y %H:%M")
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
|
||||||
|
|
@ -63,7 +63,7 @@ defmodule MvWeb.JoinRequestLive.Index do
|
||||||
>
|
>
|
||||||
<:col :let={req} label={gettext("Submitted at")}>
|
<:col :let={req} label={gettext("Submitted at")}>
|
||||||
<%= if req.submitted_at do %>
|
<%= if req.submitted_at do %>
|
||||||
{DateFormatter.format_datetime(req.submitted_at)}
|
{DateFormatter.format_datetime(req.submitted_at, @browser_timezone)}
|
||||||
<% else %>
|
<% else %>
|
||||||
<.empty_cell sr_text={gettext("Not submitted yet")} />
|
<.empty_cell sr_text={gettext("Not submitted yet")} />
|
||||||
<% end %>
|
<% end %>
|
||||||
|
|
@ -125,7 +125,7 @@ defmodule MvWeb.JoinRequestLive.Index do
|
||||||
</.badge>
|
</.badge>
|
||||||
</:col>
|
</:col>
|
||||||
<:col :let={req} label={gettext("Reviewed at")}>
|
<:col :let={req} label={gettext("Reviewed at")}>
|
||||||
{review_date(req)}
|
{review_date(req, @browser_timezone)}
|
||||||
</:col>
|
</:col>
|
||||||
<:col :let={req} label={gettext("Review by")}>
|
<:col :let={req} label={gettext("Review by")}>
|
||||||
{JoinRequestHelpers.reviewer_display(req) || ""}
|
{JoinRequestHelpers.reviewer_display(req) || ""}
|
||||||
|
|
@ -162,7 +162,7 @@ defmodule MvWeb.JoinRequestLive.Index do
|
||||||
assign(socket, :page_title, gettext("Join requests"))
|
assign(socket, :page_title, gettext("Join requests"))
|
||||||
end
|
end
|
||||||
|
|
||||||
defp review_date(req) do
|
defp review_date(req, timezone) do
|
||||||
date =
|
date =
|
||||||
case req.status do
|
case req.status do
|
||||||
:approved -> req.approved_at
|
:approved -> req.approved_at
|
||||||
|
|
@ -170,6 +170,6 @@ defmodule MvWeb.JoinRequestLive.Index do
|
||||||
_ -> nil
|
_ -> nil
|
||||||
end
|
end
|
||||||
|
|
||||||
if date, do: DateFormatter.format_datetime(date), else: ""
|
if date, do: DateFormatter.format_datetime(date, timezone), else: ""
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
|
||||||
|
|
@ -144,7 +144,7 @@ defmodule MvWeb.JoinRequestLive.Show do
|
||||||
<div class="border border-base-300 rounded-lg p-4 bg-base-100 space-y-2">
|
<div class="border border-base-300 rounded-lg p-4 bg-base-100 space-y-2">
|
||||||
<.field_row
|
<.field_row
|
||||||
label={gettext("Submitted at")}
|
label={gettext("Submitted at")}
|
||||||
value={DateFormatter.format_datetime(@join_request.submitted_at)}
|
value={DateFormatter.format_datetime(@join_request.submitted_at, @browser_timezone)}
|
||||||
/>
|
/>
|
||||||
<div class="flex gap-2">
|
<div class="flex gap-2">
|
||||||
<span class="text-base-content/60 min-w-32 shrink-0">{gettext("Status")}:</span>
|
<span class="text-base-content/60 min-w-32 shrink-0">{gettext("Status")}:</span>
|
||||||
|
|
@ -158,13 +158,17 @@ defmodule MvWeb.JoinRequestLive.Show do
|
||||||
<%= if @join_request.approved_at do %>
|
<%= if @join_request.approved_at do %>
|
||||||
<.field_row
|
<.field_row
|
||||||
label={gettext("Approved at")}
|
label={gettext("Approved at")}
|
||||||
value={DateFormatter.format_datetime(@join_request.approved_at)}
|
value={
|
||||||
|
DateFormatter.format_datetime(@join_request.approved_at, @browser_timezone)
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
<% end %>
|
<% end %>
|
||||||
<%= if @join_request.rejected_at do %>
|
<%= if @join_request.rejected_at do %>
|
||||||
<.field_row
|
<.field_row
|
||||||
label={gettext("Rejected at")}
|
label={gettext("Rejected at")}
|
||||||
value={DateFormatter.format_datetime(@join_request.rejected_at)}
|
value={
|
||||||
|
DateFormatter.format_datetime(@join_request.rejected_at, @browser_timezone)
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
<% end %>
|
<% end %>
|
||||||
<.field_row
|
<.field_row
|
||||||
|
|
|
||||||
|
|
@ -22,6 +22,15 @@ defmodule MvWeb.LiveHelpers do
|
||||||
def on_mount(:default, _params, session, socket) do
|
def on_mount(:default, _params, session, socket) do
|
||||||
locale = session["locale"] || "de"
|
locale = session["locale"] || "de"
|
||||||
Gettext.put_locale(locale)
|
Gettext.put_locale(locale)
|
||||||
|
|
||||||
|
# Browser timezone from LiveSocket connect params (set in app.js via Intl API)
|
||||||
|
connect_params = socket.private[:connect_params] || %{}
|
||||||
|
timezone = connect_params["timezone"] || connect_params[:timezone]
|
||||||
|
|
||||||
|
socket =
|
||||||
|
socket
|
||||||
|
|> assign(:browser_timezone, timezone)
|
||||||
|
|
||||||
{:cont, socket}
|
{:cont, socket}
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|
|
||||||
3
mix.exs
3
mix.exs
|
|
@ -85,7 +85,8 @@ defmodule Mv.MixProject do
|
||||||
{:slugify, "~> 1.3"},
|
{:slugify, "~> 1.3"},
|
||||||
{:nimble_csv, "~> 1.0"},
|
{:nimble_csv, "~> 1.0"},
|
||||||
{:imprintor, "~> 0.5.0"},
|
{:imprintor, "~> 0.5.0"},
|
||||||
{:hammer, "~> 7.0"}
|
{:hammer, "~> 7.0"},
|
||||||
|
{:tz, "~> 0.28"}
|
||||||
]
|
]
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|
|
||||||
1
mix.lock
1
mix.lock
|
|
@ -96,6 +96,7 @@
|
||||||
"text_diff": {:hex, :text_diff, "0.1.0", "1caf3175e11a53a9a139bc9339bd607c47b9e376b073d4571c031913317fecaa", [:mix], [], "hexpm", "d1ffaaecab338e49357b6daa82e435f877e0649041ace7755583a0ea3362dbd7"},
|
"text_diff": {:hex, :text_diff, "0.1.0", "1caf3175e11a53a9a139bc9339bd607c47b9e376b073d4571c031913317fecaa", [:mix], [], "hexpm", "d1ffaaecab338e49357b6daa82e435f877e0649041ace7755583a0ea3362dbd7"},
|
||||||
"thousand_island": {:hex, :thousand_island, "1.4.3", "2158209580f633be38d43ec4e3ce0a01079592b9657afff9080d5d8ca149a3af", [:mix], [{:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "6e4ce09b0fd761a58594d02814d40f77daff460c48a7354a15ab353bb998ea0b"},
|
"thousand_island": {:hex, :thousand_island, "1.4.3", "2158209580f633be38d43ec4e3ce0a01079592b9657afff9080d5d8ca149a3af", [:mix], [{:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "6e4ce09b0fd761a58594d02814d40f77daff460c48a7354a15ab353bb998ea0b"},
|
||||||
"tidewave": {:hex, :tidewave, "0.5.5", "a125dfc87f99daf0e2280b3a9719b874c616ead5926cdf9cdfe4fcc19a020eff", [:mix], [{:circular_buffer, "~> 0.4 or ~> 1.0", [hex: :circular_buffer, repo: "hexpm", optional: false]}, {:igniter, "~> 0.6", [hex: :igniter, repo: "hexpm", optional: true]}, {:jason, "~> 1.4", [hex: :jason, repo: "hexpm", optional: false]}, {:phoenix_live_reload, ">= 1.6.1", [hex: :phoenix_live_reload, repo: "hexpm", optional: true]}, {:plug, "~> 1.17", [hex: :plug, repo: "hexpm", optional: false]}, {:req, "~> 0.5", [hex: :req, repo: "hexpm", optional: false]}], "hexpm", "825ebb4fa20de005785efa21e5a88c04d81c3f57552638d12ff3def2f203dbf7"},
|
"tidewave": {:hex, :tidewave, "0.5.5", "a125dfc87f99daf0e2280b3a9719b874c616ead5926cdf9cdfe4fcc19a020eff", [:mix], [{:circular_buffer, "~> 0.4 or ~> 1.0", [hex: :circular_buffer, repo: "hexpm", optional: false]}, {:igniter, "~> 0.6", [hex: :igniter, repo: "hexpm", optional: true]}, {:jason, "~> 1.4", [hex: :jason, repo: "hexpm", optional: false]}, {:phoenix_live_reload, ">= 1.6.1", [hex: :phoenix_live_reload, repo: "hexpm", optional: true]}, {:plug, "~> 1.17", [hex: :plug, repo: "hexpm", optional: false]}, {:req, "~> 0.5", [hex: :req, repo: "hexpm", optional: false]}], "hexpm", "825ebb4fa20de005785efa21e5a88c04d81c3f57552638d12ff3def2f203dbf7"},
|
||||||
|
"tz": {:hex, :tz, "0.28.1", "717f5ffddfd1e475e2a233e221dc0b4b76c35c4b3650b060c8e3ba29dd6632e9", [:mix], [{:castore, "~> 0.1 or ~> 1.0", [hex: :castore, repo: "hexpm", optional: true]}, {:mint, "~> 1.6", [hex: :mint, repo: "hexpm", optional: true]}], "hexpm", "bfdca1aa1902643c6c43b77c1fb0cb3d744fd2f09a8a98405468afdee0848c8a"},
|
||||||
"unicode_util_compat": {:hex, :unicode_util_compat, "0.7.1", "a48703a25c170eedadca83b11e88985af08d35f37c6f664d6dcfb106a97782fc", [:rebar3], [], "hexpm", "b3a917854ce3ae233619744ad1e0102e05673136776fb2fa76234f3e03b23642"},
|
"unicode_util_compat": {:hex, :unicode_util_compat, "0.7.1", "a48703a25c170eedadca83b11e88985af08d35f37c6f664d6dcfb106a97782fc", [:rebar3], [], "hexpm", "b3a917854ce3ae233619744ad1e0102e05673136776fb2fa76234f3e03b23642"},
|
||||||
"websock": {:hex, :websock, "0.5.3", "2f69a6ebe810328555b6fe5c831a851f485e303a7c8ce6c5f675abeb20ebdadc", [:mix], [], "hexpm", "6105453d7fac22c712ad66fab1d45abdf049868f253cf719b625151460b8b453"},
|
"websock": {:hex, :websock, "0.5.3", "2f69a6ebe810328555b6fe5c831a851f485e303a7c8ce6c5f675abeb20ebdadc", [:mix], [], "hexpm", "6105453d7fac22c712ad66fab1d45abdf049868f253cf719b625151460b8b453"},
|
||||||
"websock_adapter": {:hex, :websock_adapter, "0.5.9", "43dc3ba6d89ef5dec5b1d0a39698436a1e856d000d84bf31a3149862b01a287f", [:mix], [{:bandit, ">= 0.6.0", [hex: :bandit, repo: "hexpm", optional: true]}, {:plug, "~> 1.14", [hex: :plug, repo: "hexpm", optional: false]}, {:plug_cowboy, "~> 2.6", [hex: :plug_cowboy, repo: "hexpm", optional: true]}, {:websock, "~> 0.5", [hex: :websock, repo: "hexpm", optional: false]}], "hexpm", "5534d5c9adad3c18a0f58a9371220d75a803bf0b9a3d87e6fe072faaeed76a08"},
|
"websock_adapter": {:hex, :websock_adapter, "0.5.9", "43dc3ba6d89ef5dec5b1d0a39698436a1e856d000d84bf31a3149862b01a287f", [:mix], [{:bandit, ">= 0.6.0", [hex: :bandit, repo: "hexpm", optional: true]}, {:plug, "~> 1.14", [hex: :plug, repo: "hexpm", optional: false]}, {:plug_cowboy, "~> 2.6", [hex: :plug_cowboy, repo: "hexpm", optional: true]}, {:websock, "~> 0.5", [hex: :websock, repo: "hexpm", optional: false]}], "hexpm", "5534d5c9adad3c18a0f58a9371220d75a803bf0b9a3d87e6fe072faaeed76a08"},
|
||||||
|
|
|
||||||
|
|
@ -3891,8 +3891,3 @@ msgstr "Einstellung konnte nicht gespeichert werden."
|
||||||
#, elixir-autogen, elixir-format
|
#, elixir-autogen, elixir-format
|
||||||
msgid "If disabled, users cannot sign up via /register; sign-in and the join form remain available."
|
msgid "If disabled, users cannot sign up via /register; sign-in and the join form remain available."
|
||||||
msgstr "Wenn deaktiviert, können sich Nutzer*innen nicht über /register anmelden; Anmeldung und Beitrittsformular bleiben verfügbar."
|
msgstr "Wenn deaktiviert, können sich Nutzer*innen nicht über /register anmelden; Anmeldung und Beitrittsformular bleiben verfügbar."
|
||||||
|
|
||||||
#: lib/accounts/user/validations/registration_enabled.ex
|
|
||||||
#, elixir-autogen, elixir-format
|
|
||||||
msgid "Registration is disabled. Please use the join form or contact an administrator."
|
|
||||||
msgstr "Die Registrierung ist deaktiviert. Bitte nutze das Beitrittsformular oder wende dich an eine*n Administrator*in."
|
|
||||||
|
|
|
||||||
|
|
@ -3891,8 +3891,3 @@ msgstr ""
|
||||||
#, elixir-autogen, elixir-format
|
#, elixir-autogen, elixir-format
|
||||||
msgid "If disabled, users cannot sign up via /register; sign-in and the join form remain available."
|
msgid "If disabled, users cannot sign up via /register; sign-in and the join form remain available."
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: lib/accounts/user/validations/registration_enabled.ex
|
|
||||||
#, elixir-autogen, elixir-format
|
|
||||||
msgid "Registration is disabled. Please use the join form or contact an administrator."
|
|
||||||
msgstr ""
|
|
||||||
|
|
|
||||||
|
|
@ -3891,8 +3891,3 @@ msgstr "Failed to update setting."
|
||||||
#, elixir-autogen, elixir-format
|
#, elixir-autogen, elixir-format
|
||||||
msgid "If disabled, users cannot sign up via /register; sign-in and the join form remain available."
|
msgid "If disabled, users cannot sign up via /register; sign-in and the join form remain available."
|
||||||
msgstr "If disabled, users cannot sign up via /register; sign-in and the join form remain available."
|
msgstr "If disabled, users cannot sign up via /register; sign-in and the join form remain available."
|
||||||
|
|
||||||
#: lib/accounts/user/validations/registration_enabled.ex
|
|
||||||
#, elixir-autogen, elixir-format
|
|
||||||
msgid "Registration is disabled. Please use the join form or contact an administrator."
|
|
||||||
msgstr ""
|
|
||||||
|
|
|
||||||
63
test/mv_web/helpers/date_formatter_test.exs
Normal file
63
test/mv_web/helpers/date_formatter_test.exs
Normal file
|
|
@ -0,0 +1,63 @@
|
||||||
|
defmodule MvWeb.Helpers.DateFormatterTest do
|
||||||
|
@moduledoc """
|
||||||
|
Tests for DateFormatter: date/datetime formatting and timezone conversion for display.
|
||||||
|
"""
|
||||||
|
use ExUnit.Case, async: true
|
||||||
|
|
||||||
|
alias MvWeb.Helpers.DateFormatter
|
||||||
|
|
||||||
|
describe "format_date/1" do
|
||||||
|
test "formats Date to European format (dd.mm.yyyy)" do
|
||||||
|
assert DateFormatter.format_date(~D[2024-03-15]) == "15.03.2024"
|
||||||
|
end
|
||||||
|
|
||||||
|
test "returns empty string for nil" do
|
||||||
|
assert DateFormatter.format_date(nil) == ""
|
||||||
|
end
|
||||||
|
|
||||||
|
test "returns 'Invalid date' for non-Date" do
|
||||||
|
assert DateFormatter.format_date("2024-03-15") == "Invalid date"
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
describe "format_datetime/1 and format_datetime/2" do
|
||||||
|
test "formats UTC DateTime without timezone (European format)" do
|
||||||
|
dt = ~U[2024-03-15 10:30:00Z]
|
||||||
|
assert DateFormatter.format_datetime(dt) == "15.03.2024 10:30"
|
||||||
|
end
|
||||||
|
|
||||||
|
test "format_datetime with nil timezone same as no timezone (UTC)" do
|
||||||
|
dt = ~U[2024-03-15 10:30:00Z]
|
||||||
|
assert DateFormatter.format_datetime(dt, nil) == "15.03.2024 10:30"
|
||||||
|
end
|
||||||
|
|
||||||
|
test "formats DateTime in Europe/Berlin (CET/CEST)" do
|
||||||
|
# Winter: 10:30 UTC = 11:30 CET (UTC+1)
|
||||||
|
dt = ~U[2024-01-15 10:30:00Z]
|
||||||
|
assert DateFormatter.format_datetime(dt, "Europe/Berlin") == "15.01.2024 11:30"
|
||||||
|
|
||||||
|
# Summer: 10:30 UTC = 12:30 CEST (UTC+2)
|
||||||
|
dt_summer = ~U[2024-07-15 10:30:00Z]
|
||||||
|
assert DateFormatter.format_datetime(dt_summer, "Europe/Berlin") == "15.07.2024 12:30"
|
||||||
|
end
|
||||||
|
|
||||||
|
test "empty string timezone falls back to UTC" do
|
||||||
|
dt = ~U[2024-03-15 10:30:00Z]
|
||||||
|
assert DateFormatter.format_datetime(dt, "") == "15.03.2024 10:30"
|
||||||
|
end
|
||||||
|
|
||||||
|
test "invalid timezone falls back to UTC" do
|
||||||
|
dt = ~U[2024-03-15 10:30:00Z]
|
||||||
|
assert DateFormatter.format_datetime(dt, "Invalid/Zone") == "15.03.2024 10:30"
|
||||||
|
end
|
||||||
|
|
||||||
|
test "returns empty string for nil datetime" do
|
||||||
|
assert DateFormatter.format_datetime(nil) == ""
|
||||||
|
assert DateFormatter.format_datetime(nil, "Europe/Berlin") == ""
|
||||||
|
end
|
||||||
|
|
||||||
|
test "returns 'Invalid datetime' for non-DateTime" do
|
||||||
|
assert DateFormatter.format_datetime("2024-03-15 10:30") == "Invalid datetime"
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
Loading…
Add table
Add a link
Reference in a new issue