Improve localized notifications and local time UX

This commit is contained in:
SimpleTest 2026-07-25 15:01:02 +03:00
parent 144167beae
commit ae663a745b
24 changed files with 2711 additions and 1709 deletions

View File

@ -345,7 +345,18 @@ const createAidMap = element => {
content.append(title)
const location = document.createElement("small")
location.textContent = point.location || ""
const capturedAt = point.captured_at ? new Date(point.captured_at) : null
const capturedAtLabel =
capturedAt && !Number.isNaN(capturedAt.getTime())
? new Intl.DateTimeFormat(undefined, {
dateStyle: "short",
timeStyle: "medium"
}).format(capturedAt)
: ""
location.textContent =
capturedAtLabel
? `${point.location_prefix || ""} ${capturedAtLabel}`.trim()
: point.location || ""
content.append(location)
if (point.id) {
@ -1145,10 +1156,14 @@ export const Hooks = {
const date = new Date(value)
if (Number.isNaN(date.getTime())) return
this.el.textContent = new Intl.DateTimeFormat(undefined, {
dateStyle: "medium",
timeStyle: "short"
}).format(date)
const dateStyle = this.el.dataset.dateStyle || "medium"
const timeStyle = this.el.dataset.timeStyle || "short"
const options = {}
if (dateStyle !== "none") options.dateStyle = dateStyle
if (timeStyle !== "none") options.timeStyle = timeStyle
this.el.textContent = new Intl.DateTimeFormat(undefined, options).format(date)
this.el.title = date.toISOString()
}

View File

@ -35,6 +35,7 @@ test("nearby alerts, private notification inbox, preferences, and data export wo
const requestTitle = projectText("Nearby notification request", scope);
await gotoLiveView(subscriber.page, "/notifications");
await subscriber.page.getByRole("link", { name: "Delivery settings" }).click();
const preferenceForm = subscriber.page.locator("#notification-preferences-form");
await preferenceForm.getByLabel("Allow email notifications").check();
await preferenceForm.getByLabel("Nearby requests by email").check();
@ -44,6 +45,7 @@ test("nearby alerts, private notification inbox, preferences, and data export wo
await preferenceForm.getByRole("button", { name: "Save preferences" }).click();
await expect(subscriber.page.getByText("Notification preferences saved.")).toBeVisible();
await subscriber.page.getByRole("link", { name: "Nearby alerts" }).click();
const subscriptionForm = subscriber.page.locator("#nearby-subscription-form");
await subscriptionForm.getByLabel("Alert name").fill(alertName);
await subscriptionForm.getByLabel("Private area label").fill(privateArea);
@ -96,6 +98,7 @@ test("nearby alerts, private notification inbox, preferences, and data export wo
await expect(requester.page.getByRole("heading", { name: requestTitle })).toBeVisible();
const requestURL = requester.page.url();
await subscriber.page.getByRole("link", { name: "Inbox" }).click();
const inbox = subscriber.page
.getByRole("heading", { name: "Inbox" })
.locator("xpath=ancestor::section");

View File

@ -886,7 +886,8 @@ defmodule WhoNeedHelp.Help do
request.id,
assignment.helper_id,
"Request cancelled",
"The requester cancelled this request. Open Who Need Help for details."
"The requester cancelled this request. Open Who Need Help for details.",
%{"event_variant" => "requester_cancelled"}
)
end
@ -908,7 +909,8 @@ defmodule WhoNeedHelp.Help do
request.id,
request.requester_id,
"Helper withdrew after expiry",
"The helper withdrew and the request is no longer open because it expired."
"The helper withdrew and the request is no longer open because it expired.",
%{"event_variant" => "helper_withdrew_after_expiry"}
)
end

View File

@ -0,0 +1,153 @@
defmodule WhoNeedHelp.Notifications.Text do
@moduledoc """
Renders privacy-safe notification copy in the recipient's locale.
Notification rows retain their original title and body as a compatibility
fallback. Known product events are rendered from `kind` and structured
`data`, so the inbox and remote delivery can follow a user's current locale
without storing private conversation text or coordinates.
"""
use Gettext, backend: WhoNeedHelpWeb.Gettext
alias WhoNeedHelp.Notifications.Notification
@supported_locales ~w(en uk ru)
def render(%Notification{} = notification, locale) do
with_locale(locale, fn ->
notification
|> content()
|> Map.put(:locale, normalized_locale(locale))
end)
end
def localize(%Notification{} = notification, locale) do
%{title: title, body: body} = render(notification, locale)
%{notification | title: title, body: body}
end
defp content(%Notification{kind: :nearby_request}) do
%{
title: gettext("New help request nearby"),
body: gettext("A request matching one of your nearby-help alerts is available.")
}
end
defp content(%Notification{kind: :request_accepted}) do
%{
title: gettext("A helper responded"),
body: gettext("Open Who Need Help to see the request update.")
}
end
defp content(%Notification{kind: :request_reopened}) do
%{
title: gettext("Your request needs a new helper"),
body: gettext("The previous helper withdrew, so the request is open again.")
}
end
defp content(%Notification{
kind: :request_cancelled,
data: %{"event_variant" => "requester_cancelled"}
}) do
%{
title: gettext("Request cancelled"),
body: gettext("The requester cancelled this request. Open Who Need Help for details.")
}
end
defp content(%Notification{
kind: :request_cancelled,
data: %{"event_variant" => "helper_withdrew_after_expiry"}
}) do
%{
title: gettext("Request closed after the helper withdrew"),
body: gettext("The helper withdrew after the request expired, so it was not reopened.")
}
end
defp content(%Notification{kind: :request_cancelled}) do
%{
title: gettext("Request updated"),
body: gettext("Open Who Need Help to review the latest request status.")
}
end
defp content(%Notification{kind: :assignment_started}) do
%{
title: gettext("Your helper started"),
body: gettext("Open Who Need Help to follow the request status.")
}
end
defp content(%Notification{kind: :helper_arrived}) do
%{
title: gettext("Your helper arrived"),
body: gettext("Open Who Need Help to coordinate the handover safely.")
}
end
defp content(%Notification{kind: :handover_verified}) do
%{
title: gettext("Handover code verified"),
body: gettext("The helper verified the one-time handover code.")
}
end
defp content(%Notification{kind: :request_completed}) do
%{
title: gettext("Help completed"),
body: gettext("Both participants confirmed completion and the handover was verified.")
}
end
defp content(%Notification{kind: :message_created}) do
%{
title: gettext("New message"),
body: gettext("Open Who Need Help to read the conversation.")
}
end
defp content(%Notification{kind: :review_revealed}) do
%{
title: gettext("Your review is now visible"),
body: gettext("Both participants submitted a review. Open Who Need Help to see it.")
}
end
defp content(%Notification{
kind: :support_update,
data: %{"kind" => "report_updated", "status" => status}
}) do
%{
title: gettext("Your report was updated"),
body: gettext("The latest moderation status is %{status}.", status: status_label(status))
}
end
defp content(%Notification{kind: :support_update}) do
%{
title: gettext("Support request updated"),
body: gettext("Open Who Need Help to review the latest update.")
}
end
defp content(%Notification{} = notification) do
%{title: notification.title, body: notification.body}
end
defp status_label("open"), do: gettext("Open")
defp status_label("reviewing"), do: gettext("Reviewing")
defp status_label("resolved"), do: gettext("Resolved")
defp status_label("dismissed"), do: gettext("Dismissed")
defp status_label(status), do: status
defp with_locale(locale, fun) do
Gettext.with_locale(WhoNeedHelpWeb.Gettext, normalized_locale(locale), fun)
end
defp normalized_locale(locale) when locale in @supported_locales, do: locale
defp normalized_locale(_locale), do: "en"
end

View File

@ -66,7 +66,7 @@ defmodule WhoNeedHelp.Push do
})
end
def enqueue_lifecycle(kind, event_id, request_id, recipient_id, title, body)
def enqueue_lifecycle(kind, event_id, request_id, recipient_id, title, body, data \\ %{})
when is_atom(kind) do
Notifications.notify_user(recipient_id, %{
kind: kind,
@ -74,7 +74,11 @@ defmodule WhoNeedHelp.Push do
body: body,
path: "/requests/#{request_id}",
idempotency_key: "#{kind}:#{event_id}:#{recipient_id}",
data: %{"kind" => to_string(kind), "request_id" => request_id}
data:
Map.merge(
%{"kind" => to_string(kind), "request_id" => request_id},
Map.new(data, fn {key, value} -> {to_string(key), value} end)
)
})
end

View File

@ -11,7 +11,7 @@ defmodule WhoNeedHelp.Push.DeviceDeliveryWorker do
]
alias WhoNeedHelp.Notifications
alias WhoNeedHelp.Notifications.{Notification, PushDevice}
alias WhoNeedHelp.Notifications.{Notification, PushDevice, Text}
alias WhoNeedHelp.Push
alias WhoNeedHelp.Repo
@ -19,7 +19,14 @@ defmodule WhoNeedHelp.Push.DeviceDeliveryWorker do
def perform(%Oban.Job{
args: %{"notification_id" => notification_id, "device_id" => device_id}
}) do
notification = Repo.get(Notification, notification_id)
notification =
Notification
|> Repo.get(notification_id)
|> case do
nil -> nil
notification -> Repo.preload(notification, :user)
end
device = Repo.get(PushDevice, device_id)
cond do
@ -33,7 +40,7 @@ defmodule WhoNeedHelp.Push.DeviceDeliveryWorker do
{:cancel, :ownership_mismatch}
true ->
deliver(notification, device)
deliver(Text.localize(notification, notification.user.locale), device)
end
end

View File

@ -6,7 +6,7 @@ defmodule WhoNeedHelp.Push.NotificationDispatchWorker do
unique: [period: :infinity, fields: [:args, :worker], keys: [:notification_id]]
alias WhoNeedHelp.Notifications
alias WhoNeedHelp.Notifications.{Notification, Preference}
alias WhoNeedHelp.Notifications.{Notification, Preference, Text}
alias WhoNeedHelp.Push
alias WhoNeedHelp.Push.{DeliveryWorker, DeviceDeliveryWorker, NotificationEmailWorker}
alias WhoNeedHelp.Repo
@ -14,8 +14,10 @@ defmodule WhoNeedHelp.Push.NotificationDispatchWorker do
@impl Oban.Worker
def perform(%Oban.Job{args: %{"notification_id" => notification_id}} = job) do
with %Notification{} = notification <- Repo.get(Notification, notification_id) do
notification = Repo.preload(notification, :user)
if job.attempt == 1, do: Notifications.broadcast_created(notification)
now = DateTime.utc_now(:second)
localized_notification = Text.localize(notification, notification.user.locale)
preference =
Repo.get_by(Preference, user_id: notification.user_id) ||
@ -38,7 +40,7 @@ defmodule WhoNeedHelp.Push.NotificationDispatchWorker do
{:snooze, seconds}
true ->
case dispatch_push(notification, push_allowed?) do
case dispatch_push(localized_notification, push_allowed?) do
:ok -> dispatch_email(notification, email_allowed?)
{:error, _reason} = error -> error
end

View File

@ -7,7 +7,7 @@ defmodule WhoNeedHelp.Push.NotificationEmailWorker do
unique: [period: :infinity, fields: [:args, :worker], keys: [:notification_id]]
alias WhoNeedHelp.Notifications
alias WhoNeedHelp.Notifications.{EmailNotifier, Notification, Preference}
alias WhoNeedHelp.Notifications.{EmailNotifier, Notification, Preference, Text}
alias WhoNeedHelp.Repo
@impl Oban.Worker
@ -26,7 +26,9 @@ defmodule WhoNeedHelp.Push.NotificationEmailWorker do
%Preference{user_id: notification.user_id}
if Notifications.email_allowed?(preference, notification) do
case EmailNotifier.deliver(notification.user, notification) do
localized_notification = Text.localize(notification, notification.user.locale)
case EmailNotifier.deliver(notification.user, localized_notification) do
{:ok, _metadata} -> :ok
{:error, reason} -> {:error, reason}
end

View File

@ -399,7 +399,11 @@ defmodule WhoNeedHelp.Trust do
body: "The latest moderation status is #{report.status}.",
path: "/reports",
idempotency_key: "report-moderated:#{report.id}:#{report.status}",
data: %{"kind" => "report_updated", "report_id" => report.id}
data: %{
"kind" => "report_updated",
"report_id" => report.id,
"status" => to_string(report.status)
}
})
result

View File

@ -472,6 +472,39 @@ defmodule WhoNeedHelpWeb.CoreComponents do
"""
end
@doc """
Renders a timestamp in the browser's locale and time zone.
UTC remains the durable server value and the no-JavaScript fallback. The
`LocalTime` hook replaces only the visible text, so the semantic `time`
element keeps its machine-readable ISO timestamp.
"""
attr :id, :string, required: true
attr :value, :any, required: true
attr :class, :string, default: nil
attr :date_style, :string, values: ~w(full long medium short none), default: "medium"
attr :time_style, :string, values: ~w(full long medium short none), default: "short"
def local_time(assigns) do
assigns =
assigns
|> assign(:iso_value, DateTime.to_iso8601(assigns.value))
|> assign(:fallback, Calendar.strftime(assigns.value, "%Y-%m-%d %H:%M UTC"))
~H"""
<time
id={@id}
phx-hook="LocalTime"
datetime={@iso_value}
data-date-style={@date_style}
data-time-style={@time_style}
class={@class}
>
{@fallback}
</time>
"""
end
defp unused_input_name(name) do
case Regex.run(~r/^(.*)\[([^\[\]]+)\]$/, name, capture: :all_but_first) do
[prefix, field] -> "#{prefix}[_unused_#{field}]"

View File

@ -28,10 +28,4 @@ defmodule WhoNeedHelpWeb.SupportHTML do
def event_actor_label(:staff), do: gettext("Support team")
def event_actor_label(:system), do: gettext("System")
def event_actor_label(value), do: to_string(value)
def format_timestamp(%DateTime{} = value) do
Calendar.strftime(value, "%Y-%m-%d %H:%M UTC")
end
def format_timestamp(value), do: to_string(value)
end

View File

@ -13,7 +13,12 @@
<h2 id="support-case-details" class="font-bold">{gettext("Your original request")}</h2>
<p class="mt-2 whitespace-pre-wrap">{@request.details}</p>
<time class="mt-3 block text-xs text-base-content/60">
{format_timestamp(@request.inserted_at)}
<.local_time
id={"support-request-created-#{@request.id}"}
value={@request.inserted_at}
date_style="short"
time_style="short"
/>
</time>
</section>
@ -43,7 +48,12 @@
<div class="flex flex-wrap items-center justify-between gap-3">
<strong class="text-sm">{message_sender_label(message.sender_role)}</strong>
<time class="text-xs text-base-content/55">
{format_timestamp(message.inserted_at)}
<.local_time
id={"support-message-created-#{message.id}"}
value={message.inserted_at}
date_style="short"
time_style="short"
/>
</time>
</div>
<p class="mt-2 whitespace-pre-wrap">{message.body}</p>
@ -126,7 +136,13 @@
></span>
<p class="font-semibold">{status_label(event.to_status)}</p>
<p class="mt-0.5 text-xs text-base-content/60">
{event_actor_label(event.actor_role)} · {format_timestamp(event.inserted_at)}
{event_actor_label(event.actor_role)} ·
<.local_time
id={"support-event-created-#{event.id}"}
value={event.inserted_at}
date_style="short"
time_style="short"
/>
</p>
</li>
<li

View File

@ -256,7 +256,15 @@ defmodule WhoNeedHelpWeb.ActivityLive.Index do
</div>
<div class="mt-5 flex flex-wrap gap-x-5 gap-y-2 border-t border-base-300 pt-4 text-xs text-base-content/65">
<span>📍 {activity.location_label}</span>
<span>🕒 {Calendar.strftime(activity.starts_at, "%d.%m.%Y, %H:%M UTC")}</span>
<span class="inline-flex items-center gap-1">
<.icon name="hero-clock" class="size-4" />
<.local_time
id={"activity-start-#{activity.id}"}
value={activity.starts_at}
date_style="medium"
time_style="short"
/>
</span>
<span>
{gettext("by %{name}",
name: activity.creator.display_name || gettext("Community member")

View File

@ -377,13 +377,19 @@ defmodule WhoNeedHelpWeb.ActivityLive.Show do
<div>
<dt class="text-xs text-base-content/50">{gettext("Starts")}</dt>
<dd class="font-semibold">
{Calendar.strftime(@activity.starts_at, "%d.%m.%Y, %H:%M UTC")}
<.local_time
id={"activity-start-#{@activity.id}"}
value={@activity.starts_at}
/>
</dd>
</div>
<div>
<dt class="text-xs text-base-content/50">{gettext("Join requests close")}</dt>
<dd class="font-semibold">
{Calendar.strftime(@activity.join_deadline, "%d.%m.%Y, %H:%M UTC")}
<.local_time
id={"activity-join-deadline-#{@activity.id}"}
value={@activity.join_deadline}
/>
</dd>
</div>
<div>
@ -475,14 +481,11 @@ defmodule WhoNeedHelpWeb.ActivityLive.Show do
{message.sender.display_name || gettext("Participant")}
</.link>
</div>
<time
<.local_time
id={"activity-message-time-#{message.id}"}
phx-hook="LocalTime"
datetime={DateTime.to_iso8601(message.inserted_at)}
value={message.inserted_at}
class="ml-auto text-xs text-base-content/50"
>
{Calendar.strftime(message.inserted_at, "%d.%m.%Y, %H:%M UTC")}
</time>
/>
<button
:if={message.sender_id != @current_scope.user.id}
type="button"

View File

@ -4,7 +4,7 @@ defmodule WhoNeedHelpWeb.NotificationLive do
alias WhoNeedHelp.Catalog
alias WhoNeedHelp.Catalog.Category
alias WhoNeedHelp.Notifications
alias WhoNeedHelp.Notifications.NearbySubscription
alias WhoNeedHelp.Notifications.{NearbySubscription, Text}
@impl true
def mount(_params, _session, socket) do
@ -13,12 +13,24 @@ defmodule WhoNeedHelpWeb.NotificationLive do
{:ok,
socket
|> assign(:page_title, gettext("Notifications"))
|> assign(:section, "inbox")
|> assign(:categories, Catalog.list_categories())
|> assign(:web_push_public_key, Application.get_env(:who_need_help, :web_push_public_key))
|> assign(:subscription_form, new_subscription_form())
|> load_notification_state()}
end
@impl true
def handle_params(params, _uri, socket) do
section =
case params["section"] do
section when section in ~w(inbox alerts settings) -> section
_other -> "inbox"
end
{:noreply, assign(socket, :section, section)}
end
@impl true
def handle_info({:notification_created, _notification}, socket) do
{:noreply, load_notifications(socket)}
@ -196,8 +208,8 @@ defmodule WhoNeedHelpWeb.NotificationLive do
defp coordinate_value(form, key), do: Map.get(form.params, key, "")
defp format_time(datetime) do
Calendar.strftime(datetime, "%Y-%m-%d %H:%M UTC")
defp notification_text(notification) do
Text.render(notification, Gettext.get_locale(WhoNeedHelpWeb.Gettext))
end
@impl true
@ -218,7 +230,7 @@ defmodule WhoNeedHelpWeb.NotificationLive do
</p>
</div>
<button
:if={@unread_count > 0}
:if={@section == "inbox" && @unread_count > 0}
type="button"
phx-click="mark-all-read"
class="btn btn-outline btn-sm"
@ -227,7 +239,41 @@ defmodule WhoNeedHelpWeb.NotificationLive do
</button>
</header>
<section class="rounded-3xl border border-base-300 bg-base-100 p-5 sm:p-6">
<nav
class="grid grid-cols-3 gap-1 rounded-2xl border border-base-300 bg-base-200 p-1"
aria-label={gettext("Notification sections")}
>
<.link
:for={
{section, label, icon} <- [
{"inbox", gettext("Inbox"), "hero-inbox"},
{"alerts", gettext("Nearby alerts"), "hero-map-pin"},
{"settings", gettext("Delivery settings"), "hero-adjustments-horizontal"}
]
}
patch={~p"/notifications?section=#{section}"}
aria-current={if @section == section, do: "page", else: nil}
class={[
"flex min-w-0 items-center justify-center gap-1.5 rounded-xl px-2 py-3 text-sm font-bold transition sm:gap-2",
@section == section && "bg-base-100 text-primary shadow-sm",
@section != section && "text-base-content/65 hover:bg-base-100/70"
]}
>
<.icon name={icon} class="size-5 shrink-0" />
<span class="truncate">{label}</span>
<span
:if={section == "inbox" && @unread_count > 0}
class="badge badge-primary badge-xs shrink-0"
>
{@unread_count}
</span>
</.link>
</nav>
<section
:if={@section == "inbox"}
class="rounded-3xl border border-base-300 bg-base-100 p-5 sm:p-6"
>
<div class="flex items-center justify-between gap-4">
<h2 class="text-xl font-black">{gettext("Inbox")}</h2>
<span class="badge badge-success badge-outline">
@ -260,13 +306,18 @@ defmodule WhoNeedHelpWeb.NotificationLive do
notification.read_at && "bg-transparent"
]} />
<span class="min-w-0 flex-1">
<span class="block">{notification.title}</span>
<% text = notification_text(notification) %>
<span class="block">{text.title}</span>
<span class="mt-1 block text-sm font-normal text-base-content/65">
{notification.body}
</span>
<span class="mt-2 block text-xs font-normal text-base-content/45">
{format_time(notification.inserted_at)}
{text.body}
</span>
<.local_time
id={"notification-time-#{notification.id}"}
value={notification.inserted_at}
date_style="short"
time_style="short"
class="mt-2 block text-xs font-normal text-base-content/45"
/>
</span>
<.icon name="hero-chevron-right" class="mt-2 size-4 shrink-0 opacity-45" />
</button>
@ -282,12 +333,12 @@ defmodule WhoNeedHelpWeb.NotificationLive do
</button>
</section>
<section class="grid gap-6 lg:grid-cols-[.9fr_1.1fr]">
<section :if={@section == "settings"}>
<div
id="push-device-registration"
phx-hook="PushNotifications"
data-vapid-public-key={@web_push_public_key || ""}
class="rounded-3xl border border-base-300 p-5 sm:p-6"
class="rounded-3xl border border-base-300 bg-base-100 p-5 sm:p-6"
>
<h2 class="text-xl font-black">{gettext("Delivery channels and quiet hours")}</h2>
<p class="mt-2 text-sm text-base-content/60">
@ -328,7 +379,13 @@ defmodule WhoNeedHelpWeb.NotificationLive do
<p class="truncate text-sm font-bold">
{device.device_label || gettext("Web browser")}
</p>
<p class="text-xs text-base-content/55">{format_time(device.last_seen_at)}</p>
<.local_time
id={"push-device-last-seen-#{device.id}"}
value={device.last_seen_at}
date_style="short"
time_style="short"
class="text-xs text-base-content/55"
/>
</div>
<button
type="button"
@ -404,8 +461,12 @@ defmodule WhoNeedHelpWeb.NotificationLive do
<.button class="btn btn-primary w-full">{gettext("Save preferences")}</.button>
</.form>
</div>
</section>
<div class="rounded-3xl border border-base-300 p-5 sm:p-6">
<section
:if={@section == "alerts"}
class="rounded-3xl border border-base-300 bg-base-100 p-5 sm:p-6"
>
<h2 class="text-xl font-black">{gettext("Your nearby alerts")}</h2>
<p class="mt-2 text-sm text-base-content/60">
{gettext("The saved center is private and is used only to find matching requests.")}
@ -465,10 +526,12 @@ defmodule WhoNeedHelpWeb.NotificationLive do
</div>
</div>
</div>
</div>
</section>
<section class="rounded-3xl border border-base-300 p-5 sm:p-6">
<section
:if={@section == "alerts"}
class="rounded-3xl border border-base-300 bg-base-100 p-5 sm:p-6"
>
<h2 class="text-2xl font-black">{gettext("Create a nearby alert")}</h2>
<p class="mt-2 text-sm text-base-content/60">
{gettext("Choose an area, categories, urgency, and the times when you are available.")}

View File

@ -69,9 +69,13 @@ defmodule WhoNeedHelpWeb.ReportLive do
<h2 class="text-sm font-bold">{gettext("Moderation response")}</h2>
<p class="mt-1 whitespace-pre-wrap text-sm">{report.resolution_note}</p>
</section>
<time class="mt-4 block text-xs text-base-content/55">
{format_timestamp(report.inserted_at)}
</time>
<.local_time
id={"report-created-#{report.id}"}
value={report.inserted_at}
date_style="short"
time_style="short"
class="mt-4 block text-xs text-base-content/55"
/>
</article>
<div
@ -121,9 +125,4 @@ defmodule WhoNeedHelpWeb.ReportLive do
do: gettext("Activity message")
defp target_label(_report), do: gettext("Reported content")
defp format_timestamp(%DateTime{} = value),
do: Calendar.strftime(value, "%Y-%m-%d %H:%M UTC")
defp format_timestamp(value), do: to_string(value)
end

View File

@ -474,10 +474,14 @@ defmodule WhoNeedHelpWeb.RequestLive.Index do
name: request.requester.display_name || gettext("Community member")
)}
</span>
<span>
{gettext("expires %{time}",
time: Calendar.strftime(request.expires_at, "%d.%m.%Y, %H:%M UTC")
)}
<span class="inline-flex items-center gap-1">
{gettext("Expires")}
<.local_time
id={"request-expiry-#{request.id}"}
value={request.expires_at}
date_style="short"
time_style="short"
/>
</span>
</div>
</.link>

View File

@ -905,11 +905,8 @@ defmodule WhoNeedHelpWeb.RequestLive.Show do
exact: true,
type: "live",
title: live_location_title(user_id, request, assignment),
location:
gettext(
"Last update: %{time}",
time: Calendar.strftime(point.captured_at, "%d.%m.%Y, %H:%M:%S UTC")
)
location_prefix: gettext("Last update:"),
captured_at: DateTime.to_iso8601(point.captured_at)
}
end)
end
@ -1073,10 +1070,7 @@ defmodule WhoNeedHelpWeb.RequestLive.Show do
</div>
<div>
<span class="text-base-content/50">{gettext("Expires:")}</span>
{Calendar.strftime(
@request.expires_at,
"%d.%m.%Y, %H:%M UTC"
)}
<.local_time id={"request-expiry-#{@request.id}"} value={@request.expires_at} />
</div>
<div>
<span class="text-base-content/50">{gettext("Location:")}</span>
@ -1168,13 +1162,12 @@ defmodule WhoNeedHelpWeb.RequestLive.Show do
>
<div class="chat-header flex items-center gap-2 text-xs">
<span>{message.sender.display_name}</span>
<time
<.local_time
id={"message-time-#{message.id}"}
phx-hook="LocalTime"
datetime={DateTime.to_iso8601(message.inserted_at)}
>
{Calendar.strftime(message.inserted_at, "%d.%m.%Y, %H:%M UTC")}
</time>
value={message.inserted_at}
date_style="short"
time_style="short"
/>
</div>
<div class="chat-bubble whitespace-pre-wrap break-words">{message.body}</div>
<div

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,60 @@
defmodule WhoNeedHelp.Notifications.TextTest do
use ExUnit.Case, async: true
alias WhoNeedHelp.Notifications.{Notification, Text}
test "renders known lifecycle events in the selected locale" do
notification = notification(:request_accepted)
assert Text.render(notification, "en") == %{
title: "A helper responded",
body: "Open Who Need Help to see the request update.",
locale: "en"
}
assert Text.render(notification, "ru") == %{
title: "Помощник откликнулся",
body: "Откройте Who Need Help, чтобы посмотреть обновление заявки.",
locale: "ru"
}
assert Text.render(notification, "uk") == %{
title: "Помічник відгукнувся",
body: "Відкрийте Who Need Help, щоб переглянути оновлення запиту.",
locale: "uk"
}
end
test "renders structured variants without relying on stored English copy" do
notification =
notification(:request_cancelled, %{
"event_variant" => "helper_withdrew_after_expiry"
})
assert Text.render(notification, "ru").title == "Заявка закрыта после отказа помощника"
assert Text.render(notification, "uk").body ==
"Помічник відмовився після завершення строку запиту, тому його не відкрито повторно."
end
test "localizes structured moderation status and falls back to English locale" do
notification =
notification(:support_update, %{
"kind" => "report_updated",
"status" => "reviewing"
})
assert Text.render(notification, "ru").body == "Текущий статус модерации: На рассмотрении."
assert Text.render(notification, "uk").body == "Поточний статус модерації: На розгляді."
assert Text.render(notification, "unsupported").locale == "en"
end
defp notification(kind, data \\ %{}) do
%Notification{
kind: kind,
title: "Stored fallback title",
body: "Stored fallback body",
data: data
}
end
end

View File

@ -82,15 +82,22 @@ defmodule WhoNeedHelpWeb.MutualAidLiveTest do
assert html =~ "Notifications"
assert html =~ "Support request updated"
assert html =~ "1 unread"
assert html =~ ~s(data-enable-label="Enable push on this device")
assert html =~ ~s(data-enabled-label="Push enabled on this device")
assert has_element?(view, "[data-disable-device='#{push_device.id}']")
view |> element("button[phx-click='mark-all-read']") |> render_click()
assert render(view) =~ "0 unread"
assert Notifications.unread_count(scope) == 0
assert Repo.get!(WhoNeedHelp.Notifications.Notification, notification.id).read_at
view
|> element("a[href='/notifications?section=settings']")
|> render_click()
assert_patch(view, ~p"/notifications?section=settings")
html = render(view)
assert html =~ ~s(data-enable-label="Enable push on this device")
assert html =~ ~s(data-enabled-label="Push enabled on this device")
assert has_element?(view, "[data-disable-device='#{push_device.id}']")
view
|> form("#notification-preferences-form", %{
"notification_preference" => %{
@ -111,6 +118,12 @@ defmodule WhoNeedHelpWeb.MutualAidLiveTest do
assert render(view) =~ "Notification preferences saved."
view
|> element("a[href='/notifications?section=alerts']")
|> render_click()
assert_patch(view, ~p"/notifications?section=alerts")
view
|> form("#nearby-subscription-form", %{
"nearby_subscription" => %{