Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 16 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
62 changes: 62 additions & 0 deletions supabase/migrations/20261027001300_home_flow.sql
Original file line number Diff line number Diff line change
@@ -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;
2 changes: 1 addition & 1 deletion supabase/tests/advisor.test.sql
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
102 changes: 102 additions & 0 deletions supabase/tests/home_flow.test.sql
Original file line number Diff line number Diff line change
@@ -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;
27 changes: 22 additions & 5 deletions web/src/App.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,11 @@ vi.mock("./auth/SignInPage", async () => {
vi.mock("./auth/CheckEmailPage", () => ({ CheckEmailPage: () => <p>check-email page</p> }));
vi.mock("./auth/ResetPasswordPage", () => ({ ResetPasswordPage: () => <p>reset page</p> }));
vi.mock("./auth/SetPasswordPage", () => ({ SetPasswordPage: () => <p>set-password page</p> }));
vi.mock("./home/HomePage", () => ({
HomePage: ({ subs, dests, loading }: { subs: unknown[]; dests: Destination[]; loading: boolean }) => (
<p>home page {loading ? "loading" : `${subs.length} connections, ${dests.length} destinations`}</p>
),
}));
vi.mock("./sources/SourcesPage", () => ({
SourcesPage: () => <p>sources page</p>,
}));
Expand Down Expand Up @@ -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 () => {
Expand All @@ -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([
Expand Down Expand Up @@ -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" }]);
Expand Down
11 changes: 7 additions & 4 deletions web/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -65,6 +66,7 @@ function Router() {
<Route path="/check-email" element={<CheckEmailPage />} />
<Route path="/reset-password" element={<ResetPasswordPage />} />
<Route path="/set-password" element={<SetPasswordPage />} />
<Route path="/home" element={<RequireAuth page="home" />} />
<Route path="/sources" element={<RequireAuth page="sources" />} />
<Route path="/sources/:id" element={<RequireAuth page="source" />} />
<Route path="/connections" element={<RequireAuth page="subscriptions" />} />
Expand All @@ -86,7 +88,7 @@ function Router() {
<Route path="/g/:slug" element={<PublicRoute page="group-public" />} />
<Route path="/account" element={<RequireAuth page="account" />} />
<Route path="/api-tokens" element={<RequireAuth page="api-tokens" />} />
<Route path="*" element={<Navigate to="/sources" replace />} />
<Route path="*" element={<Navigate to="/home" replace />} />
</Routes>
);
}
Expand All @@ -105,11 +107,11 @@ function PublicOnly({ children }: { children: ReactNode }) {
const { session, sessionLoading } = useApp();
const { state } = useLocation() as { state: { from?: string } | null };
if (sessionLoading) return <LinearProgress />;
if (session) return <Navigate to={state?.from ?? "/sources"} replace />;
if (session) return <Navigate to={state?.from ?? "/home"} replace />;
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.
Expand Down Expand Up @@ -206,7 +208,7 @@ function DestinationRoute(props: Omit<ComponentProps<typeof PushDestinationPage>
}

const LOAD_FAILED = "Couldn't load your destinations and connections.";
const LIST_PAGES = new Set<Page>(["subscriptions", "subscription-new", "subscription", "destinations", "destination-new", "destination"]);
const LIST_PAGES = new Set<Page>(["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<void> }) {
Expand Down Expand Up @@ -268,6 +270,7 @@ function SignedIn({ page }: { page: Page }) {

return (
<AppShell destinationsFailing={dests.some((d) => d.health === "failing")}>
{page === "home" && <HomePage subs={subs} dests={dests} loading={loading} />}
{page === "sources" && <SourcesPage />}
{page === "source" && <SourceDetailPage prefs={prefs} subs={subs} />}
{page === "band-map" && <BandMapPage />}
Expand Down
2 changes: 1 addition & 1 deletion web/src/auth/SetPasswordPage.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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");
});

Expand Down
2 changes: 1 addition & 1 deletion web/src/auth/SetPasswordPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
4 changes: 2 additions & 2 deletions web/src/auth/SignInPage.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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(<SignInPage />, { 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 () => {
Expand Down
2 changes: 1 addition & 1 deletion web/src/auth/SignInPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
Loading
Loading