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
### 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.
- **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.

View file

@ -25,6 +25,14 @@ import Sortable from "../vendor/sortable"
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
let Hooks = {}
@ -312,7 +320,10 @@ Hooks.SidebarState = {
let liveSocket = new LiveSocket("/live", Socket, {
longPollFallbackMs: 2500,
params: {_csrf_token: csrfToken},
params: {
_csrf_token: csrfToken,
timezone: getBrowserTimezone()
},
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,
ecto_repos: [Mv.Repo],
generators: [timestamp_type: :utc_datetime],

View file

@ -2,6 +2,7 @@ defmodule MvWeb.Helpers.DateFormatter do
@moduledoc """
Centralized date formatting helper for the application.
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
@ -28,19 +29,40 @@ defmodule MvWeb.Helpers.DateFormatter do
@doc """
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
iex> MvWeb.Helpers.DateFormatter.format_datetime(~U[2024-03-15 10:30:00Z])
"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)
""
"""
def format_datetime(%DateTime{} = dt) do
Calendar.strftime(dt, "%d.%m.%Y %H:%M")
def format_datetime(%DateTime{} = dt), do: format_datetime(dt, nil)
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
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

View file

@ -63,7 +63,7 @@ defmodule MvWeb.JoinRequestLive.Index do
>
<:col :let={req} label={gettext("Submitted at")}>
<%= if req.submitted_at do %>
{DateFormatter.format_datetime(req.submitted_at)}
{DateFormatter.format_datetime(req.submitted_at, @browser_timezone)}
<% else %>
<.empty_cell sr_text={gettext("Not submitted yet")} />
<% end %>
@ -125,7 +125,7 @@ defmodule MvWeb.JoinRequestLive.Index do
</.badge>
</:col>
<:col :let={req} label={gettext("Reviewed at")}>
{review_date(req)}
{review_date(req, @browser_timezone)}
</:col>
<:col :let={req} label={gettext("Review by")}>
{JoinRequestHelpers.reviewer_display(req) || ""}
@ -162,7 +162,7 @@ defmodule MvWeb.JoinRequestLive.Index do
assign(socket, :page_title, gettext("Join requests"))
end
defp review_date(req) do
defp review_date(req, timezone) do
date =
case req.status do
:approved -> req.approved_at
@ -170,6 +170,6 @@ defmodule MvWeb.JoinRequestLive.Index do
_ -> nil
end
if date, do: DateFormatter.format_datetime(date), else: ""
if date, do: DateFormatter.format_datetime(date, timezone), else: ""
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">
<.field_row
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">
<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 %>
<.field_row
label={gettext("Approved at")}
value={DateFormatter.format_datetime(@join_request.approved_at)}
value={
DateFormatter.format_datetime(@join_request.approved_at, @browser_timezone)
}
/>
<% end %>
<%= if @join_request.rejected_at do %>
<.field_row
label={gettext("Rejected at")}
value={DateFormatter.format_datetime(@join_request.rejected_at)}
value={
DateFormatter.format_datetime(@join_request.rejected_at, @browser_timezone)
}
/>
<% end %>
<.field_row

View file

@ -22,6 +22,15 @@ defmodule MvWeb.LiveHelpers do
def on_mount(:default, _params, session, socket) do
locale = session["locale"] || "de"
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}
end

View file

@ -85,7 +85,8 @@ defmodule Mv.MixProject do
{:slugify, "~> 1.3"},
{:nimble_csv, "~> 1.0"},
{:imprintor, "~> 0.5.0"},
{:hammer, "~> 7.0"}
{:hammer, "~> 7.0"},
{:tz, "~> 0.28"}
]
end

View file

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

View file

@ -3891,8 +3891,3 @@ msgstr "Einstellung konnte nicht gespeichert werden."
#, elixir-autogen, elixir-format
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."
#: 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
msgid "If disabled, users cannot sign up via /register; sign-in and the join form remain available."
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
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."
#: 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