Add build timestamp to footer with shared time formatting helpers

- Add compile-time @build_timestamp to Application module
- Create ToweropsWeb.TimeHelpers module for shared time formatting
- Update footer to display 'Last deployed Xm ago · YYYY-MM-DD HH:MM:SS UTC'
- Refactor AgentLive.Helpers to delegate to shared TimeHelpers
- Consolidate duplicate time formatting logic into single module
- Timestamp updates automatically on each build/deploy
This commit is contained in:
Graham McIntire 2026-01-17 11:10:50 -06:00
parent 654fcb541c
commit ae16acf016
No known key found for this signature in database
4 changed files with 144 additions and 26 deletions

View file

@ -5,6 +5,9 @@ defmodule Towerops.Application do
use Application
# Capture the build/deploy timestamp at compile time
@build_timestamp DateTime.utc_now()
@impl true
def start(_type, _args) do
# Run migrations on startup (Ecto handles locking for concurrent runs)
@ -44,4 +47,13 @@ defmodule Towerops.Application do
ToweropsWeb.Endpoint.config_change(changed, removed)
:ok
end
@doc """
Returns the build/deploy timestamp.
This timestamp is captured at compile time and represents when the
application was last built/deployed.
"""
@spec build_timestamp() :: DateTime.t()
def build_timestamp, do: @build_timestamp
end

View file

@ -411,15 +411,28 @@ defmodule ToweropsWeb.Layouts do
end
@doc """
Renders the application footer with copyright notice.
Renders the application footer with copyright notice and deploy timestamp.
"""
def footer(assigns) do
alias ToweropsWeb.TimeHelpers
build_time = Towerops.Application.build_timestamp()
assigns =
assigns
|> assign(:build_time, build_time)
|> assign(:time_ago, TimeHelpers.format_time_ago(build_time))
|> assign(:formatted_time, TimeHelpers.format_iso8601(build_time))
~H"""
<footer class="border-t border-zinc-200 bg-white dark:border-zinc-800 dark:bg-zinc-900">
<div class="mx-auto max-w-7xl px-4 py-6 sm:px-6 lg:px-8">
<p class="text-center text-sm text-zinc-600 dark:text-zinc-400">
Copyright © {Date.utc_today().year} Towerops
</p>
<div class="flex flex-col items-center gap-2 text-sm text-zinc-600 dark:text-zinc-400">
<p>Copyright © {Date.utc_today().year} Towerops</p>
<p class="text-xs">
Last deployed {@time_ago} · {@formatted_time}
</p>
</div>
</div>
</footer>
"""

View file

@ -0,0 +1,106 @@
defmodule ToweropsWeb.TimeHelpers do
@moduledoc """
Helper functions for formatting time and date values.
"""
@doc """
Formats a DateTime into a human-readable "time ago" string.
Supports short time periods (seconds, minutes, hours, days) and
long time periods (months, years) for things like build timestamps.
## Examples
iex> datetime = DateTime.add(DateTime.utc_now(), -65, :second)
iex> ToweropsWeb.TimeHelpers.format_time_ago(datetime)
"1m ago"
iex> datetime = DateTime.add(DateTime.utc_now(), -7200, :second)
iex> ToweropsWeb.TimeHelpers.format_time_ago(datetime)
"2h ago"
"""
@spec format_time_ago(DateTime.t() | nil) :: String.t()
def format_time_ago(nil), do: "Never"
def format_time_ago(datetime) do
now = DateTime.utc_now()
diff = DateTime.diff(now, datetime, :second)
cond do
diff < 60 ->
"#{diff}s ago"
diff < 3600 ->
minutes = div(diff, 60)
"#{minutes}m ago"
diff < 86_400 ->
hours = div(diff, 3600)
"#{hours}h ago"
diff < 2_592_000 ->
days = div(diff, 86_400)
"#{days}d ago"
diff < 31_536_000 ->
months = div(diff, 2_592_000)
"#{months}mo ago"
true ->
years = div(diff, 31_536_000)
"#{years}y ago"
end
end
@doc """
Formats a DateTime into a full date and time string.
## Examples
iex> datetime = ~U[2026-01-15 14:34:00Z]
iex> ToweropsWeb.TimeHelpers.format_datetime(datetime)
"Jan 15, 2026 at 02:34 PM UTC"
"""
@spec format_datetime(DateTime.t() | nil) :: String.t()
def format_datetime(nil), do: "Never"
def format_datetime(datetime) do
Calendar.strftime(datetime, "%b %d, %Y at %I:%M %p %Z")
end
@doc """
Formats a DateTime into a short date string.
## Examples
iex> datetime = ~U[2026-01-15 14:34:00Z]
iex> ToweropsWeb.TimeHelpers.format_date(datetime)
"Jan 15, 2026"
"""
@spec format_date(DateTime.t() | nil) :: String.t()
def format_date(nil), do: "Never"
def format_date(datetime) do
Calendar.strftime(datetime, "%b %d, %Y")
end
@doc """
Formats a DateTime into ISO 8601 format with UTC timezone.
## Examples
iex> datetime = ~U[2026-01-15 14:34:00Z]
iex> ToweropsWeb.TimeHelpers.format_iso8601(datetime)
"2026-01-15 14:34:00 UTC"
"""
@spec format_iso8601(DateTime.t() | nil) :: String.t()
def format_iso8601(nil), do: "Never"
def format_iso8601(datetime) do
Calendar.strftime(datetime, "%Y-%m-%d %H:%M:%S UTC")
end
end

View file

@ -57,19 +57,10 @@ defmodule ToweropsWeb.AgentLive.Helpers do
Formats a DateTime into a human-readable "time ago" string.
Returns strings like "5s ago", "3m ago", "2h ago", or "5d ago".
Delegates to ToweropsWeb.TimeHelpers.format_time_ago/1.
"""
def format_last_seen(nil), do: "Never"
def format_last_seen(datetime) do
seconds_ago = DateTime.diff(DateTime.utc_now(), datetime, :second)
cond do
seconds_ago < 60 -> "#{seconds_ago}s ago"
seconds_ago < 3600 -> "#{div(seconds_ago, 60)}m ago"
seconds_ago < 86_400 -> "#{div(seconds_ago, 3600)}h ago"
true -> "#{div(seconds_ago, 86_400)}d ago"
end
end
defdelegate format_last_seen(datetime), to: ToweropsWeb.TimeHelpers, as: :format_time_ago
@doc """
Formats uptime in seconds to a human-readable string.
@ -94,23 +85,19 @@ defmodule ToweropsWeb.AgentLive.Helpers do
Formats a DateTime into a full date and time string.
Returns strings like "Jan 15, 2026 at 2:34 PM UTC".
"""
def format_datetime(nil), do: "Never"
def format_datetime(datetime) do
Calendar.strftime(datetime, "%b %d, %Y at %I:%M %p %Z")
end
Delegates to ToweropsWeb.TimeHelpers.format_datetime/1.
"""
defdelegate format_datetime(datetime), to: ToweropsWeb.TimeHelpers
@doc """
Formats a DateTime into a short date string.
Returns strings like "Jan 15, 2026".
"""
def format_date(nil), do: "Never"
def format_date(datetime) do
Calendar.strftime(datetime, "%b %d, %Y")
end
Delegates to ToweropsWeb.TimeHelpers.format_date/1.
"""
defdelegate format_date(datetime), to: ToweropsWeb.TimeHelpers
@doc """
Formats a DateTime into a relative time with full datetime in parentheses.