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:
parent
01341d20f3
commit
a3fff198c9
3 changed files with 169 additions and 5 deletions
|
|
@ -11,11 +11,17 @@ interface ApplyFilterPayload {
|
|||
callsign: string | null
|
||||
}
|
||||
|
||||
interface ApplyDateFilterPayload {
|
||||
start: string | null
|
||||
end: string | null
|
||||
}
|
||||
|
||||
interface LineEntry {
|
||||
line: L.Polyline
|
||||
band: number
|
||||
s1: string
|
||||
s2: string
|
||||
tsDate: string
|
||||
lat1: number
|
||||
lon1: number
|
||||
lat2: number
|
||||
|
|
@ -25,6 +31,8 @@ interface LineEntry {
|
|||
interface ContactsMapHook extends ViewHook {
|
||||
allContacts: ContactTuple[]
|
||||
callsignFilter: string
|
||||
startDate: string
|
||||
endDate: string
|
||||
enabledBands: Set<number>
|
||||
bandGroups: Map<number, L.LayerGroup>
|
||||
lines: LineEntry[]
|
||||
|
|
@ -44,6 +52,14 @@ interface ContactsMapHook extends ViewHook {
|
|||
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-*
|
||||
// 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
|
||||
|
|
@ -103,6 +119,8 @@ export const ContactsMap = {
|
|||
const fetchUrl = this.el.dataset.fetchUrl || "/api/contacts/map"
|
||||
this.allContacts = []
|
||||
this.callsignFilter = ""
|
||||
this.startDate = ""
|
||||
this.endDate = ""
|
||||
this.enabledBands = new Set()
|
||||
this.bandGroups = new Map()
|
||||
this.lines = []
|
||||
|
|
@ -117,6 +135,13 @@ export const ContactsMap = {
|
|||
this.rebuildDots()
|
||||
})
|
||||
|
||||
this.handleEvent("apply_date_filter", ({start, end}: ApplyDateFilterPayload) => {
|
||||
this.startDate = start || ""
|
||||
this.endDate = end || ""
|
||||
this.applyCallsignFilter()
|
||||
this.rebuildDots()
|
||||
})
|
||||
|
||||
this.wireControls()
|
||||
|
||||
// Re-color polylines when the theme toggles. Leaflet writes `stroke` as
|
||||
|
|
@ -272,6 +297,10 @@ export const ContactsMap = {
|
|||
band,
|
||||
s1: (s1 || "").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
|
||||
})
|
||||
}
|
||||
|
|
@ -288,14 +317,18 @@ export const ContactsMap = {
|
|||
},
|
||||
|
||||
applyCallsignFilter(this: ContactsMapHook) {
|
||||
// Callsign filter: iterate individual polylines and add/remove them
|
||||
// from their band's group. Cheap compared to rebuild (no polyline
|
||||
// creation). Only runs when the filter string changes.
|
||||
// Callsign + date filter: iterate individual polylines and add/remove
|
||||
// them from their band's group. Cheap compared to rebuild (no polyline
|
||||
// creation). Runs when either filter changes.
|
||||
const match = this.callsignFilter
|
||||
const startDate = this.startDate
|
||||
const endDate = this.endDate
|
||||
for (const entry of this.lines) {
|
||||
const group = this.bandGroups.get(entry.band)
|
||||
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)
|
||||
if (visible && !present) group.addLayer(entry.line)
|
||||
else if (!visible && present) group.removeLayer(entry.line)
|
||||
|
|
@ -305,12 +338,15 @@ export const ContactsMap = {
|
|||
rebuildDots(this: ContactsMapHook) {
|
||||
this.dotLayer.clearLayers()
|
||||
const match = this.callsignFilter
|
||||
const startDate = this.startDate
|
||||
const endDate = this.endDate
|
||||
const endpointCounts: Record<string, number> = {}
|
||||
let lineCount = 0
|
||||
|
||||
for (const entry of this.lines) {
|
||||
if (!this.enabledBands.has(entry.band)) continue
|
||||
if (match && !entry.s1.includes(match) && !entry.s2.includes(match)) continue
|
||||
if (!inDateRange(entry.tsDate, startDate, endDate)) continue
|
||||
lineCount++
|
||||
const k1 = `${entry.lat1.toFixed(2)},${entry.lon1.toFixed(2)}`
|
||||
const k2 = `${entry.lat2.toFixed(2)},${entry.lon2.toFixed(2)}`
|
||||
|
|
|
|||
|
|
@ -38,7 +38,9 @@ defmodule MicrowavepropWeb.ContactMapLive do
|
|||
page_title: "Contact Map",
|
||||
contact_count: count,
|
||||
all_bands: bands,
|
||||
callsign_filter: ""
|
||||
callsign_filter: "",
|
||||
start_date: "",
|
||||
end_date: ""
|
||||
)}
|
||||
end
|
||||
|
||||
|
|
@ -54,6 +56,27 @@ defmodule MicrowavepropWeb.ContactMapLive do
|
|||
{:noreply, socket}
|
||||
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
|
||||
ghz = mhz / 1000
|
||||
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>
|
||||
</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">
|
||||
<span class="text-xs font-bold">Bands</span>
|
||||
<span class="flex gap-2">
|
||||
|
|
@ -215,6 +270,41 @@ defmodule MicrowavepropWeb.ContactMapLive do
|
|||
<button type="submit" class="btn btn-sm btn-primary">Filter</button>
|
||||
</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 --%>
|
||||
<div class="flex items-center justify-between px-1">
|
||||
<span class="text-xs font-bold">Bands</span>
|
||||
|
|
|
|||
|
|
@ -100,4 +100,42 @@ defmodule MicrowavepropWeb.ContactMapLiveTest do
|
|||
)
|
||||
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
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue