diff --git a/CHANGELOG.md b/CHANGELOG.md
index 08284ec..681169f 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -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 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.
- **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.
diff --git a/assets/js/app.js b/assets/js/app.js
index ee423eb..87f2c25 100644
--- a/assets/js/app.js
+++ b/assets/js/app.js
@@ -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
})
diff --git a/config/config.exs b/config/config.exs
index 037fd49..7bb4f61 100644
--- a/config/config.exs
+++ b/config/config.exs
@@ -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],
diff --git a/lib/mv_web/helpers/date_formatter.ex b/lib/mv_web/helpers/date_formatter.ex
index 8674e21..5e11777 100644
--- a/lib/mv_web/helpers/date_formatter.ex
+++ b/lib/mv_web/helpers/date_formatter.ex
@@ -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
diff --git a/lib/mv_web/live/join_request_live/index.ex b/lib/mv_web/live/join_request_live/index.ex
index 8d85837..a552b52 100644
--- a/lib/mv_web/live/join_request_live/index.ex
+++ b/lib/mv_web/live/join_request_live/index.ex
@@ -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
<:col :let={req} label={gettext("Reviewed at")}>
- {review_date(req)}
+ {review_date(req, @browser_timezone)}
<: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
diff --git a/lib/mv_web/live/join_request_live/show.ex b/lib/mv_web/live/join_request_live/show.ex
index 14e2760..a606e46 100644
--- a/lib/mv_web/live/join_request_live/show.ex
+++ b/lib/mv_web/live/join_request_live/show.ex
@@ -144,7 +144,7 @@ defmodule MvWeb.JoinRequestLive.Show do
<.field_row
label={gettext("Submitted at")}
- value={DateFormatter.format_datetime(@join_request.submitted_at)}
+ value={DateFormatter.format_datetime(@join_request.submitted_at, @browser_timezone)}
/>
{gettext("Status")}:
@@ -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
diff --git a/lib/mv_web/live_helpers.ex b/lib/mv_web/live_helpers.ex
index dae8325..5cbd6f0 100644
--- a/lib/mv_web/live_helpers.ex
+++ b/lib/mv_web/live_helpers.ex
@@ -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
diff --git a/mix.exs b/mix.exs
index 29dbc25..a8d0467 100644
--- a/mix.exs
+++ b/mix.exs
@@ -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
diff --git a/mix.lock b/mix.lock
index b177796..6f120c8 100644
--- a/mix.lock
+++ b/mix.lock
@@ -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"},
diff --git a/priv/gettext/de/LC_MESSAGES/default.po b/priv/gettext/de/LC_MESSAGES/default.po
index 79bd2dc..47fe18d 100644
--- a/priv/gettext/de/LC_MESSAGES/default.po
+++ b/priv/gettext/de/LC_MESSAGES/default.po
@@ -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."
diff --git a/priv/gettext/default.pot b/priv/gettext/default.pot
index a27bdbe..274ac12 100644
--- a/priv/gettext/default.pot
+++ b/priv/gettext/default.pot
@@ -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 ""
diff --git a/priv/gettext/en/LC_MESSAGES/default.po b/priv/gettext/en/LC_MESSAGES/default.po
index 69062c2..406449b 100644
--- a/priv/gettext/en/LC_MESSAGES/default.po
+++ b/priv/gettext/en/LC_MESSAGES/default.po
@@ -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 ""
diff --git a/test/mv_web/helpers/date_formatter_test.exs b/test/mv_web/helpers/date_formatter_test.exs
new file mode 100644
index 0000000..8a07ab0
--- /dev/null
+++ b/test/mv_web/helpers/date_formatter_test.exs
@@ -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