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