feat: add timezone handling
Some checks reported errors
continuous-integration/drone/push Build was killed
continuous-integration/drone/promote/production Build is failing

This commit is contained in:
Simon 2026-03-13 18:22:12 +01:00
parent 349cee0ce6
commit e8ec620d57
Signed by: simon
GPG key ID: 40E7A58C4AA1EDB2
13 changed files with 128 additions and 28 deletions

View file

@ -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 users 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.

View file

@ -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
}) })

View file

@ -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],

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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"},

View file

@ -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."

View file

@ -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 ""

View file

@ -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 ""

View 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