diff --git a/CLAUDE.md b/CLAUDE.md
index fde6e69a..745e77c6 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -687,6 +687,22 @@ profile canvas); its Copy section ("Reference page") is the copy source for `/re
POTA `primary` in the middle, SOTA `secondary` on top, all from `theme.palette`, never hex. The
band plan (`web/src/lib/bandplan.ts`) is for display only and never decides a spot's mode.
+## Home page rules
+
+- `/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`),
+ 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
+ each source's catalog `markColor` from the theme (`home/colors.ts`), never hex.
+
## Callsign group rules
- Membership is live: a `group` rule stores group ids, never a copy of the calls, so editing a
diff --git a/supabase/migrations/20261027001300_home_flow.sql b/supabase/migrations/20261027001300_home_flow.sql
new file mode 100644
index 00000000..adc74ac0
--- /dev/null
+++ b/supabase/migrations/20261027001300_home_flow.sql
@@ -0,0 +1,62 @@
+-- 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
+-- (match_pending_spots skips it), so it has no count here; the page shows an en dash.
+-- * deliveries is unique on (destination_id, spot_id): a spot two connections match for one
+-- destination counts once, for the connection the matcher credited.
+-- * Spots a quiet window held back are not counted.
+--
+-- matched is distinct spots by created_at, delivered is sent rows by sent_at, both within 24
+-- hours, so one spot sent to two destinations is one matched and two delivered. 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)
+ )
+ 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/advisor.test.sql b/supabase/tests/advisor.test.sql
index d5b85091..c87af8bb 100644
--- a/supabase/tests/advisor.test.sql
+++ b/supabase/tests/advisor.test.sql
@@ -23,7 +23,7 @@ select set_eq(
array['callsign_available', 'create_destination', 'rotate_signing_secret', 'save_subscription',
'delete_subscription', 'preview_subscription', 'list_ingest_health', 'list_recent_spots',
'normalize_group_callsigns', 'save_callsign_group', 'delete_callsign_group', 'list_callsign_groups',
- 'get_group_statistics', 'list_group_summaries',
+ 'get_group_statistics', 'list_group_summaries', 'home_flow',
'create_api_token', 'rotate_api_token',
'register_push_device', 'unregister_push_device', 'list_push_devices', 'delete_push_device',
'rename_push_device', 'report_push_device_blocked', 'save_push_destination', 'get_push_destination',
diff --git a/supabase/tests/home_flow.test.sql b/supabase/tests/home_flow.test.sql
new file mode 100644
index 00000000..5d494719
--- /dev/null
+++ b/supabase/tests/home_flow.test.sql
@@ -0,0 +1,102 @@
+-- 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.
+begin;
+create extension if not exists pgtap with schema extensions;
+\ir helpers/users.psql
+select plan(11);
+
+create function pg_temp.ago(p_hours int) returns timestamptz
+language sql as $$ select now() - make_interval(hours => p_hours) $$;
+
+-- A: Chat and Shack; Parks (POTA) to both, DX to Shack, Nowhere to none. B: Phone and Mine.
+select pg_temp.as_user('11111111-1111-1111-1111-111111111111');
+create temp table dc as select * from create_destination('discord', 'Chat', 'https://discord.com/api/webhooks/1/a');
+create temp table dw as select * from create_destination('webhook', 'Shack', 'https://shack.example.com/spots');
+create temp table subs as
+select save_subscription(jsonb_build_object('name', 'Parks', 'sources', array['pota'],
+ '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;
+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
+select save_subscription(jsonb_build_object('name', 'Mine', 'destinations', array[(select id from dp)])) as id;
+reset role;
+
+create temp table spots as
+with ins as (
+ insert into raw_spots (source, source_spot_id, content_hash, spot_time, callsign, frequency_khz, comment, raw_payload)
+ values ('pota', 'h1', 'h1', now(), 'KK4PWJ', 14062, '', '{}'),
+ ('pota', 'h2', 'h2', now(), 'W4ABC', 7032, '', '{}'),
+ ('dxcluster', 'h3', 'h3', now(), 'VP8PJ', 14025, '', '{}'),
+ ('pota', 'h4', 'h4', pg_temp.ago(30), 'N4OLD', 14062, '', '{}'),
+ ('pota', 'h5', 'h5', pg_temp.ago(25), 'N4LATE', 14062, '', '{}')
+ returning id, source_spot_id
+)
+select source_spot_id as k, id from ins;
+create function pg_temp.spot(p_k text) returns bigint language sql as $$ select id from spots where k = p_k $$;
+
+insert into deliveries (subscription_id, destination_id, spot_id, status, created_at, sent_at)
+values
+ -- h1 matched once by Parks, sent to both of its destinations.
+ ((select parks from subs), (select id from dc), pg_temp.spot('h1'), 'sent', pg_temp.ago(1), pg_temp.ago(1)),
+ ((select parks from subs), (select id from dw), pg_temp.spot('h1'), 'sent', pg_temp.ago(1), pg_temp.ago(1)),
+ -- h2 matched, still pending.
+ ((select parks from subs), (select id from dc), pg_temp.spot('h2'), 'pending', pg_temp.ago(2), null),
+ ((select dx from subs), (select id from dw), pg_temp.spot('h3'), 'sent', pg_temp.ago(3), pg_temp.ago(3)),
+ -- Matched and sent 30 hours ago: in neither window.
+ ((select parks from subs), (select id from dc), pg_temp.spot('h4'), 'sent', pg_temp.ago(30), pg_temp.ago(30)),
+ -- Matched 25 hours ago, sent an hour ago: delivered, not matched.
+ ((select parks from subs), (select id from dw), pg_temp.spot('h5'), 'sent', pg_temp.ago(25), pg_temp.ago(1)),
+ -- B's.
+ ((select id from bsub), (select id from dp), pg_temp.spot('h1'), 'sent', pg_temp.ago(1), pg_temp.ago(1));
+
+-- Access.
+select ok(not has_function_privilege('anon', 'public.home_flow()', 'execute'), 'anon cannot call home_flow');
+select ok(has_function_privilege('authenticated', 'public.home_flow()', 'execute'), 'authenticated can call home_flow');
+-- reset role keeps the last as_user's claims; clear them for a caller with no user.
+set local request.jwt.claims = '';
+select is(public.home_flow(), null, 'without a user there is no payload');
+
+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');
+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),
+ '[{"source": "dxcluster", "matched": 1}, {"source": "pota", "matched": 2}]'::jsonb,
+ 'sources: distinct matched spots per spot source');
+select set_eq(
+ $$ select (e ->> 'subscription_id')::uuid, e ->> 'source', (e ->> 'matched')::int from a, jsonb_array_elements(p -> 'subscriptions') e $$,
+ $$ values ((select parks from subs), 'pota', 2), ((select dx from subs), 'dxcluster', 1) $$,
+ 'subscriptions: matched per connection and spot source; a connection without a destination has no row');
+select set_eq(
+ $$ select (e ->> 'subscription_id')::uuid as sub, (e ->> 'destination_id')::uuid as dest, (e ->> 'delivered')::int from a, jsonb_array_elements(p -> 'links') e $$,
+ $$ 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');
+reset role;
+
+select pg_temp.as_user('22222222-2222-2222-2222-222222222222');
+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))),
+ '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(),
+ '{"matched": 0, "delivered": 0, "sources": [], "subscriptions": [], "links": []}'::jsonb,
+ 'an account with nothing in 24 hours gets zeros and empty lists');
+reset role;
+
+select * from finish();
+rollback;
diff --git a/web/src/App.test.tsx b/web/src/App.test.tsx
index 45119ab5..6109e72e 100644
--- a/web/src/App.test.tsx
+++ b/web/src/App.test.tsx
@@ -28,6 +28,11 @@ vi.mock("./auth/SignInPage", async () => {
vi.mock("./auth/CheckEmailPage", () => ({ CheckEmailPage: () =>
check-email page
}));
vi.mock("./auth/ResetPasswordPage", () => ({ ResetPasswordPage: () => reset page
}));
vi.mock("./auth/SetPasswordPage", () => ({ SetPasswordPage: () => set-password page
}));
+vi.mock("./home/HomePage", () => ({
+ HomePage: ({ subs, dests, loading }: { subs: unknown[]; dests: Destination[]; loading: boolean }) => (
+ home page {loading ? "loading" : `${subs.length} connections, ${dests.length} destinations`}
+ ),
+}));
vi.mock("./sources/SourcesPage", () => ({
SourcesPage: () => sources page
,
}));
@@ -145,9 +150,9 @@ describe("App routing", () => {
expect(screen.getByRole("progressbar")).toBeInTheDocument();
});
- it("sends signed-in users from sign in to Sources", async () => {
+ it("sends signed-in users from sign in to Home", async () => {
renderAt("/signin");
- expect(await screen.findByText("sources page")).toBeInTheDocument();
+ expect(await screen.findByText(/^home page/)).toBeInTheDocument();
});
it("sends a new session on sign in back to the page it came from, where SignInPage sends it", async () => {
@@ -159,9 +164,21 @@ describe("App routing", () => {
expect(await screen.findByText("stats page")).toBeInTheDocument();
});
- it("redirects unknown paths to Sources", async () => {
+ it("redirects unknown paths to Home", async () => {
renderAt("/nope");
- expect(await screen.findByText("sources page")).toBeInTheDocument();
+ expect(await screen.findByText(/^home page/)).toBeInTheDocument();
+ });
+
+ it("keeps Home for signed-in users and sends signed-out visitors from it to sign in", async () => {
+ session(null);
+ renderAt("/home");
+ expect(await screen.findByText("sign-in page /home")).toBeInTheDocument();
+ });
+
+ it("gives Home the shared lists", async () => {
+ vi.mocked(loadDestinations).mockResolvedValue([DEST]);
+ renderAt("/home");
+ expect(await screen.findByText("home page 0 connections, 1 destinations")).toBeInTheDocument();
});
it.each([
@@ -346,7 +363,7 @@ describe("App routing", () => {
expect(screen.getByText("sources page")).toBeInTheDocument();
});
- it.each(["/destinations", "/destinations/dc", "/connections", "/connections/s1"])(
+ it.each(["/home", "/destinations", "/destinations/dc", "/connections", "/connections/s1"])(
"shows a failed load on %s with Retry instead of an empty list or a redirect, and recovers",
async (path) => {
vi.mocked(loadDestinations).mockRejectedValueOnce(new Error("x")).mockResolvedValue([{ ...DEST, id: "dc", type: "discord" }]);
diff --git a/web/src/App.tsx b/web/src/App.tsx
index 3c5ebdb1..8f2f93b6 100644
--- a/web/src/App.tsx
+++ b/web/src/App.tsx
@@ -10,6 +10,7 @@ import { CheckEmailPage } from "./auth/CheckEmailPage";
import { ResetPasswordPage } from "./auth/ResetPasswordPage";
import { SetPasswordPage } from "./auth/SetPasswordPage";
import { AppShell } from "./layout/AppShell";
+import { HomePage } from "./home/HomePage";
import { SourcesPage } from "./sources/SourcesPage";
import { SourceDetailPage } from "./sources/SourceDetailPage";
import { DestinationsPage } from "./destinations/DestinationsPage";
@@ -65,6 +66,7 @@ function Router() {
} />
} />
} />
+ } />
} />
} />
} />
@@ -86,7 +88,7 @@ function Router() {
} />
} />
} />
- } />
+ } />
);
}
@@ -105,11 +107,11 @@ function PublicOnly({ children }: { children: ReactNode }) {
const { session, sessionLoading } = useApp();
const { state } = useLocation() as { state: { from?: string } | null };
if (sessionLoading) return ;
- if (session) return ;
+ if (session) return ;
return children;
}
-type Page = "sources" | "source" | "band-map" | "subscriptions" | "subscription-new" | "subscription" | "groups" | "group" | "group-settings" | "destinations" | "destination-new" | "destination" | "stats" | "health" | "lookup" | "call" | "ref" | "account" | "api-tokens" | "group-public";
+type Page = "home" | "sources" | "source" | "band-map" | "subscriptions" | "subscription-new" | "subscription" | "groups" | "group" | "group-settings" | "destinations" | "destination-new" | "destination" | "stats" | "health" | "lookup" | "call" | "ref" | "account" | "api-tokens" | "group-public";
type PublicPage = "band-map" | "stats" | "health" | "lookup" | "call" | "ref" | "group-public";
// Signed-out visitors read profiles in UTC.
@@ -206,7 +208,7 @@ function DestinationRoute(props: Omit
}
const LOAD_FAILED = "Couldn't load your destinations and connections.";
-const LIST_PAGES = new Set(["subscriptions", "subscription-new", "subscription", "destinations", "destination-new", "destination"]);
+const LIST_PAGES = new Set(["home", "subscriptions", "subscription-new", "subscription", "destinations", "destination-new", "destination"]);
/** A failed load in place of the page, with Retry. */
function LoadError({ message, retry }: { message: string; retry: () => Promise }) {
@@ -268,6 +270,7 @@ function SignedIn({ page }: { page: Page }) {
return (
d.health === "failing")}>
+ {page === "home" && }
{page === "sources" && }
{page === "source" && }
{page === "band-map" && }
diff --git a/web/src/auth/SetPasswordPage.test.tsx b/web/src/auth/SetPasswordPage.test.tsx
index 5f61e21e..5a6272df 100644
--- a/web/src/auth/SetPasswordPage.test.tsx
+++ b/web/src/auth/SetPasswordPage.test.tsx
@@ -26,7 +26,7 @@ describe("SetPasswordPage", () => {
await user.type(screen.getByLabelText("Confirm new password"), "hunter22");
await user.click(screen.getByRole("button", { name: "Set password" }));
expect(updatePassword).toHaveBeenCalledWith("hunter22");
- await waitFor(() => expect(screen.getByTestId("location")).toHaveTextContent("/sources"));
+ await waitFor(() => expect(screen.getByTestId("location")).toHaveTextContent("/home"));
expect(app.notify).toHaveBeenCalledWith("Password changed");
});
diff --git a/web/src/auth/SetPasswordPage.tsx b/web/src/auth/SetPasswordPage.tsx
index 096ca7f6..aa296f53 100644
--- a/web/src/auth/SetPasswordPage.tsx
+++ b/web/src/auth/SetPasswordPage.tsx
@@ -24,7 +24,7 @@ export function SetPasswordPage() {
try {
await updatePassword(next);
notify("Password changed");
- void navigate("/sources", { replace: true });
+ void navigate("/home", { replace: true });
} catch (err) {
setError(errorText(err, "Couldn't change your password."));
} finally {
diff --git a/web/src/auth/SignInPage.test.tsx b/web/src/auth/SignInPage.test.tsx
index 69429791..5e8b3979 100644
--- a/web/src/auth/SignInPage.test.tsx
+++ b/web/src/auth/SignInPage.test.tsx
@@ -32,13 +32,13 @@ describe("SignInPage: sign in", () => {
await waitFor(() => expect(where()).toHaveTextContent("/destinations"));
});
- it("defaults to Sources after sign in", async () => {
+ it("defaults to Home after sign in", async () => {
setMobile(true);
const { user } = renderPage( , { route: "/signin" });
await user.type(screen.getByLabelText("Callsign or email"), "kk4pwj");
await user.type(screen.getByLabelText("Password"), "hunter22");
await user.click(screen.getByRole("button", { name: "Sign in" }));
- await waitFor(() => expect(where()).toHaveTextContent("/sources"));
+ await waitFor(() => expect(where()).toHaveTextContent("/home"));
});
it("shows the sign-in error", async () => {
diff --git a/web/src/auth/SignInPage.tsx b/web/src/auth/SignInPage.tsx
index 4cb98f86..bf23d2b5 100644
--- a/web/src/auth/SignInPage.tsx
+++ b/web/src/auth/SignInPage.tsx
@@ -96,7 +96,7 @@ export function SignInPage() {
setError(null);
try {
await signIn(ident, pw);
- void navigate(state.from ?? "/sources", { replace: true });
+ void navigate(state.from ?? "/home", { replace: true });
} catch (err) {
setError(errorText(err, "Couldn't sign in."));
} finally {
diff --git a/web/src/home/FlowDiagram.tsx b/web/src/home/FlowDiagram.tsx
new file mode 100644
index 00000000..cd68e3b6
--- /dev/null
+++ b/web/src/home/FlowDiagram.tsx
@@ -0,0 +1,134 @@
+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" });
+
+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;
+ return (
+
+
+ {icon}
+
+ {name}
+ {detail}
+
+
+
+ );
+}
+
+export function Dot({ color }: { color: string }) {
+ return (
+
+
+
+ );
+}
+
+export function DestinationIcon() {
+ return ;
+}
+
+function Column({ children }: { children: ReactNode }) {
+ return {children} ;
+}
+
+/** One gap between columns: an SVG stretched across it, strokes kept at their pixel width. */
+function Links({ height, paths, links }: { height: number; paths: LinkPath[]; links: FlowLink[] }) {
+ const theme = useTheme();
+ return (
+
+
+ {paths.map((p, i) => (
+
+ ))}
+
+
+ );
+}
+
+/** 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 heading = (label: string) => (
+ {label}
+ );
+
+ return (
+
+
+
+ {heading("Sources")}
+
+ {heading("Connections")}
+
+ {heading("Destinations")}
+
+
+
+ {flow.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) => (
+ }
+ 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) => (
+ } 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
new file mode 100644
index 00000000..08b1ddc1
--- /dev/null
+++ b/web/src/home/HomePage.test.tsx
@@ -0,0 +1,160 @@
+import { beforeEach, describe, expect, it, vi } from "vitest";
+import { screen, waitFor, within } from "@testing-library/react";
+import { renderPage } from "../test/render";
+import { setMobile } from "../test/media";
+import { loadHomeFlow, loadLatestStats, type Destination, type SubscriptionWithLinks } from "../lib/api";
+import { HomePage } from "./HomePage";
+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 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", [])];
+const FLOW: HomeFlow = {
+ matched: 650,
+ delivered: 311,
+ sources: [{ source: "dxcluster", matched: 37 }, { source: "pota", matched: 86 }],
+ subscriptions: [
+ { subscription_id: "s1", source: "pota", matched: 86 },
+ { subscription_id: "s2", source: "dxcluster", matched: 37 },
+ ],
+ links: [
+ { subscription_id: "s1", destination_id: "d1", delivered: 131 },
+ { subscription_id: "s1", destination_id: "d2", delivered: 12 },
+ { subscription_id: "s2", destination_id: "d2", delivered: 37 },
+ ],
+};
+
+beforeEach(() => {
+ setMobile(false);
+ vi.mocked(loadHomeFlow).mockReset().mockResolvedValue(FLOW);
+ vi.mocked(loadLatestStats).mockReset().mockResolvedValue(null);
+});
+
+function render(subs = SUBS, dests = DESTS, loading = false) {
+ return renderPage( , { route: "/home" });
+}
+
+const link = (name: RegExp) => screen.getByRole("link", { name });
+const diagramPaths = (container: HTMLElement) => [...container.querySelectorAll("svg[preserveAspectRatio='none'] path")];
+
+describe("HomePage on desktop", () => {
+ it("shows the last 24 hours in two stat cards", async () => {
+ render();
+ expect(screen.getByRole("heading", { level: 1, name: "Home" })).toBeInTheDocument();
+ expect(await screen.findByText("311")).toBeInTheDocument();
+ expect(screen.getByText("650")).toBeInTheDocument();
+ expect(screen.getByText("spots sent to your destinations · last 24 hours")).toBeInTheDocument();
+ expect(screen.getByText("spots your connections picked up · last 24 hours")).toBeInTheDocument();
+ });
+
+ it("draws sources, connections and destinations as linked nodes", async () => {
+ const { container } = render();
+ await screen.findByText("311");
+ expect(screen.getByRole("heading", { level: 2, name: "How your spots flow" })).toBeInTheDocument();
+ expect(screen.getByText("Last 24 hours. Line width shows how many spots matched.")).toBeInTheDocument();
+
+ expect(link(/^POTA 86 matched$/)).toHaveAttribute("href", "/sources/pota");
+ expect(link(/^DX Cluster 37 matched$/)).toHaveAttribute("href", "/sources/dxcluster");
+ expect(link(/^POTA Carolinas 86 matched · 2 destinations$/)).toHaveAttribute("href", "/connections/s1");
+ expect(link(/^DX 20m CW 37 matched · 1 destination$/)).toHaveAttribute("href", "/connections/s2");
+ // No destination, so the matcher skips it and nothing was recorded.
+ expect(link(/^All POTA – matched · No destination$/)).toHaveAttribute("href", "/connections/s3");
+ 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.
+ const paths = diagramPaths(container);
+ expect(paths).toHaveLength(5);
+ 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");
+ expect(p).toHaveAttribute("stroke-opacity", "0.45");
+ }
+ expect(paths[0]).toHaveAttribute("stroke", "#1976d2");
+ 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("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"]);
+ });
+
+ it("shows en dashes and toasts when the counts don't load", async () => {
+ vi.mocked(loadHomeFlow).mockRejectedValue(new Error("Nope."));
+ const { app } = render();
+ await waitFor(() => expect(app.notify).toHaveBeenCalledWith("Nope."));
+ expect(screen.getAllByText("–")).toHaveLength(2);
+ expect(link(/^POTA Carolinas – matched · 2 destinations$/)).toBeInTheDocument();
+ expect(screen.queryByRole("progressbar")).not.toBeInTheDocument();
+ });
+
+ it("shows progress at the top of the card while loading, and no empty state", () => {
+ vi.mocked(loadHomeFlow).mockReturnValue(new Promise(() => {}));
+ render([], [], true);
+ expect(screen.getByRole("progressbar")).toBeInTheDocument();
+ expect(screen.queryByText("No connections or destinations yet.")).not.toBeInTheDocument();
+ expect(screen.getAllByText("–")).toHaveLength(2);
+ });
+});
+
+describe("HomePage on a phone", () => {
+ it("lists destinations instead of the diagram", async () => {
+ setMobile(true);
+ const { container } = render();
+ await screen.findByText("311");
+ expect(diagramPaths(container)).toHaveLength(0);
+ expect(screen.getByText("Last 24 hours, by destination.")).toBeInTheDocument();
+ expect(screen.getAllByText("last 24 hours")).toHaveLength(2);
+ const names = screen.getAllByRole("link").map((a) => a.textContent);
+ expect(names).toEqual(["RARS DiscordDiscord · 131 delivered", "LoggerWebhook · 49 delivered", "View all connections"]);
+ expect(link(/^View all connections$/)).toHaveAttribute("href", "/connections");
+ });
+});
+
+describe("HomePage empty states", () => {
+ it("offers a first connection when there are none", async () => {
+ const { user } = render([], []);
+ expect(screen.getByText("No connections or destinations yet.")).toBeInTheDocument();
+ expect(screen.queryByText("Delivered")).not.toBeInTheDocument();
+ await user.click(screen.getByRole("button", { name: "Add your first connection" }));
+ const dialog = await screen.findByRole("dialog", { name: "New connection" });
+ await user.click(within(dialog).getByRole("button", { name: "Continue" }));
+ expect(screen.getByTestId("location")).toHaveAttribute("data-search", "?source=pota");
+ expect(screen.getByTestId("location")).toHaveTextContent("/connections/new");
+ });
+
+ it("keeps the stat cards and offers a first destination when connections have nowhere to go", async () => {
+ vi.mocked(loadHomeFlow).mockResolvedValue({ ...FLOW, delivered: 0, matched: 0 });
+ const { user } = render(SUBS, []);
+ expect(await screen.findAllByText("0")).toHaveLength(2);
+ expect(screen.getByText("Your connections have nowhere to send spots yet.")).toBeInTheDocument();
+ expect(screen.queryByText("How your spots flow")).not.toBeInTheDocument();
+ await user.click(screen.getByRole("button", { name: "Add your first destination" }));
+ expect(await screen.findByRole("dialog", { name: "Add destination" })).toBeInTheDocument();
+ });
+});
diff --git a/web/src/home/HomePage.tsx b/web/src/home/HomePage.tsx
new file mode 100644
index 00000000..7777b82e
--- /dev/null
+++ b/web/src/home/HomePage.tsx
@@ -0,0 +1,150 @@
+import { useEffect, useMemo, useState } from "react";
+import { Box, Button, Grid, LinearProgress, Paper, Stack, Typography, useMediaQuery, useTheme } from "@mui/material";
+import AddIcon from "@mui/icons-material/Add";
+import { Link as RouterLink, useNavigate } from "react-router";
+import { PageTitle } from "../layout/PageTitle";
+import { loadHomeFlow, type Destination, type SubscriptionWithLinks } from "../lib/api";
+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 type { HomeFlow } from "./types";
+
+interface Props {
+ subs: SubscriptionWithLinks[];
+ dests: Destination[];
+ loading: boolean;
+}
+
+function StatCard({ label, value, caption }: { label: string; value: string; caption: string }) {
+ return (
+
+ {label}
+ {value}
+ {caption}
+
+ );
+}
+
+function EmptyCard({ line, action, busy, onClick }: { line: string; action: string; busy: boolean; onClick: () => void }) {
+ return (
+
+ {busy && }
+
+ {line}
+ } onClick={onClick}>{action}
+
+
+ );
+}
+
+/** The signed-in landing page: the last 24 hours, and how spots flow from sources through connections to destinations. */
+export function HomePage({ subs, dests, loading }: Props) {
+ const notify = useNotify();
+ const navigate = useNavigate();
+ const phone = useMediaQuery(useTheme().breakpoints.down("sm"));
+ const [flow, setFlow] = useState(null);
+ const [fetching, setFetching] = useState(true);
+ const [adding, setAdding] = useState<"connection" | "destination" | null>(null);
+
+ useEffect(() => {
+ let active = true;
+ loadHomeFlow()
+ .then((f) => {
+ if (active) setFlow(f);
+ })
+ .catch((err: unknown) => {
+ if (active) notify(errorText(err, "Couldn't load your last 24 hours."));
+ })
+ .finally(() => {
+ if (active) setFetching(false);
+ });
+ return () => {
+ active = false;
+ };
+ }, [notify]);
+
+ const model = useMemo(() => buildFlow(subs, dests, flow), [subs, dests, flow]);
+ const totals = useMemo(() => destinationTotals(dests, flow), [dests, flow]);
+ const busy = loading || fetching;
+ const total = (n: number | undefined) => (n === undefined ? DASH : count(n));
+
+ const dialogs = (
+ <>
+ {adding === "connection" && (
+ setAdding(null)}
+ onContinue={(source) => void navigate(`/connections/new?source=${encodeURIComponent(source)}`)}
+ />
+ )}
+ setAdding(null)} />
+ >
+ );
+
+ if (!loading && subs.length === 0) {
+ return (
+ <>
+ Home
+ setAdding("connection")}
+ />
+ {dialogs}
+ >
+ );
+ }
+
+ 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.` : ""}`;
+
+ return (
+ <>
+ Home
+
+
+
+
+
+
+
+
+ {!loading && dests.length === 0
+ ? (
+ setAdding("destination")}
+ />
+ )
+ : (
+
+ {busy && }
+
+ How your spots flow
+ {caption}
+ {!loading && (phone
+ ? (
+ <>
+
+ {totals.map((d) => (
+ } name={d.name} detail={destinationDetail(d)} />
+ ))}
+
+ View all connections
+ >
+ )
+ : )}
+
+
+ )}
+ {dialogs}
+ >
+ );
+}
diff --git a/web/src/home/colors.ts b/web/src/home/colors.ts
new file mode 100644
index 00000000..7af14d59
--- /dev/null
+++ b/web/src/home/colors.ts
@@ -0,0 +1,8 @@
+import type { Theme } from "@mui/material";
+import { SOURCES } from "../sources/sources";
+
+/** A source's catalog mark colour, the same everywhere in the app; grey for none. */
+export function sourceColor(theme: Theme, id: string | null): string {
+ const mark = SOURCES.find((s) => s.id === id)?.markColor;
+ return mark === undefined || mark === "default" ? theme.palette.text.disabled : theme.palette[mark].main;
+}
diff --git a/web/src/home/layout.test.ts b/web/src/home/layout.test.ts
new file mode 100644
index 00000000..f958f126
--- /dev/null
+++ b/web/src/home/layout.test.ts
@@ -0,0 +1,82 @@
+import { describe, expect, it } from "vitest";
+import { columnHeight, linkPaths, linkWidth, NODE_HEIGHT, rowCenters } from "./layout";
+
+describe("columnHeight", () => {
+ it("fits the rows 12 px apart", () => {
+ expect(columnHeight(7)).toBe(7 * 56 + 6 * 12);
+ expect(columnHeight(1)).toBe(56);
+ expect(columnHeight(0)).toBe(0);
+ });
+});
+
+describe("rowCenters", () => {
+ it("spreads rows as justify-content: space-between does", () => {
+ // The canvas: five sources in a 464 px column.
+ expect(rowCenters(5, 464)).toEqual([28, 130, 232, 334, 436]);
+ });
+
+ it("puts the first and last rows flush with the column", () => {
+ const rows = rowCenters(3, 300);
+ expect(rows[0]).toBe(NODE_HEIGHT / 2);
+ expect(rows[2]).toBe(300 - NODE_HEIGHT / 2);
+ });
+
+ it("leaves a lone row at the top", () => {
+ expect(rowCenters(1, 464)).toEqual([28]);
+ expect(rowCenters(0, 464)).toEqual([]);
+ });
+});
+
+describe("linkWidth", () => {
+ it("is log-scaled with a 2 px floor", () => {
+ expect(linkWidth(0)).toBe(2);
+ expect(linkWidth(1)).toBe(2);
+ expect(linkWidth(2)).toBe(2);
+ expect(linkWidth(10)).toBeCloseTo(2 * Math.log(10) - 1.5);
+ expect(linkWidth(100)).toBeCloseTo(2 * Math.log(100) - 1.5);
+ expect(linkWidth(1000)).toBeGreaterThan(linkWidth(100));
+ });
+});
+
+describe("linkPaths", () => {
+ it("draws a cubic bezier between the two rows' centres", () => {
+ const [path] = linkPaths(2, 2, 124, [{ from: 0, to: 1, value: 1 }]);
+ expect(path).toEqual({ d: "M0 28 C50 28 50 96 100 96", width: 2, y1: 28, y2: 96 });
+ });
+
+ it("stacks links that share a node edge, ordered by the other end and centred on the node", () => {
+ const w = linkWidth(500);
+ const paths = linkPaths(1, 2, 124, [
+ { from: 0, to: 1, value: 500 },
+ { from: 0, to: 0, value: 2 },
+ ]);
+ // At the shared source the link to row 0 goes first, then the one to row 1.
+ const top = 28 - (2 + w) / 2;
+ expect(paths[1]?.y1).toBeCloseTo(top + 1, 1);
+ expect(paths[0]?.y1).toBeCloseTo(top + 2 + w / 2, 1);
+ // Each target has one link, so it meets the centre.
+ expect(paths[0]?.y2).toBe(96);
+ expect(paths[1]?.y2).toBe(28);
+ });
+
+ it("orders a stack by position, not input order", () => {
+ const paths = linkPaths(3, 1, 192, [
+ { from: 2, to: 0, value: 1 },
+ { from: 0, to: 0, value: 1 },
+ { from: 1, to: 0, value: 1 },
+ ]);
+ // Three 2 px links centred on 28: rows 0, 1, 2 meet at 26, 28, 30.
+ expect(paths.map((p) => p.y2)).toEqual([30, 26, 28]);
+ });
+
+ it("returns paths in input order with rounded coordinates", () => {
+ const paths = linkPaths(3, 3, 192, [
+ { from: 0, to: 2, value: 37 },
+ { from: 1, to: 1, value: 0 },
+ ]);
+ expect(paths).toHaveLength(2);
+ expect(paths[0]?.d).toMatch(/^M0 28 C50 28 50 164 100 164$/);
+ expect(paths[0]?.width).toBe(Math.round(linkWidth(37) * 100) / 100);
+ expect(paths[1]?.d).toBe("M0 96 C50 96 50 96 100 96");
+ });
+});
diff --git a/web/src/home/layout.ts b/web/src/home/layout.ts
new file mode 100644
index 00000000..02aa37ed
--- /dev/null
+++ b/web/src/home/layout.ts
@@ -0,0 +1,78 @@
+// The flow diagram's geometry, pure so it can be tested without a browser. Node
+// rows sit in fixed-height columns laid out with justify-content: space-between;
+// each gap between columns is an SVG with viewBox "0 0 100 ", so x runs
+// 0 to 100 across the gap and y is in pixels.
+
+export const NODE_HEIGHT = 56;
+export const NODE_WIDTH = 220;
+export const ROW_GAP = 12;
+
+/** The diagram's height: the fullest column's nodes, ROW_GAP apart. */
+export function columnHeight(rows: number): number {
+ return rows > 0 ? rows * NODE_HEIGHT + (rows - 1) * ROW_GAP : 0;
+}
+
+/** Each row's vertical centre in a column of `height`, as space-between places it (a lone node sits at the top). */
+export function rowCenters(count: number, height: number): number[] {
+ if (count <= 0) return [];
+ if (count === 1) return [NODE_HEIGHT / 2];
+ const step = NODE_HEIGHT + (height - count * NODE_HEIGHT) / (count - 1);
+ return Array.from({ length: count }, (_, i) => i * step + NODE_HEIGHT / 2);
+}
+
+/** A link's stroke width in pixels for `n` spots: log-scaled, never under 2. */
+export function linkWidth(n: number): number {
+ return n > 0 ? Math.max(2, 2 * Math.log(n) - 1.5) : 2;
+}
+
+/** A link from row `from` of the left column to row `to` of the right one, carrying `value` spots. */
+export interface LinkInput {
+ from: number;
+ to: number;
+ value: number;
+}
+
+export interface LinkPath {
+ d: string;
+ width: number;
+ /** Where the link leaves the left node and meets the right one, after stacking. */
+ y1: number;
+ y2: number;
+}
+
+const round = (n: number) => Math.round(n * 100) / 100;
+
+/**
+ * Paths for the links between two columns, in input order. Links meeting at one
+ * node edge are stacked instead of overlapping: ordered by the row at their other
+ * end, each sits below the widths of the ones before it, and the stack is centred
+ * on the node.
+ */
+export function linkPaths(fromRows: number, toRows: number, height: number, links: LinkInput[]): LinkPath[] {
+ const widths = links.map((l) => linkWidth(l.value));
+ const y1 = stack(links.map((l) => l.from), links.map((l) => l.to), widths, rowCenters(fromRows, height));
+ const y2 = stack(links.map((l) => l.to), links.map((l) => l.from), widths, rowCenters(toRows, height));
+ return links.map((_, i) => {
+ const a = round(y1[i] ?? 0);
+ const b = round(y2[i] ?? 0);
+ return { d: `M0 ${a} C50 ${a} 50 ${b} 100 ${b}`, width: round(widths[i] ?? 2), y1: a, y2: b };
+ });
+}
+
+/** The y of each link at one column's edge: `at` is its row there, `other` its row at the far end. */
+function stack(at: number[], other: number[], widths: number[], centers: number[]): number[] {
+ const out = at.map(() => 0);
+ const byRow = new Map();
+ at.forEach((row, i) => byRow.set(row, [...(byRow.get(row) ?? []), i]));
+ for (const [row, members] of byRow) {
+ members.sort((a, b) => (other[a] ?? 0) - (other[b] ?? 0) || a - b);
+ const total = members.reduce((sum, i) => sum + (widths[i] ?? 0), 0);
+ let y = (centers[row] ?? 0) - total / 2;
+ for (const i of members) {
+ const w = widths[i] ?? 0;
+ out[i] = y + w / 2;
+ y += w;
+ }
+ }
+ return out;
+}
diff --git a/web/src/home/model.test.ts b/web/src/home/model.test.ts
new file mode 100644
index 00000000..93296adc
--- /dev/null
+++ b/web/src/home/model.test.ts
@@ -0,0 +1,167 @@
+import { describe, expect, it } from "vitest";
+import type { Destination, SubscriptionWithLinks } from "../lib/api";
+import {
+ buildFlow, connectionDetail, count, destinationDetail, destinationTotals, moreConnectionsName, TOP_CONNECTIONS,
+} 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;
+
+function flow(
+ subscriptions: [string, string, number][],
+ links: [string, string, number][] = [],
+ sources: [string, number][] = [],
+): HomeFlow {
+ return {
+ matched: 0,
+ delivered: 0,
+ 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 })),
+ };
+}
+
+describe("buildFlow: connections", () => {
+ const dests = [dest("d1"), dest("off", "webhook", false)];
+
+ it("sums a connection's matched spots over its sources and colours it by the busiest", () => {
+ const f = buildFlow([sub("a", ["d1"])], dests, flow([["a", "pota", 3], ["a", "rbn", 9]]));
+ expect(f.connections).toEqual([{ id: "a", name: "Conn a", source: "rbn", matched: 12, destinations: 1 }]);
+ });
+
+ it("counts zero for a live connection with no deliveries and nothing for one the matcher skips", () => {
+ const subs = [sub("live", ["d1"]), sub("off", ["d1"], false), sub("nowhere", []), sub("disabled-dest", ["off"])];
+ const f = buildFlow(subs, dests, flow([]));
+ expect(Object.fromEntries(f.connections.map((c) => [c.id, c.matched]))).toEqual({
+ live: 0, off: null, nowhere: null, "disabled-dest": null,
+ });
+ expect(f.connections.every((c) => c.source === null)).toBe(true);
+ });
+
+ it("keeps a number a connection recorded before it stopped being live", () => {
+ const f = buildFlow([sub("off", ["d1"], false)], dests, flow([["off", "pota", 4]]));
+ expect(f.connections[0]?.matched).toBe(4);
+ });
+
+ it("has no numbers when the counts didn't load", () => {
+ const f = buildFlow([sub("a", ["d1"])], dests, null);
+ expect(f.connections[0]?.matched).toBeNull();
+ 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("groups connections under their source in the order each group's busiest ranks, idle ones last", () => {
+ const subs = [sub("p50", ["d1"]), sub("dx40", ["d1"]), sub("p30", ["d1"]), sub("idle", ["d1"])];
+ const f = buildFlow(subs, dests, flow([["p50", "pota", 50], ["dx40", "dxcluster", 40], ["p30", "pota", 30]]));
+ expect(f.connections.map((c) => c.id)).toEqual(["p50", "p30", "dx40", "idle"]);
+ });
+
+ it("breaks ties by name", () => {
+ const f = buildFlow([sub("b", ["d1"]), sub("a", ["d1"])], dests, flow([]));
+ expect(f.connections.map((c) => c.id)).toEqual(["a", "b"]);
+ });
+});
+
+describe("buildFlow: sources", () => {
+ it("lists the sources visible connections matched from, in the order they are first used, with true totals", () => {
+ const subs = [sub("a", ["d1"]), sub("b", ["d1"])];
+ const f = buildFlow(subs, [dest("d1")], flow([["a", "pota", 9], ["b", "rbn", 5], ["a", "sotawatch", 1]], [], [["pota", 12], ["rbn", 5], ["sotawatch", 1]]));
+ expect(f.sources).toEqual([{ id: "pota", matched: 12 }, { id: "sotawatch", matched: 1 }, { id: "rbn", matched: 5 }]);
+ expect(f.sourceLinks).toEqual([
+ { from: 0, to: 0, value: 9, source: "pota" },
+ { from: 1, to: 0, value: 1, source: "sotawatch" },
+ { 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", () => {
+ it("shows destinations visible connections link to, ordered by where those connections sit, with true totals", () => {
+ const dests = [dest("top"), dest("bottom"), dest("shared")];
+ const subs = [sub("a", ["bottom", "shared"]), sub("b", ["top", "shared"])];
+ const f = buildFlow(subs, dests, flow([["a", "pota", 9], ["b", "pota", 3]], [["a", "bottom", 4], ["b", "shared", 2]]));
+ // a is row 0 and b row 1: bottom (mean 0), shared (mean 0.5), top (mean 1).
+ expect(f.destinations.map((d) => [d.id, d.delivered])).toEqual([["bottom", 4], ["shared", 2], ["top", 0]]);
+ expect(f.destinationLinks).toEqual([
+ { from: 0, to: 0, value: 4, source: "pota" },
+ { from: 0, to: 1, value: 0, source: "pota" },
+ { from: 1, to: 2, value: 0, source: "pota" },
+ { from: 1, to: 1, value: 2, source: "pota" },
+ ]);
+ });
+
+ it("draws a link a connection sent through in the window though it is unlinked now", () => {
+ const f = buildFlow([sub("a", [])], [dest("d1")], flow([["a", "pota", 2]], [["a", "d1", 2]]));
+ expect(f.destinations.map((d) => d.id)).toEqual(["d1"]);
+ expect(f.destinationLinks).toEqual([{ from: 0, to: 0, value: 2, source: "pota" }]);
+ });
+
+ it("ignores links to destinations it doesn't know", () => {
+ const f = buildFlow([sub("a", ["gone"])], [], flow([], [["a", "gone", 1]]));
+ expect(f.destinations).toEqual([]);
+ 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]]));
+ expect(f.destinations.map((d) => [d.id, d.delivered])).toEqual([["seen", 6]]);
+ expect(f.moreDestinations).toBe(1);
+ });
+});
+
+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]]));
+ expect(totals.map((d) => [d.id, d.delivered])).toEqual([["c", 4], ["a", 0], ["b", 0]]);
+ });
+});
+
+describe("copy", () => {
+ it("writes exact counts, and an en dash for a missing one", () => {
+ expect(count(1234)).toBe("1,234");
+ expect(count(0)).toBe("0");
+ expect(count(null)).toBe("–");
+ });
+
+ it("words a connection's caption", () => {
+ const c = { id: "a", name: "A", source: null, matched: 86, destinations: 2 };
+ expect(connectionDetail(c)).toBe("86 matched · 2 destinations");
+ expect(connectionDetail({ ...c, destinations: 1 })).toBe("86 matched · 1 destination");
+ expect(connectionDetail({ ...c, matched: null, destinations: 0 })).toBe("– matched · No destination");
+ });
+
+ it("words a destination's caption by type", () => {
+ expect(destinationDetail({ id: "d", name: "D", type: "discord", delivered: 131 })).toBe("Discord · 131 delivered");
+ expect(destinationDetail({ id: "d", name: "D", type: "push", delivered: null })).toBe("Push · – delivered");
+ 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
new file mode 100644
index 00000000..295e4373
--- /dev/null
+++ b/web/src/home/model.ts
@@ -0,0 +1,178 @@
+// 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.
+
+import type { Destination, SubscriptionWithLinks } from "../lib/api";
+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 = "–";
+
+export interface SourceNode {
+ id: string;
+ matched: number;
+}
+
+export interface ConnectionNode {
+ id: string;
+ name: string;
+ /** The spot source it matched most from, which colours it; null when it matched nothing. */
+ source: string | null;
+ /**
+ * Null when there is no record to count: the counts didn't load, or the connection is off or has
+ * no enabled destination (the matcher skips it) and made no deliveries in the window.
+ */
+ matched: number | null;
+ destinations: number;
+}
+
+export interface DestinationNode {
+ id: string;
+ name: string;
+ type: string;
+ /** Null when the counts didn't load. */
+ delivered: number | null;
+}
+
+/** A link and the source whose colour it takes (null: none, drawn grey). */
+export interface FlowLink extends LinkInput {
+ source: string | null;
+}
+
+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[];
+}
+
+/** Every destination with what it was sent in the window, busiest first (the phone's list). */
+export function destinationTotals(dests: Destination[], flow: HomeFlow | null): DestinationNode[] {
+ const sent = new Map();
+ for (const l of flow?.links ?? []) sent.set(l.destination_id, (sent.get(l.destination_id) ?? 0) + l.delivered);
+ return dests
+ .map((d) => ({ id: d.id, name: d.name, type: d.type, delivered: flow ? (sent.get(d.id) ?? 0) : null }))
+ .sort((a, b) => (b.delivered ?? 0) - (a.delivered ?? 0) || a.name.localeCompare(b.name));
+}
+
+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();
+ for (const r of flow?.subscriptions ?? []) {
+ bySub.set(r.subscription_id, [...(bySub.get(r.subscription_id) ?? []), { source: r.source, matched: r.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);
+ }
+
+ // 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 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.
+ const groups: (string | null)[] = [];
+ for (const c of shown) 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));
+
+ // 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);
+ 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]));
+ const linked = (id: string): string[] => {
+ const ids = new Set(subById.get(id)?.destinations ?? []);
+ for (const d of sentBySub.get(id)?.keys() ?? []) ids.add(d);
+ return [...ids].filter((d) => destById.has(d));
+ };
+
+ // Destinations ordered by the mean row of the connections that reach them.
+ const rowsOf = new Map();
+ connections.forEach((c, i) => {
+ for (const d of linked(c.id)) rowsOf.set(d, [...(rowsOf.get(d) ?? []), i]);
+ });
+ const mean = (id: string) => {
+ 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 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 }))
+ );
+ 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 }))
+ );
+
+ return {
+ 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,
+ };
+}
+
+/** An exact count, or an en dash when there is none. */
+export function count(n: number | null): string {
+ return n === null ? DASH : n.toLocaleString("en-US");
+}
+
+const plural = (n: number, word: string) => `${count(n)} ${word}${n === 1 ? "" : "s"}`;
+
+/** "86 matched · 2 destinations", "– matched · No destination". */
+export function connectionDetail(c: ConnectionNode): string {
+ return `${count(c.matched)} matched · ${c.destinations === 0 ? "No destination" : plural(c.destinations, "destination")}`;
+}
+
+const TYPE_LABELS: Record = { discord: "Discord", webhook: "Webhook", push: "Push" };
+
+/** "Discord · 131 delivered". */
+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
new file mode 100644
index 00000000..98e83b8c
--- /dev/null
+++ b/web/src/home/types.ts
@@ -0,0 +1,16 @@
+/**
+ * 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.
+ */
+export interface HomeFlow {
+ matched: number;
+ delivered: number;
+ /** Distinct matched spots per spot source. */
+ sources: { source: string; matched: number }[];
+ /** Matched spots per connection and spot source. */
+ subscriptions: { subscription_id: string; source: string; matched: number }[];
+ /** Sent spots per connection and destination. */
+ links: { subscription_id: string; destination_id: string; delivered: number }[];
+}
diff --git a/web/src/layout/AppShell.test.tsx b/web/src/layout/AppShell.test.tsx
index e5a5d150..cf31463f 100644
--- a/web/src/layout/AppShell.test.tsx
+++ b/web/src/layout/AppShell.test.tsx
@@ -88,7 +88,7 @@ describe("AppShell", () => {
it("lists the sections in order and keeps Callsign lookup highlighted on profiles", () => {
renderPage(shell(), { route: "/call/KK4PWJ" });
const labels = screen.getAllByRole("button").map((b) => b.textContent);
- const order = ["Sources", "Connections", "Destinations", "Groups", "Band map", "Callsign lookup", "Stats", "API tokens"];
+ const order = ["Home", "Sources", "Connections", "Destinations", "Groups", "Band map", "Callsign lookup", "Stats", "API tokens"];
expect(labels.filter((l) => order.includes(l ?? ""))).toEqual(order);
expect(screen.getByRole("button", { name: "Callsign lookup" })).toHaveClass("Mui-selected");
});
@@ -161,7 +161,7 @@ describe("AppShell", () => {
describe("AppShell signed out", () => {
const signedOut = { app: { session: null, profile: null } };
- const LOCKED = ["Sources", "Connections", "Destinations", "Groups", "API tokens"];
+ const LOCKED = ["Home", "Sources", "Connections", "Destinations", "Groups", "API tokens"];
const OPEN = ["Band map", "Callsign lookup", "Stats", "Health"];
it("shows every section, with the account ones disabled and explained", async () => {
diff --git a/web/src/layout/AppShell.tsx b/web/src/layout/AppShell.tsx
index b49918ee..233108d8 100644
--- a/web/src/layout/AppShell.tsx
+++ b/web/src/layout/AppShell.tsx
@@ -5,6 +5,7 @@ import {
useColorScheme, useMediaQuery, useTheme,
} from "@mui/material";
import MenuIcon from "@mui/icons-material/Menu";
+import HomeIcon from "@mui/icons-material/Home";
import SensorsIcon from "@mui/icons-material/Sensors";
import GraphicEqIcon from "@mui/icons-material/GraphicEq";
import FilterListIcon from "@mui/icons-material/FilterList";
@@ -30,6 +31,7 @@ import { InstallDialog } from "./InstallDialog";
// `open` sections are public; signed out, the rest stay in the drawer, disabled, to show what an account adds.
const NAV = [
+ { id: "home", label: "Home", icon: , open: false },
{ id: "sources", label: "Sources", icon: , open: false },
{ id: "connections", label: "Connections", icon: , open: false },
{ id: "destinations", label: "Destinations", icon: , open: false },
diff --git a/web/src/lib/api.test.tsx b/web/src/lib/api.test.tsx
index 2ec5322d..76f875d2 100644
--- a/web/src/lib/api.test.tsx
+++ b/web/src/lib/api.test.tsx
@@ -701,6 +701,17 @@ describe("callsign groups", () => {
await expect(api.loadGroupStatistics("g1", "30d")).rejects.toThrow("Group not found.");
});
+ it("loadHomeFlow returns home_flow's payload", async () => {
+ const flow = { matched: 3, delivered: 4, sources: [], subscriptions: [], links: [] };
+ fake.rpcs.set("home_flow", { data: flow });
+ expect(await api.loadHomeFlow()).toEqual(flow);
+ expect(fake.client.rpc).toHaveBeenCalledWith("home_flow");
+ fake.rpcs.set("home_flow", { data: null });
+ await expect(api.loadHomeFlow()).rejects.toThrow("Couldn't load your last 24 hours.");
+ fake.rpcs.set("home_flow", { error: boom });
+ await expect(api.loadHomeFlow()).rejects.toThrow("boom");
+ });
+
it("deleteGroup goes through its RPC", async () => {
await api.deleteGroup("g1");
expect(fake.client.rpc).toHaveBeenCalledWith("delete_callsign_group", { p_id: "g1" });
diff --git a/web/src/lib/api.ts b/web/src/lib/api.ts
index 00454b93..0042cb38 100644
--- a/web/src/lib/api.ts
+++ b/web/src/lib/api.ts
@@ -11,6 +11,7 @@ import type { HealthPayload, HealthRange } from "../health/types";
import type { CallsignGroup, GroupInput, NormalizedCallsigns } from "../groups/types";
import type { GroupPeriod, GroupSummary, OwnerGroupStatistics, PublicGroupStatistics } from "../groups/stats/types";
import type { Rules } from "../subscriptions/model";
+import type { HomeFlow } from "../home/types";
type Tables = Database["public"]["Tables"];
type Views = Database["public"]["Views"];
@@ -782,6 +783,17 @@ export async function previewSubscription(filter: SubscriptionFilter): Promise {
+ const { data, error } = await supabase.rpc("home_flow");
+ if (error) fail(error, "Couldn't load your last 24 hours.");
+ if (data === null) throw new ApiError("Couldn't load your last 24 hours.");
+ // jsonb in the generated types; its shape is pinned by supabase/tests/home_flow.test.sql.
+ return data as unknown as HomeFlow;
+}
+
// ---- Callsign groups ---------------------------------------------------------
export async function listGroups(): Promise {
diff --git a/web/src/lib/database.types.ts b/web/src/lib/database.types.ts
index fae7de08..eab6da81 100644
--- a/web/src/lib/database.types.ts
+++ b/web/src/lib/database.types.ts
@@ -1524,6 +1524,9 @@ isOneToOne: true
"health_worker_memory_bytes":
{ Args: Record; Returns: number
},
+"home_flow":
+{ Args: Record; Returns: Json
+ },
"ingest_aprs_packets":
{ Args: { "p_rows": Json }; Returns: number
},