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
|
||||
|
||||
### 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.
|
||||
|
|
|
|||
|
|
@ -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
|
||||
})
|
||||
|
||||
|
|
|
|||
|
|
@ -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],
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
3
mix.exs
3
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
|
||||
|
||||
|
|
|
|||
1
mix.lock
1
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"},
|
||||
|
|
|
|||
|
|
@ -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."
|
||||
|
|
|
|||
|
|
@ -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 ""
|
||||
|
|
|
|||
|
|
@ -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 ""
|
||||
|
|
|
|||
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