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:
parent
2d3da359ee
commit
d5d0c32745
7 changed files with 345 additions and 223 deletions
|
|
@ -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"])
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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 %>
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue