feat(contact-map): date-range filter alongside callsign filter

Adds start/end date inputs (UTC) to the sidebar (mobile + desktop) with
Apply dates and Clear buttons. The hook keeps a sliced YYYY-MM-DD on
each line entry so the range check is a string compare during filter
re-application; combines with the callsign filter so e.g. AA5C between
2025-01-01 and 2026-01-01 is one query.
This commit is contained in:
Graham McIntire 2026-05-03 10:36:34 -05:00
parent 01341d20f3
commit a3fff198c9
No known key found for this signature in database
GPG key ID: F4ABF488E6029E59
3 changed files with 169 additions and 5 deletions

View file

@ -11,11 +11,17 @@ interface ApplyFilterPayload {
callsign: string | null callsign: string | null
} }
interface ApplyDateFilterPayload {
start: string | null
end: string | null
}
interface LineEntry { interface LineEntry {
line: L.Polyline line: L.Polyline
band: number band: number
s1: string s1: string
s2: string s2: string
tsDate: string
lat1: number lat1: number
lon1: number lon1: number
lat2: number lat2: number
@ -25,6 +31,8 @@ interface LineEntry {
interface ContactsMapHook extends ViewHook { interface ContactsMapHook extends ViewHook {
allContacts: ContactTuple[] allContacts: ContactTuple[]
callsignFilter: string callsignFilter: string
startDate: string
endDate: string
enabledBands: Set<number> enabledBands: Set<number>
bandGroups: Map<number, L.LayerGroup> bandGroups: Map<number, L.LayerGroup>
lines: LineEntry[] lines: LineEntry[]
@ -44,6 +52,14 @@ interface ContactsMapHook extends ViewHook {
restyleLines(this: ContactsMapHook): void restyleLines(this: ContactsMapHook): void
} }
function inDateRange(tsDate: string, startDate: string, endDate: string): boolean {
if (!startDate && !endDate) return true
if (!tsDate) return false
if (startDate && tsDate < startDate) return false
if (endDate && tsDate > endDate) return false
return true
}
// Amateur-band palette. Nine of these map to the design system's --band-* // Amateur-band palette. Nine of these map to the design system's --band-*
// CSS vars (defined in assets/css/app.css); the higher mm-wave bands above // CSS vars (defined in assets/css/app.css); the higher mm-wave bands above
// 47 GHz fall back to hard-coded hues since the design set only spans up // 47 GHz fall back to hard-coded hues since the design set only spans up
@ -103,6 +119,8 @@ export const ContactsMap = {
const fetchUrl = this.el.dataset.fetchUrl || "/api/contacts/map" const fetchUrl = this.el.dataset.fetchUrl || "/api/contacts/map"
this.allContacts = [] this.allContacts = []
this.callsignFilter = "" this.callsignFilter = ""
this.startDate = ""
this.endDate = ""
this.enabledBands = new Set() this.enabledBands = new Set()
this.bandGroups = new Map() this.bandGroups = new Map()
this.lines = [] this.lines = []
@ -117,6 +135,13 @@ export const ContactsMap = {
this.rebuildDots() this.rebuildDots()
}) })
this.handleEvent("apply_date_filter", ({start, end}: ApplyDateFilterPayload) => {
this.startDate = start || ""
this.endDate = end || ""
this.applyCallsignFilter()
this.rebuildDots()
})
this.wireControls() this.wireControls()
// Re-color polylines when the theme toggles. Leaflet writes `stroke` as // Re-color polylines when the theme toggles. Leaflet writes `stroke` as
@ -272,6 +297,10 @@ export const ContactsMap = {
band, band,
s1: (s1 || "").toUpperCase(), s1: (s1 || "").toUpperCase(),
s2: (s2 || "").toUpperCase(), s2: (s2 || "").toUpperCase(),
// Date inputs come in as YYYY-MM-DD; ts is "YYYY-MM-DD HH:MM" so
// pre-slicing the date portion lets the range check be a string
// compare without having to substring per-line every render.
tsDate: (ts || "").substring(0, 10),
lat1, lon1, lat2, lon2 lat1, lon1, lat2, lon2
}) })
} }
@ -288,14 +317,18 @@ export const ContactsMap = {
}, },
applyCallsignFilter(this: ContactsMapHook) { applyCallsignFilter(this: ContactsMapHook) {
// Callsign filter: iterate individual polylines and add/remove them // Callsign + date filter: iterate individual polylines and add/remove
// from their band's group. Cheap compared to rebuild (no polyline // them from their band's group. Cheap compared to rebuild (no polyline
// creation). Only runs when the filter string changes. // creation). Runs when either filter changes.
const match = this.callsignFilter const match = this.callsignFilter
const startDate = this.startDate
const endDate = this.endDate
for (const entry of this.lines) { for (const entry of this.lines) {
const group = this.bandGroups.get(entry.band) const group = this.bandGroups.get(entry.band)
if (!group) continue if (!group) continue
const visible = !match || entry.s1.includes(match) || entry.s2.includes(match) const callsignOk = !match || entry.s1.includes(match) || entry.s2.includes(match)
const dateOk = inDateRange(entry.tsDate, startDate, endDate)
const visible = callsignOk && dateOk
const present = group.hasLayer(entry.line) const present = group.hasLayer(entry.line)
if (visible && !present) group.addLayer(entry.line) if (visible && !present) group.addLayer(entry.line)
else if (!visible && present) group.removeLayer(entry.line) else if (!visible && present) group.removeLayer(entry.line)
@ -305,12 +338,15 @@ export const ContactsMap = {
rebuildDots(this: ContactsMapHook) { rebuildDots(this: ContactsMapHook) {
this.dotLayer.clearLayers() this.dotLayer.clearLayers()
const match = this.callsignFilter const match = this.callsignFilter
const startDate = this.startDate
const endDate = this.endDate
const endpointCounts: Record<string, number> = {} const endpointCounts: Record<string, number> = {}
let lineCount = 0 let lineCount = 0
for (const entry of this.lines) { for (const entry of this.lines) {
if (!this.enabledBands.has(entry.band)) continue if (!this.enabledBands.has(entry.band)) continue
if (match && !entry.s1.includes(match) && !entry.s2.includes(match)) continue if (match && !entry.s1.includes(match) && !entry.s2.includes(match)) continue
if (!inDateRange(entry.tsDate, startDate, endDate)) continue
lineCount++ lineCount++
const k1 = `${entry.lat1.toFixed(2)},${entry.lon1.toFixed(2)}` const k1 = `${entry.lat1.toFixed(2)},${entry.lon1.toFixed(2)}`
const k2 = `${entry.lat2.toFixed(2)},${entry.lon2.toFixed(2)}` const k2 = `${entry.lat2.toFixed(2)},${entry.lon2.toFixed(2)}`

View file

@ -38,7 +38,9 @@ defmodule MicrowavepropWeb.ContactMapLive do
page_title: "Contact Map", page_title: "Contact Map",
contact_count: count, contact_count: count,
all_bands: bands, all_bands: bands,
callsign_filter: "" callsign_filter: "",
start_date: "",
end_date: ""
)} )}
end end
@ -54,6 +56,27 @@ defmodule MicrowavepropWeb.ContactMapLive do
{:noreply, socket} {:noreply, socket}
end end
def handle_event("filter_dates", %{"start_date" => start_date, "end_date" => end_date}, socket) do
start_trimmed = String.trim(start_date)
end_trimmed = String.trim(end_date)
socket =
socket
|> assign(start_date: start_trimmed, end_date: end_trimmed)
|> push_event("apply_date_filter", %{start: start_trimmed, end: end_trimmed})
{:noreply, socket}
end
def handle_event("clear_dates", _params, socket) do
socket =
socket
|> assign(start_date: "", end_date: "")
|> push_event("apply_date_filter", %{start: "", end: ""})
{:noreply, socket}
end
defp band_label(mhz) when mhz >= 1000 do defp band_label(mhz) when mhz >= 1000 do
ghz = mhz / 1000 ghz = mhz / 1000
label = if ghz == trunc(ghz), do: "#{trunc(ghz)}", else: "#{Float.round(ghz / 1, 1)}" label = if ghz == trunc(ghz), do: "#{trunc(ghz)}", else: "#{Float.round(ghz / 1, 1)}"
@ -120,6 +143,38 @@ defmodule MicrowavepropWeb.ContactMapLive do
<button type="submit" class="btn btn-xs btn-primary">Filter</button> <button type="submit" class="btn btn-xs btn-primary">Filter</button>
</form> </form>
<form phx-submit="filter_dates" class="flex flex-col gap-1">
<span class="text-[10px] opacity-70">Date range (UTC)</span>
<div class="flex gap-1 items-center">
<input
type="date"
name="start_date"
value={@start_date}
class="input input-xs flex-1 min-w-0"
/>
<span class="text-xs"></span>
<input
type="date"
name="end_date"
value={@end_date}
class="input input-xs flex-1 min-w-0"
/>
</div>
<div class="flex gap-1">
<button type="submit" class="btn btn-xs btn-primary flex-1">
Apply dates
</button>
<button
:if={@start_date != "" or @end_date != ""}
type="button"
phx-click="clear_dates"
class="btn btn-xs btn-ghost"
>
Clear
</button>
</div>
</form>
<div class="flex items-center justify-between"> <div class="flex items-center justify-between">
<span class="text-xs font-bold">Bands</span> <span class="text-xs font-bold">Bands</span>
<span class="flex gap-2"> <span class="flex gap-2">
@ -215,6 +270,41 @@ defmodule MicrowavepropWeb.ContactMapLive do
<button type="submit" class="btn btn-sm btn-primary">Filter</button> <button type="submit" class="btn btn-sm btn-primary">Filter</button>
</form> </form>
<%!-- Date range filter --%>
<form phx-submit="filter_dates" class="flex flex-col gap-1">
<span class="text-[10px] uppercase font-semibold opacity-70 px-1">
Date range (UTC)
</span>
<div class="flex gap-1 items-center">
<input
type="date"
name="start_date"
value={@start_date}
class="input input-sm flex-1 min-w-0"
/>
<span class="text-xs"></span>
<input
type="date"
name="end_date"
value={@end_date}
class="input input-sm flex-1 min-w-0"
/>
</div>
<div class="flex gap-1">
<button type="submit" class="btn btn-sm btn-primary flex-1">
Apply dates
</button>
<button
:if={@start_date != "" or @end_date != ""}
type="button"
phx-click="clear_dates"
class="btn btn-sm btn-ghost"
>
Clear
</button>
</div>
</form>
<%!-- Band checkboxes --%> <%!-- Band checkboxes --%>
<div class="flex items-center justify-between px-1"> <div class="flex items-center justify-between px-1">
<span class="text-xs font-bold">Bands</span> <span class="text-xs font-bold">Bands</span>

View file

@ -100,4 +100,42 @@ defmodule MicrowavepropWeb.ContactMapLiveTest do
) )
end end
end end
describe "filter_dates event" do
test "stores trimmed start/end dates and round-trips them in the inputs", %{conn: conn} do
{:ok, lv, _html} = live(conn, ~p"/contacts/map")
html =
lv
|> form(~s|form[phx-submit="filter_dates"]:has(input.input-sm)|,
start_date: "2025-01-01",
end_date: "2026-01-01"
)
|> render_submit()
assert html =~ ~s(name="start_date" value="2025-01-01")
assert html =~ ~s(name="end_date" value="2026-01-01")
# Clear button shows up only once a filter is set.
assert html =~ ~s(phx-click="clear_dates")
end
test "clear_dates wipes both inputs", %{conn: conn} do
{:ok, lv, _html} = live(conn, ~p"/contacts/map")
lv
|> form(~s|form[phx-submit="filter_dates"]:has(input.input-sm)|,
start_date: "2025-01-01",
end_date: "2026-01-01"
)
|> render_submit()
html =
render_click(
element(lv, ~s|form:has(input.input-sm) button[phx-click="clear_dates"]|)
)
refute html =~ ~s(value="2025-01-01")
refute html =~ ~s(value="2026-01-01")
end
end
end end