Use daisyUI components for stats, alerts, cards, and tabs

- about: stat cards → daisyUI stat, warning/donate boxes → alert
- submit: tab buttons → daisyUI tabs-box, info/instruction boxes → alert
- backfill: enrichment/DB stat boxes → daisyUI stat
- beacon show: stats container → daisyUI card
- contact show: info/status boxes → alert, data source cards → card
- ADIF: strip APP_ prefix from app-defined fields, normalize grid case
This commit is contained in:
Graham McIntire 2026-04-12 09:20:13 -05:00
parent 2d3da359ee
commit d5d0c32745
7 changed files with 345 additions and 223 deletions

View file

@ -69,12 +69,20 @@ defmodule Microwaveprop.Radio.AdifImport do
# -- ADIF parser -----------------------------------------------------------
# Header-only ADIF tags that never appear inside QSO records
@header_only_tags ~w(ADIF_VER PROGRAMID PROGRAMVERSION CREATED_TIMESTAMP)
defp parse_adif(string) do
# Strip header if present (everything before <EOH>)
body =
case Regex.run(~r/<EOH>/i, string, return: :index) do
[{pos, len}] -> String.slice(string, (pos + len)..-1//1)
nil -> string
[{pos, len}] ->
String.slice(string, (pos + len)..-1//1)
nil ->
# No <EOH> — strip header-only tags (ADIF_VER, PROGRAMID, etc.)
# and any preceding plain text so they don't leak into the first record.
strip_header_tags(string)
end
# Split on <EOR> to get individual records
@ -89,7 +97,8 @@ defmodule Microwaveprop.Radio.AdifImport do
|> Regex.scan(record_string, return: :index)
|> Enum.reduce(%{}, fn indices, acc ->
[{tag_start, _tag_len}, {name_start, name_len}, {size_start, size_len} | _] = indices
name = record_string |> String.slice(name_start, name_len) |> String.upcase()
raw_name = record_string |> String.slice(name_start, name_len) |> String.upcase()
name = strip_app_prefix(raw_name)
size = record_string |> String.slice(size_start, size_len) |> String.to_integer()
# Value starts right after the closing >
@ -98,17 +107,43 @@ defmodule Microwaveprop.Radio.AdifImport do
value_offset = tag_start + (tag_text |> String.split(">", parts: 2) |> List.first() |> byte_size()) + 1
value = String.slice(record_string, value_offset, size)
Map.put(acc, name, value)
# Standard ADIF fields take precedence over app-defined ones
if raw_name != name and Map.has_key?(acc, name) do
acc
else
Map.put(acc, name, value)
end
end)
end
# Strip APP_PROGRAMNAME_ prefix so app-defined fields resolve to standard names.
# e.g. APP_N1MM_MYGRID → MYGRID, APP_LOGGER32_CALL → CALL
defp strip_app_prefix("APP_" <> rest) do
case String.split(rest, "_", parts: 2) do
[_program, field] -> field
_ -> "APP_" <> rest
end
end
defp strip_app_prefix(name), do: name
# Strip known header-only tags and any plain text before the first record tag.
defp strip_header_tags(string) do
Enum.reduce(@header_only_tags, string, fn tag, acc ->
Regex.replace(~r/<#{tag}:\d+(?::[^>]*)?>.*?(?=<)/is, acc, "")
end)
end
defp normalize_grid(nil), do: nil
defp normalize_grid(g), do: g |> String.trim() |> String.upcase()
# -- row building ----------------------------------------------------------
defp build_row({fields, row_num}, submitter_email) do
station1 = fields["STATION_CALLSIGN"] || fields["OPERATOR"]
station2 = fields["CALL"]
grid1 = fields["MY_GRIDSQUARE"]
grid2 = fields["GRIDSQUARE"]
grid1 = normalize_grid(fields["MY_GRIDSQUARE"])
grid2 = normalize_grid(fields["GRIDSQUARE"])
mode = fields["MODE"]
band = resolve_band(fields)
timestamp = parse_adif_datetime(fields["QSO_DATE"], fields["TIME_ON"])

View file

@ -39,7 +39,7 @@ defmodule MicrowavepropWeb.AboutLive do
band, based on real atmospheric conditions and what we've learned
from the contacts you and everyone else have already made.
</p>
<div class="rounded-box border border-warning/30 bg-warning/10 p-3">
<div role="alert" class="alert alert-warning">
<p>
<strong>Heads up:</strong>
the scoring you see is hand-calibrated from the data we have so far.
@ -95,16 +95,46 @@ defmodule MicrowavepropWeb.AboutLive do
<h2 class="text-xl font-bold">What we've collected so far</h2>
<p>Straight from the production database:</p>
<div class="grid grid-cols-2 md:grid-cols-3 gap-3">
<.stat_card label="Contacts" value={@stats.contacts} />
<.stat_card label="Weather stations" value={@stats.weather_stations} />
<.stat_card label="Surface observations" value={@stats.surface_observations} />
<.stat_card label="Upper-air soundings" value={@stats.soundings} />
<.stat_card label="HRRR profiles" value={@stats.hrrr_profiles} />
<.stat_card label="IEMRE gridded obs" value={@stats.iemre_observations} />
<.stat_card label="Terrain profiles" value={@stats.terrain_profiles} />
<.stat_card label="Propagation scores" value={@stats.propagation_scores} />
<.stat_card label="Beacons" value={@stats.beacons} />
<.stat_card label="Commercial link samples" value={@stats.commercial_samples} />
<div class="stat bg-base-200 rounded-box">
<div class="stat-title">Contacts</div>
<div class="stat-value text-xl">{format_count(@stats.contacts)}</div>
</div>
<div class="stat bg-base-200 rounded-box">
<div class="stat-title">Weather stations</div>
<div class="stat-value text-xl">{format_count(@stats.weather_stations)}</div>
</div>
<div class="stat bg-base-200 rounded-box">
<div class="stat-title">Surface observations</div>
<div class="stat-value text-xl">{format_count(@stats.surface_observations)}</div>
</div>
<div class="stat bg-base-200 rounded-box">
<div class="stat-title">Upper-air soundings</div>
<div class="stat-value text-xl">{format_count(@stats.soundings)}</div>
</div>
<div class="stat bg-base-200 rounded-box">
<div class="stat-title">HRRR profiles</div>
<div class="stat-value text-xl">{format_count(@stats.hrrr_profiles)}</div>
</div>
<div class="stat bg-base-200 rounded-box">
<div class="stat-title">IEMRE gridded obs</div>
<div class="stat-value text-xl">{format_count(@stats.iemre_observations)}</div>
</div>
<div class="stat bg-base-200 rounded-box">
<div class="stat-title">Terrain profiles</div>
<div class="stat-value text-xl">{format_count(@stats.terrain_profiles)}</div>
</div>
<div class="stat bg-base-200 rounded-box">
<div class="stat-title">Propagation scores</div>
<div class="stat-value text-xl">{format_count(@stats.propagation_scores)}</div>
</div>
<div class="stat bg-base-200 rounded-box">
<div class="stat-title">Beacons</div>
<div class="stat-value text-xl">{format_count(@stats.beacons)}</div>
</div>
<div class="stat bg-base-200 rounded-box">
<div class="stat-title">Commercial link samples</div>
<div class="stat-value text-xl">{format_count(@stats.commercial_samples)}</div>
</div>
</div>
</div>
@ -170,7 +200,7 @@ defmodule MicrowavepropWeb.AboutLive do
</li>
</ul>
</div>
<div class="rounded-box border border-primary/30 bg-primary/10 p-3 text-center">
<div role="alert" class="alert alert-info text-center">
<p>
If this tool is useful to you, consider
<a href="https://ntms.org" target="_blank" class="link link-primary font-semibold">
@ -184,18 +214,6 @@ defmodule MicrowavepropWeb.AboutLive do
"""
end
attr :label, :string, required: true
attr :value, :integer, required: true
defp stat_card(assigns) do
~H"""
<div class="rounded-lg border border-base-300 bg-base-200/40 p-3">
<div class="text-xs opacity-70 uppercase tracking-wide">{@label}</div>
<div class="text-xl font-bold tabular-nums">{format_count(@value)}</div>
</div>
"""
end
# For tables that get huge (tens of millions of rows), using count(*) on
# every About page load is wasteful. We use PostgreSQL's pg_class.reltuples
# estimate — it's maintained by ANALYZE and is accurate to within a few

View file

@ -333,11 +333,12 @@ defmodule MicrowavepropWeb.BackfillLive do
</.header>
<div class="grid grid-cols-1 md:grid-cols-4 gap-4 my-6">
<div class="bg-base-200 rounded-box p-4 md:col-span-2">
<div class="text-xs opacity-60">Enrichment Progress</div>
<div class="text-2xl font-bold">
{format_number(@unprocessed.complete)} / {format_number(@unprocessed.total)} fully enriched
<div class="stat bg-base-200 rounded-box md:col-span-2">
<div class="stat-title">Enrichment Progress</div>
<div class="stat-value text-2xl">
{format_number(@unprocessed.complete)} / {format_number(@unprocessed.total)}
</div>
<div class="stat-desc">fully enriched</div>
<div class="flex flex-col gap-1.5 mt-3">
<%= for {label, complete} <- [{"HRRR", @unprocessed.total - @unprocessed.hrrr}, {"Weather", @unprocessed.total - @unprocessed.weather}, {"Terrain", @unprocessed.total - @unprocessed.terrain}, {"IEMRE", @unprocessed.total - @unprocessed.iemre}, {"ERA5", @unprocessed.total - @unprocessed.era5}] do %>
<% pct =
@ -356,19 +357,17 @@ defmodule MicrowavepropWeb.BackfillLive do
<% end %>
</div>
</div>
<div class="bg-base-200 rounded-box p-4 md:col-span-2">
<div class="flex gap-8">
<div>
<div class="text-xs opacity-60">Active/Queued Jobs</div>
<div class="text-2xl font-bold">
{Enum.reduce(@stats.by_worker, 0, fn w, acc -> acc + w.total end)}
</div>
</div>
<div>
<div class="text-xs opacity-60">Completed (last hour)</div>
<div class="text-2xl font-bold">{format_number(@stats.completed_1h)}</div>
<div class="stats stats-vertical bg-base-200 rounded-box md:col-span-2">
<div class="stat">
<div class="stat-title">Active/Queued Jobs</div>
<div class="stat-value text-2xl">
{Enum.reduce(@stats.by_worker, 0, fn w, acc -> acc + w.total end)}
</div>
</div>
<div class="stat">
<div class="stat-title">Completed (last hour)</div>
<div class="stat-value text-2xl">{format_number(@stats.completed_1h)}</div>
</div>
</div>
</div>
@ -463,42 +462,46 @@ defmodule MicrowavepropWeb.BackfillLive do
<h2 class="text-base font-semibold mb-2">Enrichment Status by Type</h2>
<div class="grid grid-cols-2 md:grid-cols-4 gap-4 mb-6">
<%= for {type, label} <- [{"hrrr", "HRRR"}, {"weather", "Weather"}, {"terrain", "Terrain"}, {"iemre", "IEMRE"}, {"era5", "ERA5"}] do %>
<div class="bg-base-200 rounded-box p-3 text-xs">
<div class="font-semibold mb-1">{label}</div>
<%= for {status, count} <- Map.get(@db_stats.statuses, type, []) do %>
<div class="flex justify-between">
<span class={status_class(status)}>{status}</span>
<span class="font-mono">{format_number(count)}</span>
</div>
<% end %>
<div class="stat bg-base-200 rounded-box">
<div class="stat-title">{label}</div>
<div class="stat-desc mt-1">
<%= for {status, count} <- Map.get(@db_stats.statuses, type, []) do %>
<div class="flex justify-between">
<span class={status_class(status)}>{status}</span>
<span class="font-mono">{format_number(count)}</span>
</div>
<% end %>
</div>
</div>
<% end %>
</div>
<h2 class="text-base font-semibold mb-2">Database</h2>
<div class="grid grid-cols-2 md:grid-cols-4 gap-4 mb-4">
<div class="bg-base-200 rounded-box p-3">
<div class="text-xs opacity-60">Total DB Size</div>
<div class="text-lg font-bold font-mono">{@db_stats.db_size}</div>
<div class="stat bg-base-200 rounded-box">
<div class="stat-title">Total DB Size</div>
<div class="stat-value text-2xl font-mono">{@db_stats.db_size}</div>
</div>
<div class="bg-base-200 rounded-box p-3">
<div class="text-xs opacity-60">HRRR Profiles</div>
<div class="text-lg font-bold font-mono">
<div class="stat bg-base-200 rounded-box">
<div class="stat-title">HRRR Profiles</div>
<div class="stat-value text-2xl font-mono">
{format_number(@db_stats.totals.hrrr_profiles)}
</div>
<div class="text-xs opacity-60">{@db_stats.averages.hrrr_per_contact} per contact</div>
<div class="stat-desc">{@db_stats.averages.hrrr_per_contact} per contact</div>
</div>
<div class="bg-base-200 rounded-box p-3">
<div class="text-xs opacity-60">Surface Observations</div>
<div class="text-lg font-bold font-mono">
<div class="stat bg-base-200 rounded-box">
<div class="stat-title">Surface Observations</div>
<div class="stat-value text-2xl font-mono">
{format_number(@db_stats.totals.surface_observations)}
</div>
<div class="text-xs opacity-60">{@db_stats.averages.obs_per_contact} per contact</div>
<div class="stat-desc">{@db_stats.averages.obs_per_contact} per contact</div>
</div>
<div class="bg-base-200 rounded-box p-3">
<div class="text-xs opacity-60">Soundings</div>
<div class="text-lg font-bold font-mono">{format_number(@db_stats.totals.soundings)}</div>
<div class="text-xs opacity-60">{@db_stats.averages.soundings_per_contact} per contact</div>
<div class="stat bg-base-200 rounded-box">
<div class="stat-title">Soundings</div>
<div class="stat-value text-2xl font-mono">
{format_number(@db_stats.totals.soundings)}
</div>
<div class="stat-desc">{@db_stats.averages.soundings_per_contact} per contact</div>
</div>
</div>

View file

@ -99,42 +99,46 @@ defmodule MicrowavepropWeb.BeaconLive.Show do
<% end %>
</div>
<div class="rounded-lg border border-base-300 bg-base-200/40 p-3 text-sm">
<dl class="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 gap-x-4 gap-y-2">
<.stat_field label="Frequency">{Beacon.format_freq(@beacon.frequency_mhz)} MHz</.stat_field>
<.stat_field label="Callsign">{@beacon.callsign}</.stat_field>
<.stat_field label="Keying">{Beacon.keying_label(@beacon.keying)}</.stat_field>
<.stat_field label="On the air">
<span class={[
"badge badge-sm",
(@beacon.on_the_air && "badge-success") || "badge-ghost"
]}>
{if @beacon.on_the_air, do: "Yes", else: "No"}
</span>
</.stat_field>
<div class="card card-border bg-base-200/40">
<div class="card-body p-3 text-sm">
<dl class="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 gap-x-4 gap-y-2">
<.stat_field label="Frequency">
{Beacon.format_freq(@beacon.frequency_mhz)} MHz
</.stat_field>
<.stat_field label="Callsign">{@beacon.callsign}</.stat_field>
<.stat_field label="Keying">{Beacon.keying_label(@beacon.keying)}</.stat_field>
<.stat_field label="On the air">
<span class={[
"badge badge-sm",
(@beacon.on_the_air && "badge-success") || "badge-ghost"
]}>
{if @beacon.on_the_air, do: "Yes", else: "No"}
</span>
</.stat_field>
<.stat_field label="Grid">{@beacon.grid}</.stat_field>
<.stat_field label="Latitude">{format_coord(@beacon.lat)}</.stat_field>
<.stat_field label="Longitude">{format_coord(@beacon.lon)}</.stat_field>
<.stat_field label="EIRP">{Beacon.format_mw(@beacon.power_mw)} mW</.stat_field>
<.stat_field label="Grid">{@beacon.grid}</.stat_field>
<.stat_field label="Latitude">{format_coord(@beacon.lat)}</.stat_field>
<.stat_field label="Longitude">{format_coord(@beacon.lon)}</.stat_field>
<.stat_field label="EIRP">{Beacon.format_mw(@beacon.power_mw)} mW</.stat_field>
<.stat_field label="Height AGL">{@beacon.height_ft} ft</.stat_field>
<.stat_field label="Bearing">{bearing_label(@beacon.bearing)}</.stat_field>
<.stat_field :if={@beacon.beamwidth_deg} label="Beamwidth">
{Beacon.format_mw(@beacon.beamwidth_deg)}°
</.stat_field>
</dl>
<.stat_field label="Height AGL">{@beacon.height_ft} ft</.stat_field>
<.stat_field label="Bearing">{bearing_label(@beacon.bearing)}</.stat_field>
<.stat_field :if={@beacon.beamwidth_deg} label="Beamwidth">
{Beacon.format_mw(@beacon.beamwidth_deg)}°
</.stat_field>
</dl>
<div :if={@beacon.notes && @beacon.notes != ""} class="mt-3 pt-3 border-t border-base-300">
<div class="font-semibold text-[11px] uppercase tracking-wider opacity-60 mb-1">
Notes
<div :if={@beacon.notes && @beacon.notes != ""} class="mt-3 pt-3 border-t border-base-300">
<div class="font-semibold text-[11px] uppercase tracking-wider opacity-60 mb-1">
Notes
</div>
<div class="whitespace-pre-wrap text-sm">{@beacon.notes}</div>
</div>
<div class="whitespace-pre-wrap text-sm">{@beacon.notes}</div>
</div>
<div :if={@beacon.user && @beacon.user.callsign} class="mt-3 pt-3 border-t border-base-300">
<span class="text-xs opacity-60">Submitted by</span>
<span class="text-sm font-semibold">{@beacon.user.callsign}</span>
<div :if={@beacon.user && @beacon.user.callsign} class="mt-3 pt-3 border-t border-base-300">
<span class="text-xs opacity-60">Submitted by</span>
<span class="text-sm font-semibold">{@beacon.user.callsign}</span>
</div>
</div>
</div>
</Layouts.app>

View file

@ -569,7 +569,7 @@ defmodule MicrowavepropWeb.ContactLive.Show do
</.header>
<%= if @pending_edit && !@editing && !admin?(assigns) do %>
<div class="bg-info/10 border border-info/30 rounded-box px-4 py-2 text-sm mb-4">
<div class="alert alert-info text-sm mb-4">
<.icon name="hero-clock" class="w-4 h-4 inline" />
You have a pending edit for this contact awaiting admin review.
</div>
@ -665,12 +665,12 @@ defmodule MicrowavepropWeb.ContactLive.Show do
</table>
<%= if @elevation_profile.verdict == "BLOCKED" && @elevation_profile.first_obstruction_km do %>
<div class="bg-error/10 text-error font-semibold px-3 py-1 rounded mb-2 text-sm">
<div class="alert alert-error alert-soft py-1 mb-2 text-sm">
Obstructed at {format_dist(@elevation_profile.first_obstruction_km)}
</div>
<% end %>
<%= if @elevation_profile.verdict == "CLEAR" do %>
<div class="bg-success/10 text-success font-semibold px-3 py-1 rounded mb-2 text-sm">
<div class="alert alert-success alert-soft py-1 mb-2 text-sm">
Line of sight clear
</div>
<% end %>
@ -1087,77 +1087,89 @@ defmodule MicrowavepropWeb.ContactLive.Show do
<h2 class="text-base font-semibold mb-2">Data Sources</h2>
<div class="grid grid-cols-2 md:grid-cols-3 gap-4 text-xs">
<div class="bg-base-200 rounded-box p-3">
<div class="font-semibold mb-1">HRRR Model</div>
<%= if @data_sources.hrrr do %>
<div>{@data_sources.hrrr.profile_count} path profiles</div>
<div>{@data_sources.hrrr.levels} pressure levels each</div>
<div>Valid: {@data_sources.hrrr.valid_time}</div>
<div>Grid: {@data_sources.hrrr.lat}, {@data_sources.hrrr.lon}</div>
<% else %>
<div class="opacity-50">Not available</div>
<% end %>
<div class="card card-border card-sm bg-base-200">
<div class="card-body p-3">
<div class="font-semibold mb-1">HRRR Model</div>
<%= if @data_sources.hrrr do %>
<div>{@data_sources.hrrr.profile_count} path profiles</div>
<div>{@data_sources.hrrr.levels} pressure levels each</div>
<div>Valid: {@data_sources.hrrr.valid_time}</div>
<div>Grid: {@data_sources.hrrr.lat}, {@data_sources.hrrr.lon}</div>
<% else %>
<div class="opacity-50">Not available</div>
<% end %>
</div>
</div>
<div class="bg-base-200 rounded-box p-3">
<div class="font-semibold mb-1">Elevation Profile</div>
<%= if @data_sources.elevation do %>
<div>{@data_sources.elevation.samples} samples along path</div>
<div>{@data_sources.elevation.dist} path distance</div>
<div>Source: {@data_sources.elevation.source}</div>
<div>{@data_sources.elevation.duct_count} duct layers detected</div>
<% else %>
<div class="opacity-50">Not available</div>
<% end %>
<div class="card card-border card-sm bg-base-200">
<div class="card-body p-3">
<div class="font-semibold mb-1">Elevation Profile</div>
<%= if @data_sources.elevation do %>
<div>{@data_sources.elevation.samples} samples along path</div>
<div>{@data_sources.elevation.dist} path distance</div>
<div>Source: {@data_sources.elevation.source}</div>
<div>{@data_sources.elevation.duct_count} duct layers detected</div>
<% else %>
<div class="opacity-50">Not available</div>
<% end %>
</div>
</div>
<div class="bg-base-200 rounded-box p-3">
<div class="font-semibold mb-1">Terrain Analysis</div>
<%= if @data_sources.terrain do %>
<div>{@data_sources.terrain.samples} profile samples</div>
<div>Verdict: {@data_sources.terrain.verdict}</div>
<div>Max elevation: {@data_sources.terrain.max_elev} m</div>
<div>Diffraction: {@data_sources.terrain.diffraction} dB</div>
<% else %>
<div class="opacity-50">Not available</div>
<% end %>
<div class="card card-border card-sm bg-base-200">
<div class="card-body p-3">
<div class="font-semibold mb-1">Terrain Analysis</div>
<%= if @data_sources.terrain do %>
<div>{@data_sources.terrain.samples} profile samples</div>
<div>Verdict: {@data_sources.terrain.verdict}</div>
<div>Max elevation: {@data_sources.terrain.max_elev} m</div>
<div>Diffraction: {@data_sources.terrain.diffraction} dB</div>
<% else %>
<div class="opacity-50">Not available</div>
<% end %>
</div>
</div>
<div class="bg-base-200 rounded-box p-3">
<div class="font-semibold mb-1">Surface Observations</div>
<div>{length(@surface_observations)} stations</div>
<%= if @surface_observations != [] do %>
<div>
{Calendar.strftime(hd(@surface_observations).observed_at, "%H:%M")} {Calendar.strftime(
List.last(@surface_observations).observed_at,
"%H:%M"
)} UTC
</div>
<% end %>
<div class="card card-border card-sm bg-base-200">
<div class="card-body p-3">
<div class="font-semibold mb-1">Surface Observations</div>
<div>{length(@surface_observations)} stations</div>
<%= if @surface_observations != [] do %>
<div>
{Calendar.strftime(hd(@surface_observations).observed_at, "%H:%M")} {Calendar.strftime(
List.last(@surface_observations).observed_at,
"%H:%M"
)} UTC
</div>
<% end %>
</div>
</div>
<div class="bg-base-200 rounded-box p-3">
<div class="font-semibold mb-1">Soundings (RAOB)</div>
<div>{length(@soundings)} profiles</div>
<%= if @soundings != [] do %>
<div>
{Enum.map_join(@soundings, ", ", fn s ->
"#{s.station.station_code} (#{s.level_count} levels)"
end)}
</div>
<div>{Enum.count(@soundings, & &1.ducting_detected)} with ducting</div>
<% end %>
<div class="card card-border card-sm bg-base-200">
<div class="card-body p-3">
<div class="font-semibold mb-1">Soundings (RAOB)</div>
<div>{length(@soundings)} profiles</div>
<%= if @soundings != [] do %>
<div>
{Enum.map_join(@soundings, ", ", fn s ->
"#{s.station.station_code} (#{s.level_count} levels)"
end)}
</div>
<div>{Enum.count(@soundings, & &1.ducting_detected)} with ducting</div>
<% end %>
</div>
</div>
<div class="bg-base-200 rounded-box p-3">
<div class="font-semibold mb-1">Solar Indices</div>
<%= if @solar do %>
<div>SFI: {@solar.sfi || ""}</div>
<div>SSN: {@solar.sunspot_number || ""}</div>
<div>Ap: {@solar.ap_index || ""}</div>
<% else %>
<div class="opacity-50">Not available</div>
<% end %>
<div class="card card-border card-sm bg-base-200">
<div class="card-body p-3">
<div class="font-semibold mb-1">Solar Indices</div>
<%= if @solar do %>
<div>SFI: {@solar.sfi || ""}</div>
<div>SSN: {@solar.sunspot_number || ""}</div>
<div>Ap: {@solar.ap_index || ""}</div>
<% else %>
<div class="opacity-50">Not available</div>
<% end %>
</div>
</div>
</div>
<% end %>

View file

@ -240,17 +240,19 @@ defmodule MicrowavepropWeb.SubmitLive do
<:subtitle>Help us build a better propagation model</:subtitle>
</.header>
<div class="bg-info/10 border border-info/30 rounded-box p-4 mb-6 text-sm leading-relaxed">
<p class="font-semibold text-info mb-1">Every contact matters</p>
<p>
Our propagation model improves with real-world data. Each verified contact
you submit is matched against atmospheric conditions at the time of your contact,
helping us calibrate predictions for all amateur bands from 902 MHz and up. The
more contacts we have, the better our forecasts get for everyone.
</p>
<div class="alert alert-info mb-6 text-sm leading-relaxed">
<div>
<p class="font-semibold mb-1">Every contact matters</p>
<p>
Our propagation model improves with real-world data. Each verified contact
you submit is matched against atmospheric conditions at the time of your contact,
helping us calibrate predictions for all amateur bands from 902 MHz and up. The
more contacts we have, the better our forecasts get for everyone.
</p>
</div>
</div>
<div class="bg-base-200 rounded-box p-4 mb-6 text-sm leading-relaxed">
<div class="alert mb-6 text-sm leading-relaxed">
<p>
Your contact will be available on the
<.link navigate="/contacts" class="link link-primary">contacts page</.link>
@ -259,35 +261,26 @@ defmodule MicrowavepropWeb.SubmitLive do
</p>
</div>
<div class="flex gap-1 bg-base-200 rounded-lg p-1 mb-6 w-fit">
<div role="tablist" class="tabs tabs-box mb-6">
<button
class={[
"px-4 py-2 text-sm font-medium rounded-md transition-colors",
if(@active_tab == :single,
do: "bg-primary text-primary-content",
else: "hover:bg-base-300"
)
]}
role="tab"
class={["tab", @active_tab == :single && "tab-active"]}
phx-click="switch_tab"
phx-value-tab="single"
>
Single Contact
</button>
<button
class={[
"px-4 py-2 text-sm font-medium rounded-md transition-colors",
if(@active_tab == :csv, do: "bg-primary text-primary-content", else: "hover:bg-base-300")
]}
role="tab"
class={["tab", @active_tab == :csv && "tab-active"]}
phx-click="switch_tab"
phx-value-tab="csv"
>
Upload CSV
</button>
<button
class={[
"px-4 py-2 text-sm font-medium rounded-md transition-colors",
if(@active_tab == :adif, do: "bg-primary text-primary-content", else: "hover:bg-base-300")
]}
role="tab"
class={["tab", @active_tab == :adif && "tab-active"]}
phx-click="switch_tab"
phx-value-tab="adif"
>
@ -412,25 +405,27 @@ defmodule MicrowavepropWeb.SubmitLive do
defp csv_upload_form(assigns) do
~H"""
<div class="space-y-6">
<div class="bg-base-200 rounded-box p-4 text-sm">
<p class="mb-2">
Upload a CSV file with multiple contacts. Required columns:
</p>
<code class="text-xs">station1, station2, grid1, grid2, band, mode, qso_timestamp</code>
<ul class="list-disc list-inside mt-2 space-y-1 text-base-content/60">
<li>
Timestamps in most formats accepted (e.g. <code>2024-06-15T14:30:00Z</code>, <code>6/15/2024 2:30 PM</code>, <code>2024-06-15 14:30</code>). All times assumed UTC.
</li>
<li>
Grid squares should be as detailed as possible (8 characters preferred, e.g. EM12kp37)
</li>
<li>Band in MHz (e.g. 10000, 24000)</li>
</ul>
<p class="mt-2">
<a href="/downloads/sample_contacts.csv" download class="link link-primary">
<.icon name="hero-arrow-down-tray" class="w-4 h-4" /> Download sample CSV
</a>
</p>
<div class="alert text-sm">
<div>
<p class="mb-2">
Upload a CSV file with multiple contacts. Required columns:
</p>
<code class="text-xs">station1, station2, grid1, grid2, band, mode, qso_timestamp</code>
<ul class="list-disc list-inside mt-2 space-y-1 text-base-content/60">
<li>
Timestamps in most formats accepted (e.g. <code>2024-06-15T14:30:00Z</code>, <code>6/15/2024 2:30 PM</code>, <code>2024-06-15 14:30</code>). All times assumed UTC.
</li>
<li>
Grid squares should be as detailed as possible (8 characters preferred, e.g. EM12kp37)
</li>
<li>Band in MHz (e.g. 10000, 24000)</li>
</ul>
<p class="mt-2">
<a href="/downloads/sample_contacts.csv" download class="link link-primary">
<.icon name="hero-arrow-down-tray" class="w-4 h-4" /> Download sample CSV
</a>
</p>
</div>
</div>
<form id="csv-upload-form" phx-submit="upload_csv" phx-change="validate_csv" class="space-y-4">
@ -502,21 +497,23 @@ defmodule MicrowavepropWeb.SubmitLive do
defp adif_upload_form(assigns) do
~H"""
<div class="space-y-6">
<div class="bg-base-200 rounded-box p-4 text-sm">
<p class="mb-2">
Upload an ADIF (.adi / .adif) file exported from your logging program.
</p>
<ul class="list-disc list-inside mt-2 space-y-1 text-base-content/60">
<li>
Required fields: <code>CALL</code>, <code>STATION_CALLSIGN</code>
(or <code>OPERATOR</code>), <code>GRIDSQUARE</code>, <code>MY_GRIDSQUARE</code>, <code>QSO_DATE</code>, <code>TIME_ON</code>,
and <code>FREQ</code>
or <code>BAND</code>
</li>
<li>Only microwave contacts (902 MHz and up) will be imported</li>
<li>Sub-microwave contacts are silently skipped</li>
<li>Frequencies are fuzzy-matched to the nearest microwave band</li>
</ul>
<div class="alert text-sm">
<div>
<p class="mb-2">
Upload an ADIF (.adi / .adif) file exported from your logging program.
</p>
<ul class="list-disc list-inside mt-2 space-y-1 text-base-content/60">
<li>
Required fields: <code>CALL</code>, <code>STATION_CALLSIGN</code>
(or <code>OPERATOR</code>), <code>GRIDSQUARE</code>, <code>MY_GRIDSQUARE</code>, <code>QSO_DATE</code>, <code>TIME_ON</code>,
and <code>FREQ</code>
or <code>BAND</code>
</li>
<li>Only microwave contacts (902 MHz and up) will be imported</li>
<li>Sub-microwave contacts are silently skipped</li>
<li>Frequencies are fuzzy-matched to the nearest microwave band</li>
</ul>
</div>
</div>
<form
@ -784,7 +781,7 @@ defmodule MicrowavepropWeb.SubmitLive do
<div class="space-y-6">
<div
:if={@imported_count > 0}
class="rounded-box border border-success/40 bg-success/10 p-8 text-center"
class="alert alert-success p-8 text-center"
>
<.icon name="hero-check-circle" class="w-16 h-16 text-success mx-auto mb-3" />
<div class="text-3xl font-bold tabular-nums">

View file

@ -185,5 +185,58 @@ defmodule Microwaveprop.Radio.AdifImportTest do
assert [row] = preview.valid
assert row.attrs["station1"] == "W5XD"
end
test "normalizes gridsquare case to uppercase" do
adif = """
<STATION_CALLSIGN:4>W5XD<CALL:4>K5TR<GRIDSQUARE:4>em00<MY_GRIDSQUARE:6>em12kp<FREQ:9>10368.000<MODE:2>CW<QSO_DATE:8>20260328<TIME_ON:6>180000<EOR>
"""
assert {:ok, preview} = AdifImport.preview(adif, @submitter)
assert [row] = preview.valid
assert row.attrs["grid1"] == "EM12KP"
assert row.attrs["grid2"] == "EM00"
end
test "parses file with header tags but no <EOH>" do
adif = """
<ADIF_VER:5>3.1.4
<PROGRAMID:8>Logger32
<STATION_CALLSIGN:4>W5XD<CALL:4>K5TR<GRIDSQUARE:4>EM00<MY_GRIDSQUARE:4>EM12<FREQ:9>10368.000<MODE:2>CW<QSO_DATE:8>20260328<TIME_ON:6>180000<EOR>
"""
assert {:ok, preview} = AdifImport.preview(adif, @submitter)
assert length(preview.valid) == 1
end
test "strips APP_ prefix from app-defined fields" do
adif = """
<APP_N1MM_STATION_CALLSIGN:4>W5XD<CALL:4>K5TR<APP_MYLOG_GRIDSQUARE:4>EM00<APP_MYLOG_MY_GRIDSQUARE:4>EM12<FREQ:9>10368.000<MODE:2>CW<QSO_DATE:8>20260328<TIME_ON:6>180000<EOR>
"""
assert {:ok, preview} = AdifImport.preview(adif, @submitter)
assert [row] = preview.valid
assert row.attrs["station1"] == "W5XD"
assert row.attrs["grid1"] == "EM12"
assert row.attrs["grid2"] == "EM00"
end
test "standard fields take precedence over app-defined" do
adif = """
<STATION_CALLSIGN:4>W5XD<APP_FOO_STATION_CALLSIGN:5>WRONG<CALL:4>K5TR<GRIDSQUARE:4>EM00<MY_GRIDSQUARE:4>EM12<FREQ:9>10368.000<MODE:2>CW<QSO_DATE:8>20260328<TIME_ON:6>180000<EOR>
"""
assert {:ok, preview} = AdifImport.preview(adif, @submitter)
assert [row] = preview.valid
assert row.attrs["station1"] == "W5XD"
end
test "parses file with no header at all" do
adif = """
<STATION_CALLSIGN:4>W5XD<CALL:4>K5TR<GRIDSQUARE:4>EM00<MY_GRIDSQUARE:4>EM12<FREQ:9>10368.000<MODE:2>CW<QSO_DATE:8>20260328<TIME_ON:6>180000<EOR>
"""
assert {:ok, preview} = AdifImport.preview(adif, @submitter)
assert length(preview.valid) == 1
end
end
end