weather page

This commit is contained in:
Graham McIntire 2025-06-22 09:06:27 -05:00
parent 46d34a7c5e
commit 51efdf34a2
No known key found for this signature in database
18 changed files with 173 additions and 133 deletions

View file

@ -393,26 +393,29 @@ defmodule AprsWeb.CoreComponents do
end
@doc """
Renders a header with title.
Renders a modern site header with navigation links.
"""
attr :class, :string, default: nil
slot :inner_block, required: true
slot :subtitle
slot :actions
attr :class, :string, default: ""
def header(assigns) do
~H"""
<header class={[@actions != [] && "flex items-center justify-between gap-6", @class]}>
<div>
<h1 class="text-lg font-semibold leading-8 text-zinc-800">
{render_slot(@inner_block)}
</h1>
<p :if={@subtitle != []} class="mt-2 text-sm leading-6 text-zinc-600">
{render_slot(@subtitle)}
</p>
</div>
<div class="flex-none">{render_slot(@actions)}</div>
<header class={["bg-white shadow sticky top-0 z-40", @class]}>
<nav
class="mx-auto max-w-7xl px-4 sm:px-6 lg:px-8 flex h-16 items-center justify-between"
aria-label="Main"
>
<div class="flex items-center space-x-4">
<a href="/" class="text-xl font-bold text-blue-700 hover:text-blue-900 transition-colors">
aprs.me
</a>
</div>
<div class="flex items-center space-x-6">
<a href="/" class="text-gray-700 hover:text-blue-700 font-medium transition-colors">Home</a>
<a href="/about" class="text-gray-700 hover:text-blue-700 font-medium transition-colors">
About
</a>
</div>
</nav>
</header>
"""
end

View file

@ -1,3 +1,4 @@
<.header />
<main class="min-h-screen bg-white">
<div>
<.flash kind={:info} title="Success!" flash={@flash} />

View file

@ -0,0 +1,9 @@
defmodule AprsWeb.AboutLive do
@moduledoc false
use AprsWeb, :live_view
@impl true
def mount(_params, _session, socket) do
{:ok, assign(socket, page_title: "About aprs.me")}
end
end

View file

View file

@ -1,74 +1,6 @@
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 my-8">
<div class="mb-8">
<.header>
<div class="flex items-center justify-between">
<div>
<h1 class="text-2xl font-semibold text-gray-900">
Bad APRS Packets
<span class="ml-2 text-base font-normal text-gray-500">
(Showing {length(@bad_packets)} most recent)
</span>
</h1>
<p class="mt-1 text-sm text-gray-500">
Packets that failed to parse or process (limited to 100 most recent)
<%= if @last_updated do %>
<span class="text-xs text-gray-400 ml-2">
Last updated: {Calendar.strftime(@last_updated, "%H:%M:%S UTC")}
</span>
<% end %>
</p>
</div>
<div class="flex items-center space-x-4">
<button
phx-click="refresh"
class="inline-flex items-center px-3 py-2 border border-transparent text-sm leading-4 font-medium rounded-md text-white bg-blue-600 hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500 disabled:opacity-50 disabled:cursor-not-allowed"
disabled={@loading}
>
<%= if @loading do %>
<svg
class="animate-spin -ml-1 mr-2 h-4 w-4 text-white"
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
>
<circle
class="opacity-25"
cx="12"
cy="12"
r="10"
stroke="currentColor"
stroke-width="4"
>
</circle>
<path
class="opacity-75"
fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
>
</path>
</svg>
Refreshing...
<% else %>
<svg class="w-4 h-4 mr-1.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"
/>
</svg>
Refresh
<% end %>
</button>
<.link navigate={~p"/packets"} class="text-sm text-blue-600 hover:text-blue-800">
View Good Packets →
</.link>
<.link navigate={~p"/"} class="text-sm text-blue-600 hover:text-blue-800">
← Back to Map
</.link>
</div>
</div>
</.header>
<.header />
</div>
<div class="mt-6 bg-white shadow-sm rounded-lg overflow-hidden">

View file

@ -52,7 +52,7 @@ defmodule AprsWeb.MapLive.Index do
overlay_callsign: "",
trail_duration: "1",
historical_hours: "1",
packet_age_threshold: 3600,
packet_age_threshold: one_hour_ago,
slideover_open: true,
replay_active: false,
replay_start_time: nil,
@ -1016,7 +1016,26 @@ defmodule AprsWeb.MapLive.Index do
defp packet_within_time_threshold?(packet, threshold) do
case packet do
%{received_at: received_at} when not is_nil(received_at) ->
DateTime.compare(received_at, threshold) in [:gt, :eq]
threshold_dt =
cond do
is_integer(threshold) ->
# Assume seconds since epoch
DateTime.from_unix!(threshold)
is_binary(threshold) ->
case DateTime.from_iso8601(threshold) do
{:ok, dt, _} -> dt
_ -> DateTime.utc_now()
end
match?(%DateTime{}, threshold) ->
threshold
true ->
DateTime.utc_now()
end
DateTime.compare(received_at, threshold_dt) in [:gt, :eq]
_ ->
# If no timestamp, treat as current
@ -1381,7 +1400,7 @@ defmodule AprsWeb.MapLive.Index do
<div class="aprs-callsign"><strong><a href="/map/callsign/#{packet_info.callsign}">#{packet_info.callsign}</a></strong></div>
#{comment_html}
<div class="aprs-coords">#{Float.round(PacketUtils.to_float(lat), 4)}, #{Float.round(PacketUtils.to_float(lon), 4)}</div>
<div class="aprs-time">#{packet_info.timestamp}</div>
<div class="aprs-time">#{format_popup_timestamp(packet_info.timestamp)}</div>
</div>
"""
end
@ -1408,11 +1427,11 @@ defmodule AprsWeb.MapLive.Index do
defp build_weather_popup_html(packet, callsign) do
received_at = get_received_at(packet)
timestamp_str = format_timestamp(received_at)
timestamp_str = format_popup_timestamp(received_at)
"""
<strong>#{callsign} - Weather Report</strong><br>
<small>#{timestamp_str} UTC</small>
<small>#{timestamp_str}</small>
<hr>
Temperature: #{PacketUtils.get_weather_field(packet, :temperature)}°F<br>
Humidity: #{PacketUtils.get_weather_field(packet, :humidity)}%<br>
@ -1432,10 +1451,30 @@ defmodule AprsWeb.MapLive.Index do
end
end
defp format_timestamp(received_at) do
if received_at,
do: Calendar.strftime(received_at, "%Y-%m-%d %H:%M:%S"),
else: "N/A"
defp format_popup_timestamp(ts) do
cond do
is_binary(ts) ->
case DateTime.from_iso8601(ts) do
{:ok, dt, _} -> Calendar.strftime(dt, "%Y-%m-%d %H:%M:%S UTC")
_ -> ts
end
is_integer(ts) ->
ts
|> DateTime.from_unix!(:millisecond)
|> Calendar.strftime("%Y-%m-%d %H:%M:%S UTC")
match?(%DateTime{}, ts) ->
Calendar.strftime(ts, "%Y-%m-%d %H:%M:%S UTC")
match?(%NaiveDateTime{}, ts) ->
ts
|> DateTime.from_naive!("Etc/UTC")
|> Calendar.strftime("%Y-%m-%d %H:%M:%S UTC")
true ->
to_string(ts)
end
end
@spec generate_callsign(map() | struct()) :: String.t()

View file

@ -1,23 +1,6 @@
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 my-8">
<div class="mb-8">
<.header>
<div class="flex items-center justify-between">
<div>
<h1 class="text-2xl font-semibold text-gray-900">Packets for {@callsign}</h1>
<p class="mt-1 text-sm text-gray-500">
Showing up to 100 packets (stored and live) for callsign {@callsign}
</p>
</div>
<div class="flex items-center space-x-4">
<.link navigate={~p"/"} class="text-sm text-blue-600 hover:text-blue-800">
← Back to Map
</.link>
<.link navigate={~p"/packets"} class="text-sm text-blue-600 hover:text-blue-800">
All Packets
</.link>
</div>
</div>
</.header>
<.header />
</div>
<%= if @error do %>

View file

@ -1,21 +1,6 @@
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 my-8">
<div class="mb-8">
<.header>
<div class="flex items-center justify-between">
<div>
<h1 class="text-2xl font-semibold text-gray-900">APRS Packets</h1>
<p class="mt-1 text-sm text-gray-500">Live and recent APRS packets from the network</p>
</div>
<div class="flex items-center space-x-4">
<.link navigate={~p"/badpackets"} class="text-sm text-blue-600 hover:text-blue-800">
View Bad Packets →
</.link>
<.link navigate={~p"/"} class="text-sm text-blue-600 hover:text-blue-800">
← Back to Map
</.link>
</div>
</div>
</.header>
<.header />
</div>
<div class="mt-6 bg-white shadow-sm rounded-lg overflow-hidden">

View file

@ -6,7 +6,7 @@ defmodule AprsWeb.UserConfirmationInstructionsLive do
def render(assigns) do
~H"""
<.header>Resend confirmation instructions</.header>
<.header />
<.simple_form
:let={f}

View file

@ -7,7 +7,7 @@ defmodule AprsWeb.UserConfirmationLive do
def render(%{live_action: :edit} = assigns) do
~H"""
<div class="mx-auto max-w-sm">
<.header class="text-center">Confirm Account</.header>
<.header class="text-center" />
<.simple_form :let={f} for={%{}} as={:user} id="confirmation_form" phx-submit="confirm_account">
<.input field={{f, :token}} type="hidden" value={@token} />

View file

@ -7,6 +7,8 @@ defmodule AprsWeb.UserForgotPasswordLive do
def render(assigns) do
~H"""
<div class="mx-auto max-w-sm">
<.header class="text-center" />
<.header class="text-center">
Forgot your password?
<:subtitle>We'll send a password reset link to your inbox</:subtitle>

View file

@ -8,6 +8,8 @@ defmodule AprsWeb.UserLoginLive do
def render(assigns) do
~H"""
<div class="mx-auto max-w-sm">
<.header class="text-center" />
<.header class="text-center">
Sign in to account
<:subtitle>

View file

@ -8,6 +8,8 @@ defmodule AprsWeb.UserRegistrationLive do
def render(assigns) do
~H"""
<div class="mx-auto max-w-sm">
<.header class="text-center" />
<.header class="text-center">
Register for an account
<:subtitle>

View file

@ -7,7 +7,7 @@ defmodule AprsWeb.UserResetPasswordLive do
def render(assigns) do
~H"""
<div class="mx-auto max-w-sm">
<.header class="text-center">Reset Password</.header>
<.header class="text-center" />
<.simple_form
:let={f}

View file

@ -6,7 +6,7 @@ defmodule AprsWeb.UserSettingsLive do
def render(assigns) do
~H"""
<.header>Change Email</.header>
<.header />
<.simple_form
:let={f}
@ -35,7 +35,7 @@ defmodule AprsWeb.UserSettingsLive do
</:actions>
</.simple_form>
<.header>Change Password</.header>
<.header />
<.simple_form
:let={f}

View file

@ -0,0 +1,27 @@
defmodule AprsWeb.WeatherLive.CallsignView do
use AprsWeb, :live_view
alias Aprs.Packets
alias AprsWeb.MapLive.PacketUtils
@impl true
def mount(%{"callsign" => callsign}, _session, socket) do
normalized_callsign = String.upcase(String.trim(callsign))
weather_packet = get_latest_weather_packet(normalized_callsign)
socket =
socket
|> assign(:callsign, normalized_callsign)
|> assign(:weather_packet, weather_packet)
|> assign(:page_title, "Weather for #{normalized_callsign}")
{:ok, socket}
end
defp get_latest_weather_packet(callsign) do
# Get the most recent packet for this callsign that is a weather report
%{callsign: callsign, limit: 10}
|> Packets.get_recent_packets()
|> Enum.find(&PacketUtils.is_weather_packet?/1)
end
end

View file

@ -0,0 +1,53 @@
<%= if @weather_packet do %>
<div class="max-w-lg mx-auto mt-8 p-6 bg-white rounded shadow">
<h1 class="text-2xl font-bold mb-2">Weather for {@callsign}</h1>
<div class="text-gray-600 mb-4">
{PacketUtils.get_timestamp(@weather_packet)}
</div>
<div class="grid grid-cols-2 gap-4">
<div>
<strong>Temperature:</strong>
{PacketUtils.get_weather_field(@weather_packet, :temperature)}°F
</div>
<div>
<strong>Humidity:</strong>
{PacketUtils.get_weather_field(@weather_packet, :humidity)}%
</div>
<div>
<strong>Wind:</strong>
{PacketUtils.get_weather_field(@weather_packet, :wind_direction)}° @ {PacketUtils.get_weather_field(
@weather_packet,
:wind_speed
)} mph
</div>
<div>
<strong>Gusts:</strong>
{PacketUtils.get_weather_field(@weather_packet, :wind_gust)} mph
</div>
<div>
<strong>Pressure:</strong>
{PacketUtils.get_weather_field(@weather_packet, :pressure)} hPa
</div>
<div>
<strong>Rain (1h):</strong>
{PacketUtils.get_weather_field(@weather_packet, :rain_1h)} in
</div>
<div>
<strong>Rain (24h):</strong>
{PacketUtils.get_weather_field(@weather_packet, :rain_24h)} in
</div>
<div>
<strong>Rain (since midnight):</strong>
{PacketUtils.get_weather_field(@weather_packet, :rain_since_midnight)} in
</div>
</div>
<div class="mt-4 text-sm text-gray-500">
<strong>Raw comment:</strong> {@weather_packet.comment || @weather_packet["comment"]}
</div>
</div>
<% else %>
<div class="max-w-lg mx-auto mt-8 p-6 bg-white rounded shadow text-center">
<h1 class="text-2xl font-bold mb-2">Weather for {@callsign}</h1>
<div class="text-gray-600">No recent weather data available for this callsign.</div>
</div>
<% end %>

View file

@ -40,6 +40,8 @@ defmodule AprsWeb.Router do
live "/packets", PacketsLive.Index, :index
live "/packets/:callsign", PacketsLive.CallsignView, :index
live "/badpackets", BadPacketsLive.Index, :index
live "/weather/:callsign", WeatherLive.CallsignView, :index
live "/about", AboutLive, :index
end
end