From ab758a4e399b140dcea9bc8e5ec92b4b5b0ac950 Mon Sep 17 00:00:00 2001 From: Brad Bazemore Date: Tue, 27 Oct 2026 11:46:12 -0400 Subject: [PATCH 1/3] Home page: draw the configuration, not just the traffic The flow diagram now shows how the system is set up whether or not spots flowed: every source each connection takes spots from, every destination (linked or not), and quiet links at the 2 px floor. The last 24 hours only label the nodes and weight the links. home_flow() gains subscription_sources: each connection's sources by the matcher's own rules (source_matches, so "any source" expands in SQL, and RBN only with an AND group that names stations). Co-Authored-By: Claude Opus 5.5 --- CLAUDE.md | 15 ++-- .../migrations/20261027001300_home_flow.sql | 24 +++++- supabase/tests/home_flow.test.sql | 43 +++++++++-- web/src/home/HomePage.test.tsx | 26 ++++++- web/src/home/model.test.ts | 45 ++++++++++- web/src/home/model.ts | 77 +++++++++++++------ web/src/home/types.ts | 10 ++- web/src/lib/api.test.tsx | 2 +- 8 files changed, 191 insertions(+), 51 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 745e77c6..47922080 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -691,10 +691,15 @@ profile canvas); its Copy section ("Reference page") is the copy source for `/re - `/home` is the signed-in landing page (sign-in, set password and unknown paths go there) and is not public. Its copy comes from Brad's "Long Lines home" canvas. -- Counts come only from `home_flow()`: the caller's last 24 hours of `deliveries` (matched = - distinct spots by `created_at`, delivered = sent rows by `sent_at`). Names, types, sources and - links come from the lists `SignedIn` already loads. `home_flow` is web-only: never add it to - `api_call`, and never return spot data from it. Its shape is `HomeFlow` (`web/src/home/types.ts`), +- The diagram draws the configuration, not the traffic: every connection (the top + `TOP_CONNECTIONS` by matched, the rest in a "more" node), every source each takes spots from and + every destination, linked or not. The last 24 hours only label nodes and weight links; never drop + a node or link for being quiet. +- `home_flow()` gives what the lists `SignedIn` loads can't: the caller's last 24 hours of + `deliveries` (matched = distinct spots by `created_at`, delivered = sent rows by `sent_at`) and + `subscription_sources`, each connection's sources by the matcher's own rules (`source_matches`, + and RBN only with an AND group that names stations). Never expand "any source" in TypeScript. + `home_flow` is web-only: never add it to `api_call`, and never return spot data from it. Its shape is `HomeFlow` (`web/src/home/types.ts`), pinned by `supabase/tests/home_flow.test.sql`; change the SQL, the type and the test together. - Nothing records a match for a connection the matcher skips (off, or no enabled destination), so the page shows an en dash for it; never count matches live over `raw_spots` per visitor. @@ -805,7 +810,7 @@ page" canvas); its Copy section is the copy source for `/health`. `preview_subscription` both go through it. `group_ids` is derived from the rules (every group named, `is` or `is_not`) and written only by `save_subscription`; never set it elsewhere. `normalize_subscription_sources` is the only check on `sources` (both call it); its list is - `raw_spots_source_check` without `aprs`, so a new source goes in both. Band values are stored + `raw_spots_source_check` without `aprs`, so a new source goes in both, and in `home_flow`'s list. Band values are stored lowercase, and a rule takes at most 1,000 values. - Matching is `rule_matches` per rule inside `spot_matches`; never evaluate rules in TypeScript. The web's `model.ts` only converts builder rows to rules (`rulesOf` drops rows without values) and diff --git a/supabase/migrations/20261027001300_home_flow.sql b/supabase/migrations/20261027001300_home_flow.sql index adc74ac0..1f849569 100644 --- a/supabase/migrations/20261027001300_home_flow.sql +++ b/supabase/migrations/20261027001300_home_flow.sql @@ -1,7 +1,13 @@ --- The Home page's counts (the "Long Lines home" canvas): the caller's last 24 hours of --- deliveries, which are the only record of a match. Names, types, sources and links come from --- the tables the web app already reads; this returns numbers only, never spot data, and stays --- out of api_call (the management API is configuration only). +-- The Home page's diagram (the "Long Lines home" canvas) draws the caller's configuration, and +-- this gives it the two things the web app can't read itself: the counts for the caller's last 24 +-- hours of deliveries (the only record of a match), and which sources each connection takes spots +-- from. Names, types and destination links come from the tables the web app already reads. It +-- returns no spot data and stays out of api_call (the management API is configuration only). +-- +-- subscription_sources is every (connection, source) pair the matcher would let through, decided +-- by its own predicates: source_matches (an empty list is "any" of POTA, SOTAwatch and RBN) and, +-- for RBN, an AND group that names stations, as in spot_matches. The source list is +-- normalize_subscription_sources'; a new source goes in both. -- -- What the numbers can and cannot say: -- * A connection with no enabled destination, or a disabled one, makes no deliveries @@ -53,6 +59,16 @@ as $$ select jsonb_agg(jsonb_build_object('subscription_id', s.subscription_id, 'destination_id', s.destination_id, 'delivered', s.n) order by s.subscription_id, s.destination_id) from (select subscription_id, destination_id, count(*) as n from sent group by subscription_id, destination_id) s + ), '[]'::jsonb), + 'subscription_sources', coalesce(( + select jsonb_agg(jsonb_build_object('subscription_id', s.id, 'source', src.source) order by s.id, src.source) + from subscriptions s + cross join unnest(array['pota', 'sotawatch', 'wwff', 'wwbota', 'rbn', 'dxcluster']) as src(source) + where s.user_id = auth.uid() + and public.source_matches(s.sources, src.source) + and (src.source <> 'rbn' or exists ( + select 1 from jsonb_array_elements(s.rules) as g + where public.rules_name_stations(jsonb_build_array(g)))) ), '[]'::jsonb) ) where auth.uid() is not null; diff --git a/supabase/tests/home_flow.test.sql b/supabase/tests/home_flow.test.sql index 5d494719..19c0e037 100644 --- a/supabase/tests/home_flow.test.sql +++ b/supabase/tests/home_flow.test.sql @@ -1,10 +1,11 @@ --- pgTAP: home_flow() (20261027001300_home_flow), the Home page's counts. Pins the --- payload shape (HomeFlow in web/src/home/types.ts), the 24-hour windows (matched by --- created_at, delivered by sent_at), distinct spots, per-user isolation and access. +-- pgTAP: home_flow() (20261027001300_home_flow), the Home page's counts and each +-- connection's sources. Pins the payload shape (HomeFlow in web/src/home/types.ts), the +-- 24-hour windows (matched by created_at, delivered by sent_at), distinct spots, the +-- matcher's source rules, per-user isolation and access. begin; create extension if not exists pgtap with schema extensions; \ir helpers/users.psql -select plan(11); +select plan(13); create function pg_temp.ago(p_hours int) returns timestamptz language sql as $$ select now() - make_interval(hours => p_hours) $$; @@ -18,7 +19,14 @@ select save_subscription(jsonb_build_object('name', 'Parks', 'sources', array['p 'destinations', array[(select id from dc), (select id from dw)])) as parks, save_subscription(jsonb_build_object('name', 'DX', 'sources', array['dxcluster'], 'rules', '[[{"field":"callsign","op":"is","values":["VP8PJ"]}]]'::jsonb, 'destinations', array[(select id from dw)])) as dx, - save_subscription(jsonb_build_object('name', 'Nowhere', 'destinations', '[]'::jsonb)) as nowhere; + save_subscription(jsonb_build_object('name', 'Nowhere', 'destinations', '[]'::jsonb)) as nowhere, + save_subscription(jsonb_build_object('name', 'Any call', + 'rules', '[[{"field":"callsign","op":"is","values":["KK4PWJ"]}]]'::jsonb, 'destinations', '[]'::jsonb)) as anycall, + save_subscription(jsonb_build_object('name', 'Either', + 'rules', '[[{"field":"callsign","op":"is","values":["KK4PWJ"]}], [{"field":"band","op":"is","values":["20m"]}]]'::jsonb, + 'destinations', '[]'::jsonb)) as either, + save_subscription(jsonb_build_object('name', 'Everything', 'sources', array['pota', 'sotawatch', 'wwff', 'wwbota', 'rbn', 'dxcluster'], + 'rules', '[[{"field":"callsign","op":"is","values":["KK4PWJ"]}]]'::jsonb, 'destinations', '[]'::jsonb)) as everything; select pg_temp.as_user('22222222-2222-2222-2222-222222222222'); create temp table dp as select * from create_destination('push', 'Phone', null); create temp table bsub as @@ -64,7 +72,7 @@ select pg_temp.as_user('11111111-1111-1111-1111-111111111111'); create temp table a as select public.home_flow() as p; select set_eq($$ select jsonb_object_keys(p) from a $$, - array['matched', 'delivered', 'sources', 'subscriptions', 'links'], 'payload keys'); + array['matched', 'delivered', 'sources', 'subscriptions', 'links', 'subscription_sources'], 'payload keys'); select is((select (p ->> 'matched')::int from a), 3, 'matched counts distinct spots created in the last 24 hours'); select is((select (p ->> 'delivered')::int from a), 4, 'delivered counts rows sent in the last 24 hours, one per destination'); select is((select p -> 'sources' from a), @@ -79,6 +87,22 @@ select set_eq( $$ values ((select parks from subs), (select id from dc), 1), ((select parks from subs), (select id from dw), 2), ((select dx from subs), (select id from dw), 1) $$, 'links: delivered per connection and destination'); +select set_eq( + $$ select (e ->> 'subscription_id')::uuid, e ->> 'source' from a, jsonb_array_elements(p -> 'subscription_sources') e $$, + $$ select x.id, x.source from (values + ((select parks from subs), 'pota'), + ((select dx from subs), 'dxcluster'), + ((select nowhere from subs), 'pota'), ((select nowhere from subs), 'sotawatch'), + ((select anycall from subs), 'pota'), ((select anycall from subs), 'sotawatch'), ((select anycall from subs), 'rbn'), + ((select either from subs), 'pota'), ((select either from subs), 'sotawatch'), ((select either from subs), 'rbn') + ) x(id, source) + union all + select (select everything from subs), unnest(array['pota', 'sotawatch', 'wwff', 'wwbota', 'rbn', 'dxcluster']) $$, + 'subscription_sources: every connection, traffic or not, with the sources the matcher lets through'); +select ok(not exists ( + select 1 from a, jsonb_array_elements(p -> 'subscription_sources') e + where (e ->> 'subscription_id')::uuid = (select id from bsub)), + 'subscription_sources holds only the caller''s connections'); reset role; select pg_temp.as_user('22222222-2222-2222-2222-222222222222'); @@ -86,14 +110,17 @@ select is(public.home_flow(), jsonb_build_object('matched', 1, 'delivered', 1, 'sources', '[{"source": "pota", "matched": 1}]'::jsonb, 'subscriptions', jsonb_build_array(jsonb_build_object('subscription_id', (select id from bsub), 'source', 'pota', 'matched', 1)), - 'links', jsonb_build_array(jsonb_build_object('subscription_id', (select id from bsub), 'destination_id', (select id from dp), 'delivered', 1))), + 'links', jsonb_build_array(jsonb_build_object('subscription_id', (select id from bsub), 'destination_id', (select id from dp), 'delivered', 1)), + 'subscription_sources', jsonb_build_array( + jsonb_build_object('subscription_id', (select id from bsub), 'source', 'pota'), + jsonb_build_object('subscription_id', (select id from bsub), 'source', 'sotawatch'))), 'another user sees only their own deliveries'); reset role; -- No deliveries at all: zeros and empty lists, not nulls. delete from deliveries where destination_id in (select id from dp); select pg_temp.as_user('22222222-2222-2222-2222-222222222222'); -select is(public.home_flow(), +select is(public.home_flow() - 'subscription_sources', '{"matched": 0, "delivered": 0, "sources": [], "subscriptions": [], "links": []}'::jsonb, 'an account with nothing in 24 hours gets zeros and empty lists'); reset role; diff --git a/web/src/home/HomePage.test.tsx b/web/src/home/HomePage.test.tsx index 08b1ddc1..d9c6080b 100644 --- a/web/src/home/HomePage.test.tsx +++ b/web/src/home/HomePage.test.tsx @@ -9,7 +9,7 @@ import type { HomeFlow } from "./types"; vi.mock("../lib/api", () => ({ loadHomeFlow: vi.fn(), loadLatestStats: vi.fn() })); const dest = (id: string, name: string, type: string) => ({ id, name, type, enabled: true }) as Destination; -const sub = (id: string, name: string, destinations: string[]) => ({ id, name, enabled: true, destinations }) as SubscriptionWithLinks; +const sub = (id: string, name: string, destinations: string[]) => ({ id, name, enabled: true, destinations, sources: [] as string[] }) as SubscriptionWithLinks; const DESTS = [dest("d1", "RARS Discord", "discord"), dest("d2", "Logger", "webhook")]; const SUBS = [sub("s1", "POTA Carolinas", ["d1", "d2"]), sub("s2", "DX 20m CW", ["d2"]), sub("s3", "All POTA", [])]; @@ -26,6 +26,11 @@ const FLOW: HomeFlow = { { subscription_id: "s1", destination_id: "d2", delivered: 12 }, { subscription_id: "s2", destination_id: "d2", delivered: 37 }, ], + subscription_sources: [ + { subscription_id: "s1", source: "pota" }, + { subscription_id: "s2", source: "dxcluster" }, + { subscription_id: "s3", source: "pota" }, + ], }; beforeEach(() => { @@ -66,9 +71,10 @@ describe("HomePage on desktop", () => { expect(link(/^RARS Discord Discord · 131 delivered$/)).toHaveAttribute("href", "/destinations/d1"); expect(link(/^Logger Webhook · 49 delivered$/)).toHaveAttribute("href", "/destinations/d2"); - // Two source links and three destination links, in the source's colour at 0.45, true-pixel strokes. + // Three source links (All POTA's too, though it matched nothing) and three destination links, + // in the source's colour at 0.45, true-pixel strokes. const paths = diagramPaths(container); - expect(paths).toHaveLength(5); + expect(paths).toHaveLength(6); for (const p of paths) { expect(p.getAttribute("d")).toMatch(/^M0 [\d.]+ C50 [\d.]+ 50 [\d.]+ 100 [\d.]+$/); expect(p).toHaveAttribute("vector-effect", "non-scaling-stroke"); @@ -78,6 +84,20 @@ describe("HomePage on desktop", () => { expect(container.querySelector("svg[preserveAspectRatio='none']")).toHaveAttribute("viewBox", expect.stringMatching(/^0 0 100 \d+$/)); }); + it("draws the whole configuration when nothing has flowed", async () => { + vi.mocked(loadHomeFlow).mockResolvedValue({ ...FLOW, matched: 0, delivered: 0, sources: [], subscriptions: [], links: [] }); + const { container } = render(SUBS, [...DESTS, dest("d3", "Spare", "push")]); + expect(await screen.findAllByText("0")).toHaveLength(2); + expect(link(/^POTA 0 matched$/)).toHaveAttribute("href", "/sources/pota"); + expect(link(/^DX Cluster 0 matched$/)).toBeInTheDocument(); + expect(link(/^POTA Carolinas 0 matched · 2 destinations$/)).toBeInTheDocument(); + expect(link(/^Spare Push · 0 delivered$/)).toHaveAttribute("href", "/destinations/d3"); + // Every configured link, at the 2 px floor; the spare destination has none. + const paths = diagramPaths(container); + expect(paths).toHaveLength(6); + for (const p of paths) expect(p).toHaveAttribute("stroke-width", "2"); + }); + it("caps the diagram at six connections and links the rest to Connections", async () => { const subs = Array.from({ length: 8 }, (_, i) => sub(`c${i}`, `Conn ${i}`, ["d1"])); vi.mocked(loadHomeFlow).mockResolvedValue({ diff --git a/web/src/home/model.test.ts b/web/src/home/model.test.ts index 93296adc..07da50dc 100644 --- a/web/src/home/model.test.ts +++ b/web/src/home/model.test.ts @@ -6,13 +6,14 @@ import { import type { HomeFlow } from "./types"; const dest = (id: string, type = "discord", enabled = true) => ({ id, name: `Dest ${id}`, type, enabled }) as Destination; -const sub = (id: string, destinations: string[], enabled = true) => - ({ id, name: `Conn ${id}`, enabled, destinations }) as SubscriptionWithLinks; +const sub = (id: string, destinations: string[], enabled = true, sources: string[] = []) => + ({ id, name: `Conn ${id}`, enabled, destinations, sources }) as SubscriptionWithLinks; function flow( subscriptions: [string, string, number][], links: [string, string, number][] = [], sources: [string, number][] = [], + configured: [string, string][] = [], ): HomeFlow { return { matched: 0, @@ -20,6 +21,7 @@ function flow( sources: sources.map(([source, matched]) => ({ source, matched })), subscriptions: subscriptions.map(([subscription_id, source, matched]) => ({ subscription_id, source, matched })), links: links.map(([subscription_id, destination_id, delivered]) => ({ subscription_id, destination_id, delivered })), + subscription_sources: configured.map(([subscription_id, source]) => ({ subscription_id, source })), }; } @@ -132,6 +134,45 @@ describe("buildFlow: destinations", () => { }); }); +describe("buildFlow: configuration, traffic or not", () => { + it("links a quiet connection to every source it takes spots from, at the thinnest width", () => { + const f = buildFlow([sub("a", ["d1"])], [dest("d1")], flow([], [], [], [["a", "sotawatch"], ["a", "pota"]])); + expect(f.sources).toEqual([{ id: "pota", matched: 0 }, { id: "sotawatch", matched: 0 }]); + expect(f.sourceLinks).toEqual([ + { from: 0, to: 0, value: 0, source: "pota" }, + { from: 1, to: 0, value: 0, source: "sotawatch" }, + ]); + // Coloured by its first source in catalog order while nothing has matched. + expect(f.connections[0]?.source).toBe("pota"); + expect(f.destinationLinks).toEqual([{ from: 0, to: 0, value: 0, source: "pota" }]); + }); + + it("takes a connection's sources from SQL, not its own list, so any source reads as SQL expands it", () => { + const f = buildFlow([sub("any", ["d1"], true, [])], [dest("d1")], flow([], [], [], [["any", "pota"], ["any", "sotawatch"], ["any", "rbn"]])); + expect(f.sources.map((s) => s.id)).toEqual(["pota", "sotawatch", "rbn"]); + }); + + it("falls back to the connection's own list when the counts didn't load", () => { + const f = buildFlow([sub("a", ["d1"], true, ["dxcluster"]), sub("any", ["d1"], true, [])], [dest("d1")], null); + expect(f.sources.map((s) => s.id)).toEqual(["dxcluster"]); + expect(f.connections.find((c) => c.id === "any")?.source).toBeNull(); + }); + + it("puts the busiest source first and keeps one it matched from that is no longer configured", () => { + const f = buildFlow([sub("a", ["d1"])], [dest("d1")], flow([["a", "rbn", 3], ["a", "wwff", 7]], [], [], [["a", "pota"], ["a", "rbn"]])); + expect(f.sourceLinks.map((l) => [l.source, l.value])).toEqual([["wwff", 7], ["rbn", 3], ["pota", 0]]); + expect(f.connections[0]?.source).toBe("wwff"); + }); + + it("shows destinations no connection reaches yet after the linked ones, with no links", () => { + const dests = [dest("spare-b"), dest("linked"), dest("spare-a")]; + const f = buildFlow([sub("a", ["linked"])], dests, flow([], [], [], [["a", "pota"]])); + expect(f.destinations.map((d) => d.id)).toEqual(["linked", "spare-a", "spare-b"]); + expect(f.destinationLinks.map((l) => l.to)).toEqual([0]); + expect(f.moreDestinations).toBe(0); + }); +}); + describe("destinationTotals", () => { it("lists every destination, busiest first, then by name", () => { const totals = destinationTotals([dest("b"), dest("a"), dest("c")], flow([], [["x", "c", 3], ["y", "c", 1], ["x", "b", 0]])); diff --git a/web/src/home/model.ts b/web/src/home/model.ts index 295e4373..a9e1f4f1 100644 --- a/web/src/home/model.ts +++ b/web/src/home/model.ts @@ -1,9 +1,12 @@ -// What the Home page's flow diagram shows: which sources, connections and -// destinations get a node, in what order, and the links between them. Counts come -// from home_flow(); names, types and links from the lists every signed-in page -// already has. Geometry is layout.ts's. +// What the Home page's flow diagram shows: the caller's configuration (every +// connection, the sources it takes spots from, the destinations it sends to and +// destinations nothing sends to yet), labelled and weighted by the last 24 hours. +// Nothing leaves the diagram for being quiet. Counts and each connection's sources +// come from home_flow(); names, types and destination links from the lists every +// signed-in page already has. Geometry is layout.ts's. import type { Destination, SubscriptionWithLinks } from "../lib/api"; +import { SOURCES } from "../sources/sources"; import type { LinkInput } from "./layout"; import type { HomeFlow } from "./types"; @@ -21,7 +24,7 @@ export interface SourceNode { export interface ConnectionNode { id: string; name: string; - /** The spot source it matched most from, which colours it; null when it matched nothing. */ + /** Its first source (the one it matched most from, else the first in the catalog), which colours it; null for none. */ source: string | null; /** * Null when there is no record to count: the counts didn't load, or the connection is off or has @@ -48,7 +51,7 @@ export interface Flow { sources: SourceNode[]; connections: ConnectionNode[]; destinations: DestinationNode[]; - /** Sources and destinations only hidden connections touch, counted in a "+N more" node. */ + /** Sources and destinations only hidden connections use, counted in a "+N more" node. */ moreSources: number; moreDestinations: number; /** The connections past TOP_CONNECTIONS; null when none are hidden. */ @@ -66,36 +69,57 @@ export function destinationTotals(dests: Destination[], flow: HomeFlow | null): .sort((a, b) => (b.delivered ?? 0) - (a.delivered ?? 0) || a.name.localeCompare(b.name)); } +const catalogOrder = (id: string) => { + const i = SOURCES.findIndex((s) => s.id === id); + return i < 0 ? SOURCES.length : i; +}; + export function buildFlow(subs: SubscriptionWithLinks[], dests: Destination[], flow: HomeFlow | null): Flow { const destById = new Map(dests.map((d) => [d.id, d])); - // Each connection's matched rows by spot source, busiest first. - const bySub = new Map(); + const matchedBySub = new Map>(); for (const r of flow?.subscriptions ?? []) { - bySub.set(r.subscription_id, [...(bySub.get(r.subscription_id) ?? []), { source: r.source, matched: r.matched }]); + const matched = matchedBySub.get(r.subscription_id) ?? new Map(); + matched.set(r.source, r.matched); + matchedBySub.set(r.subscription_id, matched); } - for (const rows of bySub.values()) rows.sort((a, b) => b.matched - a.matched || a.source.localeCompare(b.source)); const sentBySub = new Map>(); for (const l of flow?.links ?? []) { const sent = sentBySub.get(l.subscription_id) ?? new Map(); sent.set(l.destination_id, l.delivered); sentBySub.set(l.subscription_id, sent); } + const configured = new Map(); + for (const r of flow?.subscription_sources ?? []) configured.set(r.subscription_id, [...(configured.get(r.subscription_id) ?? []), r.source]); + + // A connection's sources as SQL expands them (its own list when the counts didn't load), plus any it + // matched from in the window since; busiest first, then in catalog order. + const sourcesOf = (s: SubscriptionWithLinks): { source: string; matched: number }[] => { + const matched = matchedBySub.get(s.id) ?? new Map(); + const ids = new Set([...(flow ? (configured.get(s.id) ?? []) : s.sources), ...matched.keys()]); + return [...ids] + .map((source) => ({ source, matched: matched.get(source) ?? 0 })) + .sort((a, b) => b.matched - a.matched || catalogOrder(a.source) - catalogOrder(b.source)); + }; + const subSources = new Map(subs.map((s) => [s.id, sourcesOf(s)])); // The matcher's test for a connection it would deliver for. const live = (s: SubscriptionWithLinks) => s.enabled && s.destinations.some((id) => destById.get(id)?.enabled === true); const nodes: ConnectionNode[] = subs.map((s) => { - const rows = bySub.get(s.id) ?? []; - const recorded = rows.length > 0 ? rows.reduce((n, r) => n + r.matched, 0) : live(s) ? 0 : null; - return { id: s.id, name: s.name, source: rows[0]?.source ?? null, matched: flow ? recorded : null, destinations: s.destinations.length }; + const matched = matchedBySub.get(s.id); + const recorded = matched ? [...matched.values()].reduce((n, m) => n + m, 0) : live(s) ? 0 : null; + return { + id: s.id, name: s.name, source: subSources.get(s.id)?.[0]?.source ?? null, matched: flow ? recorded : null, + destinations: s.destinations.length, + }; }); const ranked = [...nodes].sort((a, b) => (b.matched ?? 0) - (a.matched ?? 0) || a.name.localeCompare(b.name)); const shown = ranked.slice(0, TOP_CONNECTIONS); const hidden = ranked.slice(TOP_CONNECTIONS); - // Grouped under their top source to keep links from crossing: groups in the order their busiest - // connection ranks, connections that matched nothing last. + // Grouped under their first source to keep links from crossing: groups in the order their busiest + // connection ranks, connections without a source last. const groups: (string | null)[] = []; for (const c of shown) if (c.source !== null && !groups.includes(c.source)) groups.push(c.source); groups.push(null); @@ -104,9 +128,9 @@ export function buildFlow(subs: SubscriptionWithLinks[], dests: Destination[], f // Sources in the order the connections first use them. const sourceTotals = new Map((flow?.sources ?? []).map((s) => [s.source, s.matched])); const sourceIds: string[] = []; - for (const c of connections) for (const r of bySub.get(c.id) ?? []) if (!sourceIds.includes(r.source)) sourceIds.push(r.source); + for (const c of connections) for (const r of subSources.get(c.id) ?? []) if (!sourceIds.includes(r.source)) sourceIds.push(r.source); const sources = sourceIds.map((id) => ({ id, matched: sourceTotals.get(id) ?? 0 })); - const hiddenSources = new Set(hidden.flatMap((c) => (bySub.get(c.id) ?? []).map((r) => r.source))); + const hiddenSources = new Set(hidden.flatMap((c) => (subSources.get(c.id) ?? []).map((r) => r.source))); const moreSources = [...hiddenSources].filter((id) => !sourceIds.includes(id)).length; // A connection's destinations: the ones it links to, and any it sent to in the window since unlinked. @@ -117,7 +141,8 @@ export function buildFlow(subs: SubscriptionWithLinks[], dests: Destination[], f return [...ids].filter((d) => destById.has(d)); }; - // Destinations ordered by the mean row of the connections that reach them. + // Destinations the shown connections reach, by the mean row of those connections, then the ones no + // connection reaches yet. const rowsOf = new Map(); connections.forEach((c, i) => { for (const d of linked(c.id)) rowsOf.set(d, [...(rowsOf.get(d) ?? []), i]); @@ -126,16 +151,20 @@ export function buildFlow(subs: SubscriptionWithLinks[], dests: Destination[], f const rows = rowsOf.get(id) ?? []; return rows.reduce((a, b) => a + b, 0) / Math.max(rows.length, 1); }; - const destinations = destinationTotals(dests, flow) - .filter((d) => rowsOf.has(d.id)) - .sort((a, b) => mean(a.id) - mean(b.id) || (b.delivered ?? 0) - (a.delivered ?? 0) || a.name.localeCompare(b.name)); - const hiddenDestinations = new Set(hidden.flatMap((c) => linked(c.id))); - const moreDestinations = [...hiddenDestinations].filter((id) => !rowsOf.has(id)).length; + const totals = destinationTotals(dests, flow); + const reachedByHidden = new Set(hidden.flatMap((c) => linked(c.id))); + const destinations = [ + ...totals + .filter((d) => rowsOf.has(d.id)) + .sort((a, b) => mean(a.id) - mean(b.id) || (b.delivered ?? 0) - (a.delivered ?? 0) || a.name.localeCompare(b.name)), + ...totals.filter((d) => !rowsOf.has(d.id) && !reachedByHidden.has(d.id)).sort((a, b) => a.name.localeCompare(b.name)), + ]; + const moreDestinations = [...reachedByHidden].filter((id) => !rowsOf.has(id)).length; const sourceRow = new Map(sources.map((s, i) => [s.id, i])); const destinationRow = new Map(destinations.map((d, i) => [d.id, i])); const sourceLinks = connections.flatMap((c, i) => - (bySub.get(c.id) ?? []).map((r) => ({ from: sourceRow.get(r.source) ?? 0, to: i, value: r.matched, source: r.source })) + (subSources.get(c.id) ?? []).map((r) => ({ from: sourceRow.get(r.source) ?? 0, to: i, value: r.matched, source: r.source })) ); const destinationLinks = connections.flatMap((c, i) => linked(c.id).map((d) => ({ from: i, to: destinationRow.get(d) ?? 0, value: sentBySub.get(c.id)?.get(d) ?? 0, source: c.source })) diff --git a/web/src/home/types.ts b/web/src/home/types.ts index 98e83b8c..8cea2978 100644 --- a/web/src/home/types.ts +++ b/web/src/home/types.ts @@ -1,8 +1,8 @@ /** - * home_flow()'s payload: the caller's last 24 hours of deliveries, pinned by - * supabase/tests/home_flow.test.sql. `matched` counts distinct spots, `delivered` - * sent rows (one per destination). A connection with no row in `subscriptions` - * matched nothing that was recorded. + * home_flow()'s payload, pinned by supabase/tests/home_flow.test.sql: the caller's + * last 24 hours of deliveries, and which sources each connection takes spots from. + * `matched` counts distinct spots, `delivered` sent rows (one per destination). A + * connection with no row in `subscriptions` matched nothing that was recorded. */ export interface HomeFlow { matched: number; @@ -13,4 +13,6 @@ export interface HomeFlow { subscriptions: { subscription_id: string; source: string; matched: number }[]; /** Sent spots per connection and destination. */ links: { subscription_id: string; destination_id: string; delivered: number }[]; + /** Every connection's sources as the matcher reads them ("any" expanded, RBN only with stations), traffic or not. */ + subscription_sources: { subscription_id: string; source: string }[]; } diff --git a/web/src/lib/api.test.tsx b/web/src/lib/api.test.tsx index 76f875d2..6df96445 100644 --- a/web/src/lib/api.test.tsx +++ b/web/src/lib/api.test.tsx @@ -702,7 +702,7 @@ describe("callsign groups", () => { }); it("loadHomeFlow returns home_flow's payload", async () => { - const flow = { matched: 3, delivered: 4, sources: [], subscriptions: [], links: [] }; + const flow = { matched: 3, delivered: 4, sources: [], subscriptions: [], links: [], subscription_sources: [] }; fake.rpcs.set("home_flow", { data: flow }); expect(await api.loadHomeFlow()).toEqual(flow); expect(fake.client.rpc).toHaveBeenCalledWith("home_flow"); From aa764f77569baf7b052ba698152ae52f9ab39366 Mon Sep 17 00:00:00 2001 From: Brad Bazemore Date: Tue, 27 Oct 2026 11:58:28 -0400 Subject: [PATCH 2/3] Home page: show every connection, no cap The diagram is the system's configuration, so it no longer folds quiet connections into an "N more connections" node. Every connection, source and destination gets its own node; the "more" nodes and TOP_CONNECTIONS are gone. Co-Authored-By: Claude Opus 5.5 --- CLAUDE.md | 7 +++--- web/src/home/FlowDiagram.tsx | 42 +++++++++------------------------- web/src/home/HomePage.test.tsx | 26 ++++++--------------- web/src/home/HomePage.tsx | 4 ++-- web/src/home/model.test.ts | 41 +++++++++------------------------ web/src/home/model.ts | 29 ++++------------------- 6 files changed, 38 insertions(+), 111 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 47922080..5c08ab0b 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -691,9 +691,8 @@ profile canvas); its Copy section ("Reference page") is the copy source for `/re - `/home` is the signed-in landing page (sign-in, set password and unknown paths go there) and is not public. Its copy comes from Brad's "Long Lines home" canvas. -- The diagram draws the configuration, not the traffic: every connection (the top - `TOP_CONNECTIONS` by matched, the rest in a "more" node), every source each takes spots from and - every destination, linked or not. The last 24 hours only label nodes and weight links; never drop +- The diagram draws the configuration, not the traffic: every connection (no cap), every source + each takes spots from and every destination, linked or not. The last 24 hours only label nodes and weight links; never drop a node or link for being quiet. - `home_flow()` gives what the lists `SignedIn` loads can't: the caller's last 24 hours of `deliveries` (matched = distinct spots by `created_at`, delivered = sent rows by `sent_at`) and @@ -705,7 +704,7 @@ profile canvas); its Copy section ("Reference page") is the copy source for `/re the page shows an en dash for it; never count matches live over `raw_spots` per visitor. - The diagram is plain MUI plus one SVG per gap (`preserveAspectRatio="none"`, `vector-effect="non-scaling-stroke"`); no chart or graph dependency. Geometry is the pure - `home/layout.ts`, node choice and order `home/model.ts` (`TOP_CONNECTIONS`). Link colours are + `home/layout.ts`, node choice and order `home/model.ts`. Link colours are each source's catalog `markColor` from the theme (`home/colors.ts`), never hex. ## Callsign group rules diff --git a/web/src/home/FlowDiagram.tsx b/web/src/home/FlowDiagram.tsx index cd68e3b6..5a8118d8 100644 --- a/web/src/home/FlowDiagram.tsx +++ b/web/src/home/FlowDiagram.tsx @@ -1,29 +1,23 @@ import type { ReactNode } from "react"; import { Box, Card, CardActionArea, Stack, Typography, useTheme } from "@mui/material"; -import { styled } from "@mui/material/styles"; import SendIcon from "@mui/icons-material/Send"; import { Link as RouterLink } from "react-router"; import { sourceName } from "../sources/sources"; import { sourceColor } from "./colors"; import { columnHeight, linkPaths, NODE_HEIGHT, NODE_WIDTH, type LinkPath } from "./layout"; -import { connectionDetail, count, destinationDetail, moreConnectionsName, type Flow, type FlowLink } from "./model"; - -/** The "more" nodes stand for several, so their outline is dashed. */ -const MoreCard = styled(Card)({ borderStyle: "dashed" }); +import { connectionDetail, count, destinationDetail, type Flow, type FlowLink } from "./model"; interface NodeProps { to: string; icon: ReactNode; name: string; detail: string; - more?: boolean; } /** One 56 px node: an icon or colour dot, a name and a caption, linking to its page. */ -export function FlowNode({ to, icon, name, detail, more = false }: NodeProps) { - const Frame = more ? MoreCard : Card; +export function FlowNode({ to, icon, name, detail }: NodeProps) { return ( - + {icon} @@ -31,7 +25,7 @@ export function FlowNode({ to, icon, name, detail, more = false }: NodeProps) { {detail} - + ); } @@ -71,12 +65,10 @@ function Links({ height, paths, links }: { height: number; paths: LinkPath[]; li /** Sources, connections and destinations in three columns, joined by curved links (desktop only). */ export function FlowDiagram({ flow }: { flow: Flow }) { const theme = useTheme(); - const sourceRows = flow.sources.length + (flow.moreSources > 0 ? 1 : 0); - const connectionRows = flow.connections.length + (flow.moreConnections ? 1 : 0); - const destinationRows = flow.destinations.length + (flow.moreDestinations > 0 ? 1 : 0); - const height = columnHeight(Math.max(sourceRows, connectionRows, destinationRows)); - const left = linkPaths(sourceRows, connectionRows, height, flow.sourceLinks); - const right = linkPaths(connectionRows, destinationRows, height, flow.destinationLinks); + const { sources, connections, destinations } = flow; + const height = columnHeight(Math.max(sources.length, connections.length, destinations.length)); + const left = linkPaths(sources.length, connections.length, height, flow.sourceLinks); + const right = linkPaths(connections.length, destinations.length, height, flow.destinationLinks); const heading = (label: string) => ( {label} ); @@ -93,39 +85,27 @@ export function FlowDiagram({ flow }: { flow: Flow }) { - {flow.sources.map((s) => ( + {sources.map((s) => ( } name={sourceName(s.id)} detail={`${count(s.matched)} matched`} /> ))} - {flow.moreSources > 0 && ( - } name={`+${flow.moreSources} more`} detail="View all" more /> - )} - {flow.connections.map((c) => ( + {connections.map((c) => ( } name={c.name} detail={connectionDetail(c)} /> ))} - {flow.moreConnections && ( - } name={moreConnectionsName(flow.moreConnections.count)} - detail={`${count(flow.moreConnections.matched)} matched · View all`} more - /> - )} - {flow.destinations.map((d) => ( + {destinations.map((d) => ( } name={d.name} detail={destinationDetail(d)} /> ))} - {flow.moreDestinations > 0 && ( - } name={`+${flow.moreDestinations} more`} detail="View all" more /> - )} diff --git a/web/src/home/HomePage.test.tsx b/web/src/home/HomePage.test.tsx index d9c6080b..a986991a 100644 --- a/web/src/home/HomePage.test.tsx +++ b/web/src/home/HomePage.test.tsx @@ -98,30 +98,18 @@ describe("HomePage on desktop", () => { for (const p of paths) expect(p).toHaveAttribute("stroke-width", "2"); }); - it("caps the diagram at six connections and links the rest to Connections", async () => { - const subs = Array.from({ length: 8 }, (_, i) => sub(`c${i}`, `Conn ${i}`, ["d1"])); + it("shows every connection, with no cap", async () => { + const subs = Array.from({ length: 10 }, (_, i) => sub(`c${i}`, `Conn ${i}`, ["d1"])); vi.mocked(loadHomeFlow).mockResolvedValue({ ...FLOW, - subscriptions: subs.map((s, i) => ({ subscription_id: s.id, source: "pota", matched: 10 - i })), + subscriptions: subs.slice(0, 3).map((s, i) => ({ subscription_id: s.id, source: "pota", matched: 10 - i })), links: [], + subscription_sources: subs.map((s) => ({ subscription_id: s.id, source: "pota" })), }); render(subs); - expect(await screen.findByText("Last 24 hours. Line width shows how many spots matched. Showing your top 6 connections.")).toBeInTheDocument(); - expect(link(/^2 more connections 7 matched · View all$/)).toHaveAttribute("href", "/connections"); - expect(screen.queryByRole("link", { name: /^Conn 6/ })).not.toBeInTheDocument(); - }); - - it("counts sources and destinations only hidden connections touch", async () => { - const subs = Array.from({ length: 7 }, (_, i) => sub(`c${i}`, `Conn ${i}`, i === 6 ? ["d2"] : ["d1"])); - vi.mocked(loadHomeFlow).mockResolvedValue({ - ...FLOW, - subscriptions: subs.map((s, i) => ({ subscription_id: s.id, source: i === 6 ? "rbn" : "pota", matched: 10 - i })), - links: [], - }); - render(subs); - await screen.findByText("1 more connection"); - const more = screen.getAllByRole("link", { name: /^\+1 more View all$/ }); - expect(more.map((a) => a.getAttribute("href"))).toEqual(["/sources", "/destinations"]); + expect(await screen.findByText("Last 24 hours. Line width shows how many spots matched.")).toBeInTheDocument(); + for (const s of subs) expect(link(new RegExp(`^${s.name} `))).toHaveAttribute("href", `/connections/${s.id}`); + expect(screen.queryByText(/more connection/)).not.toBeInTheDocument(); }); it("shows en dashes and toasts when the counts don't load", async () => { diff --git a/web/src/home/HomePage.tsx b/web/src/home/HomePage.tsx index 7777b82e..ecc5930a 100644 --- a/web/src/home/HomePage.tsx +++ b/web/src/home/HomePage.tsx @@ -8,7 +8,7 @@ import { errorText, useNotify } from "../app/hooks"; import { SourcePickerDialog } from "../subscriptions/SourcePickerDialog"; import { DestinationDialog } from "../destinations/DestinationDialog"; import { DestinationIcon, FlowDiagram, FlowNode } from "./FlowDiagram"; -import { buildFlow, count, destinationDetail, destinationTotals, DASH, TOP_CONNECTIONS } from "./model"; +import { buildFlow, count, destinationDetail, destinationTotals, DASH } from "./model"; import type { HomeFlow } from "./types"; interface Props { @@ -97,7 +97,7 @@ export function HomePage({ subs, dests, loading }: Props) { const caption = phone ? "Last 24 hours, by destination." - : `Last 24 hours. Line width shows how many spots matched.${model.moreConnections ? ` Showing your top ${TOP_CONNECTIONS} connections.` : ""}`; + : "Last 24 hours. Line width shows how many spots matched."; return ( <> diff --git a/web/src/home/model.test.ts b/web/src/home/model.test.ts index 07da50dc..4ab10fb8 100644 --- a/web/src/home/model.test.ts +++ b/web/src/home/model.test.ts @@ -1,7 +1,7 @@ import { describe, expect, it } from "vitest"; import type { Destination, SubscriptionWithLinks } from "../lib/api"; import { - buildFlow, connectionDetail, count, destinationDetail, destinationTotals, moreConnectionsName, TOP_CONNECTIONS, + buildFlow, connectionDetail, count, destinationDetail, destinationTotals, } from "./model"; import type { HomeFlow } from "./types"; @@ -53,15 +53,13 @@ describe("buildFlow: connections", () => { expect(f.destinations[0]?.delivered).toBeNull(); }); - it(`shows the top ${TOP_CONNECTIONS} by matched and folds the rest into one node`, () => { - const subs = Array.from({ length: 9 }, (_, i) => sub(`c${i}`, ["d1"])); - const f = buildFlow(subs, dests, flow(subs.map((s, i) => [s.id, "pota", 100 - i]))); - expect(f.connections.map((c) => c.id)).toEqual(["c0", "c1", "c2", "c3", "c4", "c5"]); - expect(f.moreConnections).toEqual({ count: 3, matched: 94 + 93 + 92 }); - }); - - it("has no more node when everything fits", () => { - expect(buildFlow([sub("a", ["d1"])], dests, flow([])).moreConnections).toBeNull(); + it("shows every connection, busiest first, however many there are and whether or not they matched", () => { + const subs = Array.from({ length: 12 }, (_, i) => sub(`c${i}`, ["d1"])); + const f = buildFlow(subs, dests, flow(subs.slice(0, 9).map((s, i) => [s.id, "pota", 100 - i]), [], [], subs.map((s) => [s.id, "pota"]))); + // The quiet ones follow by name ("Conn c10" sorts before "Conn c9"). + expect(f.connections.map((c) => c.id)).toEqual(["c0", "c1", "c2", "c3", "c4", "c5", "c6", "c7", "c8", "c10", "c11", "c9"]); + expect(f.connections.slice(9).map((c) => c.matched)).toEqual([0, 0, 0]); + expect(f.sourceLinks).toHaveLength(12); }); it("groups connections under their source in the order each group's busiest ranks, idle ones last", () => { @@ -87,15 +85,6 @@ describe("buildFlow: sources", () => { { from: 2, to: 1, value: 5, source: "rbn" }, ]); }); - - it("counts sources only hidden connections touch", () => { - const subs = Array.from({ length: 8 }, (_, i) => sub(`c${i}`, [])); - const rows: [string, string, number][] = subs.map((s, i) => [s.id, "pota", 100 - i]); - rows.push(["c6", "rbn", 1], ["c7", "wwff", 1]); - const f = buildFlow(subs, [], flow(rows)); - expect(f.sources.map((s) => s.id)).toEqual(["pota"]); - expect(f.moreSources).toBe(2); - }); }); describe("buildFlow: destinations", () => { @@ -125,12 +114,10 @@ describe("buildFlow: destinations", () => { expect(f.destinationLinks).toEqual([]); }); - it("counts destinations only hidden connections reach, and totals include hidden connections", () => { - const dests = [dest("seen"), dest("hidden")]; - const subs = Array.from({ length: 7 }, (_, i) => sub(`c${i}`, i === 6 ? ["hidden", "seen"] : ["seen"])); - const f = buildFlow(subs, dests, flow(subs.map((s, i) => [s.id, "pota", 100 - i]), [["c0", "seen", 1], ["c6", "seen", 5]])); + it("totals a destination's deliveries over every connection", () => { + const subs = [sub("a", ["seen"]), sub("b", ["seen"])]; + const f = buildFlow(subs, [dest("seen")], flow([], [["a", "seen", 1], ["b", "seen", 5]])); expect(f.destinations.map((d) => [d.id, d.delivered])).toEqual([["seen", 6]]); - expect(f.moreDestinations).toBe(1); }); }); @@ -169,7 +156,6 @@ describe("buildFlow: configuration, traffic or not", () => { const f = buildFlow([sub("a", ["linked"])], dests, flow([], [], [], [["a", "pota"]])); expect(f.destinations.map((d) => d.id)).toEqual(["linked", "spare-a", "spare-b"]); expect(f.destinationLinks.map((l) => l.to)).toEqual([0]); - expect(f.moreDestinations).toBe(0); }); }); @@ -200,9 +186,4 @@ describe("copy", () => { expect(destinationDetail({ id: "d", name: "D", type: "webhook", delivered: 49 })).toBe("Webhook · 49 delivered"); expect(destinationDetail({ id: "d", name: "D", type: "pager", delivered: 1 })).toBe("pager · 1 delivered"); }); - - it("names the more node", () => { - expect(moreConnectionsName(9)).toBe("9 more connections"); - expect(moreConnectionsName(1)).toBe("1 more connection"); - }); }); diff --git a/web/src/home/model.ts b/web/src/home/model.ts index a9e1f4f1..9fdba51b 100644 --- a/web/src/home/model.ts +++ b/web/src/home/model.ts @@ -10,8 +10,6 @@ import { SOURCES } from "../sources/sources"; import type { LinkInput } from "./layout"; import type { HomeFlow } from "./types"; -/** How many connections the diagram shows; the rest fold into one "N more connections" node. */ -export const TOP_CONNECTIONS = 6; /** A missing number. */ export const DASH = "–"; @@ -51,11 +49,6 @@ export interface Flow { sources: SourceNode[]; connections: ConnectionNode[]; destinations: DestinationNode[]; - /** Sources and destinations only hidden connections use, counted in a "+N more" node. */ - moreSources: number; - moreDestinations: number; - /** The connections past TOP_CONNECTIONS; null when none are hidden. */ - moreConnections: { count: number; matched: number } | null; sourceLinks: FlowLink[]; destinationLinks: FlowLink[]; } @@ -115,23 +108,19 @@ export function buildFlow(subs: SubscriptionWithLinks[], dests: Destination[], f }); const ranked = [...nodes].sort((a, b) => (b.matched ?? 0) - (a.matched ?? 0) || a.name.localeCompare(b.name)); - const shown = ranked.slice(0, TOP_CONNECTIONS); - const hidden = ranked.slice(TOP_CONNECTIONS); // Grouped under their first source to keep links from crossing: groups in the order their busiest // connection ranks, connections without a source last. const groups: (string | null)[] = []; - for (const c of shown) if (c.source !== null && !groups.includes(c.source)) groups.push(c.source); + for (const c of ranked) if (c.source !== null && !groups.includes(c.source)) groups.push(c.source); groups.push(null); - const connections = groups.flatMap((g) => shown.filter((c) => c.source === g)); + const connections = groups.flatMap((g) => ranked.filter((c) => c.source === g)); // Sources in the order the connections first use them. const sourceTotals = new Map((flow?.sources ?? []).map((s) => [s.source, s.matched])); const sourceIds: string[] = []; for (const c of connections) for (const r of subSources.get(c.id) ?? []) if (!sourceIds.includes(r.source)) sourceIds.push(r.source); const sources = sourceIds.map((id) => ({ id, matched: sourceTotals.get(id) ?? 0 })); - const hiddenSources = new Set(hidden.flatMap((c) => (subSources.get(c.id) ?? []).map((r) => r.source))); - const moreSources = [...hiddenSources].filter((id) => !sourceIds.includes(id)).length; // A connection's destinations: the ones it links to, and any it sent to in the window since unlinked. const subById = new Map(subs.map((s) => [s.id, s])); @@ -141,7 +130,7 @@ export function buildFlow(subs: SubscriptionWithLinks[], dests: Destination[], f return [...ids].filter((d) => destById.has(d)); }; - // Destinations the shown connections reach, by the mean row of those connections, then the ones no + // Destinations the connections reach, by the mean row of those connections, then the ones no // connection reaches yet. const rowsOf = new Map(); connections.forEach((c, i) => { @@ -152,14 +141,12 @@ export function buildFlow(subs: SubscriptionWithLinks[], dests: Destination[], f return rows.reduce((a, b) => a + b, 0) / Math.max(rows.length, 1); }; const totals = destinationTotals(dests, flow); - const reachedByHidden = new Set(hidden.flatMap((c) => linked(c.id))); const destinations = [ ...totals .filter((d) => rowsOf.has(d.id)) .sort((a, b) => mean(a.id) - mean(b.id) || (b.delivered ?? 0) - (a.delivered ?? 0) || a.name.localeCompare(b.name)), - ...totals.filter((d) => !rowsOf.has(d.id) && !reachedByHidden.has(d.id)).sort((a, b) => a.name.localeCompare(b.name)), + ...totals.filter((d) => !rowsOf.has(d.id)).sort((a, b) => a.name.localeCompare(b.name)), ]; - const moreDestinations = [...reachedByHidden].filter((id) => !rowsOf.has(id)).length; const sourceRow = new Map(sources.map((s, i) => [s.id, i])); const destinationRow = new Map(destinations.map((d, i) => [d.id, i])); @@ -174,9 +161,6 @@ export function buildFlow(subs: SubscriptionWithLinks[], dests: Destination[], f sources, connections, destinations, - moreSources, - moreDestinations, - moreConnections: hidden.length > 0 ? { count: hidden.length, matched: hidden.reduce((n, c) => n + (c.matched ?? 0), 0) } : null, sourceLinks, destinationLinks, }; @@ -200,8 +184,3 @@ const TYPE_LABELS: Record = { discord: "Discord", webhook: "Webh export function destinationDetail(d: DestinationNode): string { return `${TYPE_LABELS[d.type] ?? d.type} · ${count(d.delivered)} delivered`; } - -/** "9 more connections", "1 more connection". */ -export function moreConnectionsName(n: number): string { - return `${count(n)} more connection${n === 1 ? "" : "s"}`; -} From 8f9969daf1c140b508f28e5390b4eb1bcf571298 Mon Sep 17 00:00:00 2001 From: Brad Bazemore Date: Tue, 27 Oct 2026 12:04:48 -0400 Subject: [PATCH 3/3] home_flow: subscription_sources in a new migration 20261027001300_home_flow merged with #276 and may already be hosted, so it goes back to exactly what main has, and subscription_sources ships as 20261027001400_home_flow_sources (create or replace, same signature, same grants). Co-Authored-By: Claude Opus 5.5 --- .../migrations/20261027001300_home_flow.sql | 24 ++----- .../20261027001400_home_flow_sources.sql | 66 +++++++++++++++++++ supabase/tests/home_flow.test.sql | 8 +-- 3 files changed, 74 insertions(+), 24 deletions(-) create mode 100644 supabase/migrations/20261027001400_home_flow_sources.sql diff --git a/supabase/migrations/20261027001300_home_flow.sql b/supabase/migrations/20261027001300_home_flow.sql index 1f849569..adc74ac0 100644 --- a/supabase/migrations/20261027001300_home_flow.sql +++ b/supabase/migrations/20261027001300_home_flow.sql @@ -1,13 +1,7 @@ --- The Home page's diagram (the "Long Lines home" canvas) draws the caller's configuration, and --- this gives it the two things the web app can't read itself: the counts for the caller's last 24 --- hours of deliveries (the only record of a match), and which sources each connection takes spots --- from. Names, types and destination links come from the tables the web app already reads. It --- returns no spot data and stays out of api_call (the management API is configuration only). --- --- subscription_sources is every (connection, source) pair the matcher would let through, decided --- by its own predicates: source_matches (an empty list is "any" of POTA, SOTAwatch and RBN) and, --- for RBN, an AND group that names stations, as in spot_matches. The source list is --- normalize_subscription_sources'; a new source goes in both. +-- The Home page's counts (the "Long Lines home" canvas): the caller's last 24 hours of +-- deliveries, which are the only record of a match. Names, types, sources and links come from +-- the tables the web app already reads; this returns numbers only, never spot data, and stays +-- out of api_call (the management API is configuration only). -- -- What the numbers can and cannot say: -- * A connection with no enabled destination, or a disabled one, makes no deliveries @@ -59,16 +53,6 @@ as $$ select jsonb_agg(jsonb_build_object('subscription_id', s.subscription_id, 'destination_id', s.destination_id, 'delivered', s.n) order by s.subscription_id, s.destination_id) from (select subscription_id, destination_id, count(*) as n from sent group by subscription_id, destination_id) s - ), '[]'::jsonb), - 'subscription_sources', coalesce(( - select jsonb_agg(jsonb_build_object('subscription_id', s.id, 'source', src.source) order by s.id, src.source) - from subscriptions s - cross join unnest(array['pota', 'sotawatch', 'wwff', 'wwbota', 'rbn', 'dxcluster']) as src(source) - where s.user_id = auth.uid() - and public.source_matches(s.sources, src.source) - and (src.source <> 'rbn' or exists ( - select 1 from jsonb_array_elements(s.rules) as g - where public.rules_name_stations(jsonb_build_array(g)))) ), '[]'::jsonb) ) where auth.uid() is not null; diff --git a/supabase/migrations/20261027001400_home_flow_sources.sql b/supabase/migrations/20261027001400_home_flow_sources.sql new file mode 100644 index 00000000..5911f34a --- /dev/null +++ b/supabase/migrations/20261027001400_home_flow_sources.sql @@ -0,0 +1,66 @@ +-- home_flow() (20261027001300) also returns subscription_sources, so the Home page can draw the +-- configuration rather than only the traffic: every (connection, source) pair the matcher would +-- let through, traffic or not, decided by its own predicates: source_matches (an empty list is +-- "any" of POTA, SOTAwatch and RBN) and, for RBN, an AND group that names stations, as in +-- spot_matches. The source list is normalize_subscription_sources'; a new source goes in both. +-- The rest of the payload is unchanged: the caller's last 24 hours of deliveries (matched = +-- distinct spots by created_at, delivered = sent rows by sent_at). No spot data, and still out of +-- api_call. The shape is HomeFlow in web/src/home/types.ts, pinned by +-- supabase/tests/home_flow.test.sql. + +create or replace function public.home_flow() +returns jsonb +language sql +stable +security definer +set search_path = public +as $$ + with mine as ( + select d.subscription_id, d.destination_id, d.spot_id, d.created_at, d.sent_at + from destinations dd + join deliveries d on d.destination_id = dd.id + where dd.user_id = auth.uid() + and (d.created_at > now() - interval '24 hours' or d.sent_at > now() - interval '24 hours') + ), + matched as ( + select m.subscription_id, r.source, m.spot_id + from mine m + join raw_spots r on r.id = m.spot_id + where m.created_at > now() - interval '24 hours' + ), + sent as ( + select m.subscription_id, m.destination_id from mine m where m.sent_at > now() - interval '24 hours' + ) + select jsonb_build_object( + 'matched', (select count(distinct spot_id) from matched), + 'delivered', (select count(*) from sent), + 'sources', coalesce(( + select jsonb_agg(jsonb_build_object('source', s.source, 'matched', s.n) order by s.source) + from (select source, count(distinct spot_id) as n from matched group by source) s + ), '[]'::jsonb), + 'subscriptions', coalesce(( + select jsonb_agg(jsonb_build_object('subscription_id', s.subscription_id, 'source', s.source, 'matched', s.n) + order by s.subscription_id, s.source) + from (select subscription_id, source, count(distinct spot_id) as n from matched group by subscription_id, source) s + ), '[]'::jsonb), + 'links', coalesce(( + select jsonb_agg(jsonb_build_object('subscription_id', s.subscription_id, 'destination_id', s.destination_id, 'delivered', s.n) + order by s.subscription_id, s.destination_id) + from (select subscription_id, destination_id, count(*) as n from sent group by subscription_id, destination_id) s + ), '[]'::jsonb), + 'subscription_sources', coalesce(( + select jsonb_agg(jsonb_build_object('subscription_id', s.id, 'source', src.source) order by s.id, src.source) + from subscriptions s + cross join unnest(array['pota', 'sotawatch', 'wwff', 'wwbota', 'rbn', 'dxcluster']) as src(source) + where s.user_id = auth.uid() + and public.source_matches(s.sources, src.source) + and (src.source <> 'rbn' or exists ( + select 1 from jsonb_array_elements(s.rules) as g + where public.rules_name_stations(jsonb_build_array(g)))) + ), '[]'::jsonb) + ) + where auth.uid() is not null; +$$; + +revoke all on function public.home_flow() from public; +grant execute on function public.home_flow() to authenticated; diff --git a/supabase/tests/home_flow.test.sql b/supabase/tests/home_flow.test.sql index 19c0e037..64dc40c4 100644 --- a/supabase/tests/home_flow.test.sql +++ b/supabase/tests/home_flow.test.sql @@ -1,7 +1,7 @@ --- pgTAP: home_flow() (20261027001300_home_flow), the Home page's counts and each --- connection's sources. Pins the payload shape (HomeFlow in web/src/home/types.ts), the --- 24-hour windows (matched by created_at, delivered by sent_at), distinct spots, the --- matcher's source rules, per-user isolation and access. +-- pgTAP: home_flow() (20261027001300_home_flow, 20261027001400_home_flow_sources), the +-- Home page's counts and each connection's sources. Pins the payload shape (HomeFlow in +-- web/src/home/types.ts), the 24-hour windows (matched by created_at, delivered by sent_at), +-- distinct spots, the matcher's source rules, per-user isolation and access. begin; create extension if not exists pgtap with schema extensions; \ir helpers/users.psql