diff --git a/CLAUDE.md b/CLAUDE.md index 745e77c6..5c08ab0b 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -691,16 +691,20 @@ 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 (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 + `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. - 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 @@ -805,7 +809,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/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 5d494719..64dc40c4 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, 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 -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/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 08b1ddc1..a986991a 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,30 +84,32 @@ describe("HomePage on desktop", () => { expect(container.querySelector("svg[preserveAspectRatio='none']")).toHaveAttribute("viewBox", expect.stringMatching(/^0 0 100 \d+$/)); }); - 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({ - ...FLOW, - subscriptions: subs.map((s, i) => ({ subscription_id: s.id, source: "pota", matched: 10 - i })), - links: [], - }); - 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("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("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"])); + 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: i === 6 ? "rbn" : "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); - 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 93296adc..4ab10fb8 100644 --- a/web/src/home/model.test.ts +++ b/web/src/home/model.test.ts @@ -1,18 +1,19 @@ 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"; 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 })), }; } @@ -51,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", () => { @@ -85,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", () => { @@ -123,12 +114,48 @@ 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); + }); +}); + +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]); }); }); @@ -159,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 295e4373..9fdba51b 100644 --- a/web/src/home/model.ts +++ b/web/src/home/model.ts @@ -1,14 +1,15 @@ -// 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"; -/** 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 = "–"; @@ -21,7 +22,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,11 +49,6 @@ export interface Flow { sources: SourceNode[]; connections: ConnectionNode[]; destinations: DestinationNode[]; - /** Sources and destinations only hidden connections touch, 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[]; } @@ -66,48 +62,65 @@ 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); + 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 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 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])); @@ -117,7 +130,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 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 +140,18 @@ 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 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)).sort((a, b) => a.name.localeCompare(b.name)), + ]; 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 })) @@ -145,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, }; @@ -171,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"}`; -} 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");