From a3fff198c92024871a0770e2113b207c5fea0966 Mon Sep 17 00:00:00 2001 From: Graham McIntire Date: Sun, 3 May 2026 10:36:34 -0500 Subject: [PATCH] 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. --- assets/js/contacts_map_hook.ts | 44 ++++++++- .../live/contact_map_live.ex | 92 ++++++++++++++++++- .../live/contact_map_live_test.exs | 38 ++++++++ 3 files changed, 169 insertions(+), 5 deletions(-) diff --git a/assets/js/contacts_map_hook.ts b/assets/js/contacts_map_hook.ts index b1319fbd..df98b527 100644 --- a/assets/js/contacts_map_hook.ts +++ b/assets/js/contacts_map_hook.ts @@ -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 bandGroups: Map 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 = {} 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)}` diff --git a/lib/microwaveprop_web/live/contact_map_live.ex b/lib/microwaveprop_web/live/contact_map_live.ex index 3089ab83..8426741c 100644 --- a/lib/microwaveprop_web/live/contact_map_live.ex +++ b/lib/microwaveprop_web/live/contact_map_live.ex @@ -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 +
+ Date range (UTC) +
+ + + +
+
+ + +
+
+
Bands @@ -215,6 +270,41 @@ defmodule MicrowavepropWeb.ContactMapLive do + <%!-- Date range filter --%> +
+ + Date range (UTC) + +
+ + + +
+
+ + +
+
+ <%!-- Band checkboxes --%>
Bands diff --git a/test/microwaveprop_web/live/contact_map_live_test.exs b/test/microwaveprop_web/live/contact_map_live_test.exs index 69f068c5..e3730597 100644 --- a/test/microwaveprop_web/live/contact_map_live_test.exs +++ b/test/microwaveprop_web/live/contact_map_live_test.exs @@ -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