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 ( + + + + ); +} + +/** 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} + + + + ); +} + +/** 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)} /> + ))} + + + + ) + : )} + + + )} + {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 },