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");