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: 10 additions & 6 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -691,16 +691,20 @@ profile canvas); its Copy section ("Reference page") is the copy source for `/re

- `/home` is the signed-in landing page (sign-in, set password and unknown paths go there) and is
not public. Its copy comes from Brad's "Long Lines home" canvas.
- Counts come only from `home_flow()`: the caller's last 24 hours of `deliveries` (matched =
distinct spots by `created_at`, delivered = sent rows by `sent_at`). Names, types, sources and
links come from the lists `SignedIn` already loads. `home_flow` is web-only: never add it to
`api_call`, and never return spot data from it. Its shape is `HomeFlow` (`web/src/home/types.ts`),
- The diagram draws the configuration, not the traffic: every connection (no cap), every source
each takes spots from and every destination, linked or not. The last 24 hours only label nodes and weight links; never drop
a node or link for being quiet.
- `home_flow()` gives what the lists `SignedIn` loads can't: the caller's last 24 hours of
`deliveries` (matched = distinct spots by `created_at`, delivered = sent rows by `sent_at`) and
`subscription_sources`, each connection's sources by the matcher's own rules (`source_matches`,
and RBN only with an AND group that names stations). Never expand "any source" in TypeScript.
`home_flow` is web-only: never add it to `api_call`, and never return spot data from it. Its shape is `HomeFlow` (`web/src/home/types.ts`),
pinned by `supabase/tests/home_flow.test.sql`; change the SQL, the type and the test together.
- Nothing records a match for a connection the matcher skips (off, or no enabled destination), so
the page shows an en dash for it; never count matches live over `raw_spots` per visitor.
- The diagram is plain MUI plus one SVG per gap (`preserveAspectRatio="none"`,
`vector-effect="non-scaling-stroke"`); no chart or graph dependency. Geometry is the pure
`home/layout.ts`, node choice and order `home/model.ts` (`TOP_CONNECTIONS`). Link colours are
`home/layout.ts`, node choice and order `home/model.ts`. Link colours are
each source's catalog `markColor` from the theme (`home/colors.ts`), never hex.

## Callsign group rules
Expand Down Expand Up @@ -805,7 +809,7 @@ page" canvas); its Copy section is the copy source for `/health`.
`preview_subscription` both go through it. `group_ids` is derived from the rules (every group
named, `is` or `is_not`) and written only by `save_subscription`; never set it elsewhere.
`normalize_subscription_sources` is the only check on `sources` (both call it); its list is
`raw_spots_source_check` without `aprs`, so a new source goes in both. Band values are stored
`raw_spots_source_check` without `aprs`, so a new source goes in both, and in `home_flow`'s list. Band values are stored
lowercase, and a rule takes at most 1,000 values.
- Matching is `rule_matches` per rule inside `spot_matches`; never evaluate rules in TypeScript.
The web's `model.ts` only converts builder rows to rules (`rulesOf` drops rows without values) and
Expand Down
66 changes: 66 additions & 0 deletions supabase/migrations/20261027001400_home_flow_sources.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
-- home_flow() (20261027001300) also returns subscription_sources, so the Home page can draw the
-- configuration rather than only the traffic: every (connection, source) pair the matcher would
-- let through, traffic or not, decided by its own predicates: source_matches (an empty list is
-- "any" of POTA, SOTAwatch and RBN) and, for RBN, an AND group that names stations, as in
-- spot_matches. The source list is normalize_subscription_sources'; a new source goes in both.
-- The rest of the payload is unchanged: the caller's last 24 hours of deliveries (matched =
-- distinct spots by created_at, delivered = sent rows by sent_at). No spot data, and still out of
-- api_call. The shape is HomeFlow in web/src/home/types.ts, pinned by
-- supabase/tests/home_flow.test.sql.

create or replace function public.home_flow()
returns jsonb
language sql
stable
security definer
set search_path = public
as $$
with mine as (
select d.subscription_id, d.destination_id, d.spot_id, d.created_at, d.sent_at
from destinations dd
join deliveries d on d.destination_id = dd.id
where dd.user_id = auth.uid()
and (d.created_at > now() - interval '24 hours' or d.sent_at > now() - interval '24 hours')
),
matched as (
select m.subscription_id, r.source, m.spot_id
from mine m
join raw_spots r on r.id = m.spot_id
where m.created_at > now() - interval '24 hours'
),
sent as (
select m.subscription_id, m.destination_id from mine m where m.sent_at > now() - interval '24 hours'
)
select jsonb_build_object(
'matched', (select count(distinct spot_id) from matched),
'delivered', (select count(*) from sent),
'sources', coalesce((
select jsonb_agg(jsonb_build_object('source', s.source, 'matched', s.n) order by s.source)
from (select source, count(distinct spot_id) as n from matched group by source) s
), '[]'::jsonb),
'subscriptions', coalesce((
select jsonb_agg(jsonb_build_object('subscription_id', s.subscription_id, 'source', s.source, 'matched', s.n)
order by s.subscription_id, s.source)
from (select subscription_id, source, count(distinct spot_id) as n from matched group by subscription_id, source) s
), '[]'::jsonb),
'links', coalesce((
select jsonb_agg(jsonb_build_object('subscription_id', s.subscription_id, 'destination_id', s.destination_id, 'delivered', s.n)
order by s.subscription_id, s.destination_id)
from (select subscription_id, destination_id, count(*) as n from sent group by subscription_id, destination_id) s
), '[]'::jsonb),
'subscription_sources', coalesce((
select jsonb_agg(jsonb_build_object('subscription_id', s.id, 'source', src.source) order by s.id, src.source)
from subscriptions s
cross join unnest(array['pota', 'sotawatch', 'wwff', 'wwbota', 'rbn', 'dxcluster']) as src(source)
where s.user_id = auth.uid()
and public.source_matches(s.sources, src.source)
and (src.source <> 'rbn' or exists (
select 1 from jsonb_array_elements(s.rules) as g
where public.rules_name_stations(jsonb_build_array(g))))
), '[]'::jsonb)
)
where auth.uid() is not null;
$$;

revoke all on function public.home_flow() from public;
grant execute on function public.home_flow() to authenticated;
43 changes: 35 additions & 8 deletions supabase/tests/home_flow.test.sql
Original file line number Diff line number Diff line change
@@ -1,10 +1,11 @@
-- pgTAP: home_flow() (20261027001300_home_flow), the Home page's counts. Pins the
-- payload shape (HomeFlow in web/src/home/types.ts), the 24-hour windows (matched by
-- created_at, delivered by sent_at), distinct spots, per-user isolation and access.
-- pgTAP: home_flow() (20261027001300_home_flow, 20261027001400_home_flow_sources), the
-- Home page's counts and each connection's sources. Pins the payload shape (HomeFlow in
-- web/src/home/types.ts), the 24-hour windows (matched by created_at, delivered by sent_at),
-- distinct spots, the matcher's source rules, per-user isolation and access.
begin;
create extension if not exists pgtap with schema extensions;
\ir helpers/users.psql
select plan(11);
select plan(13);

create function pg_temp.ago(p_hours int) returns timestamptz
language sql as $$ select now() - make_interval(hours => p_hours) $$;
Expand All @@ -18,7 +19,14 @@ select save_subscription(jsonb_build_object('name', 'Parks', 'sources', array['p
'destinations', array[(select id from dc), (select id from dw)])) as parks,
save_subscription(jsonb_build_object('name', 'DX', 'sources', array['dxcluster'],
'rules', '[[{"field":"callsign","op":"is","values":["VP8PJ"]}]]'::jsonb, 'destinations', array[(select id from dw)])) as dx,
save_subscription(jsonb_build_object('name', 'Nowhere', 'destinations', '[]'::jsonb)) as nowhere;
save_subscription(jsonb_build_object('name', 'Nowhere', 'destinations', '[]'::jsonb)) as nowhere,
save_subscription(jsonb_build_object('name', 'Any call',
'rules', '[[{"field":"callsign","op":"is","values":["KK4PWJ"]}]]'::jsonb, 'destinations', '[]'::jsonb)) as anycall,
save_subscription(jsonb_build_object('name', 'Either',
'rules', '[[{"field":"callsign","op":"is","values":["KK4PWJ"]}], [{"field":"band","op":"is","values":["20m"]}]]'::jsonb,
'destinations', '[]'::jsonb)) as either,
save_subscription(jsonb_build_object('name', 'Everything', 'sources', array['pota', 'sotawatch', 'wwff', 'wwbota', 'rbn', 'dxcluster'],
'rules', '[[{"field":"callsign","op":"is","values":["KK4PWJ"]}]]'::jsonb, 'destinations', '[]'::jsonb)) as everything;
select pg_temp.as_user('22222222-2222-2222-2222-222222222222');
create temp table dp as select * from create_destination('push', 'Phone', null);
create temp table bsub as
Expand Down Expand Up @@ -64,7 +72,7 @@ select pg_temp.as_user('11111111-1111-1111-1111-111111111111');
create temp table a as select public.home_flow() as p;

select set_eq($$ select jsonb_object_keys(p) from a $$,
array['matched', 'delivered', 'sources', 'subscriptions', 'links'], 'payload keys');
array['matched', 'delivered', 'sources', 'subscriptions', 'links', 'subscription_sources'], 'payload keys');
select is((select (p ->> 'matched')::int from a), 3, 'matched counts distinct spots created in the last 24 hours');
select is((select (p ->> 'delivered')::int from a), 4, 'delivered counts rows sent in the last 24 hours, one per destination');
select is((select p -> 'sources' from a),
Expand All @@ -79,21 +87,40 @@ select set_eq(
$$ values ((select parks from subs), (select id from dc), 1), ((select parks from subs), (select id from dw), 2),
((select dx from subs), (select id from dw), 1) $$,
'links: delivered per connection and destination');
select set_eq(
$$ select (e ->> 'subscription_id')::uuid, e ->> 'source' from a, jsonb_array_elements(p -> 'subscription_sources') e $$,
$$ select x.id, x.source from (values
((select parks from subs), 'pota'),
((select dx from subs), 'dxcluster'),
((select nowhere from subs), 'pota'), ((select nowhere from subs), 'sotawatch'),
((select anycall from subs), 'pota'), ((select anycall from subs), 'sotawatch'), ((select anycall from subs), 'rbn'),
((select either from subs), 'pota'), ((select either from subs), 'sotawatch'), ((select either from subs), 'rbn')
) x(id, source)
union all
select (select everything from subs), unnest(array['pota', 'sotawatch', 'wwff', 'wwbota', 'rbn', 'dxcluster']) $$,
'subscription_sources: every connection, traffic or not, with the sources the matcher lets through');
select ok(not exists (
select 1 from a, jsonb_array_elements(p -> 'subscription_sources') e
where (e ->> 'subscription_id')::uuid = (select id from bsub)),
'subscription_sources holds only the caller''s connections');
reset role;

select pg_temp.as_user('22222222-2222-2222-2222-222222222222');
select is(public.home_flow(),
jsonb_build_object('matched', 1, 'delivered', 1,
'sources', '[{"source": "pota", "matched": 1}]'::jsonb,
'subscriptions', jsonb_build_array(jsonb_build_object('subscription_id', (select id from bsub), 'source', 'pota', 'matched', 1)),
'links', jsonb_build_array(jsonb_build_object('subscription_id', (select id from bsub), 'destination_id', (select id from dp), 'delivered', 1))),
'links', jsonb_build_array(jsonb_build_object('subscription_id', (select id from bsub), 'destination_id', (select id from dp), 'delivered', 1)),
'subscription_sources', jsonb_build_array(
jsonb_build_object('subscription_id', (select id from bsub), 'source', 'pota'),
jsonb_build_object('subscription_id', (select id from bsub), 'source', 'sotawatch'))),
'another user sees only their own deliveries');
reset role;

-- No deliveries at all: zeros and empty lists, not nulls.
delete from deliveries where destination_id in (select id from dp);
select pg_temp.as_user('22222222-2222-2222-2222-222222222222');
select is(public.home_flow(),
select is(public.home_flow() - 'subscription_sources',
'{"matched": 0, "delivered": 0, "sources": [], "subscriptions": [], "links": []}'::jsonb,
'an account with nothing in 24 hours gets zeros and empty lists');
reset role;
Expand Down
42 changes: 11 additions & 31 deletions web/src/home/FlowDiagram.tsx
Original file line number Diff line number Diff line change
@@ -1,37 +1,31 @@
import type { ReactNode } from "react";
import { Box, Card, CardActionArea, Stack, Typography, useTheme } from "@mui/material";
import { styled } from "@mui/material/styles";
import SendIcon from "@mui/icons-material/Send";
import { Link as RouterLink } from "react-router";
import { sourceName } from "../sources/sources";
import { sourceColor } from "./colors";
import { columnHeight, linkPaths, NODE_HEIGHT, NODE_WIDTH, type LinkPath } from "./layout";
import { connectionDetail, count, destinationDetail, moreConnectionsName, type Flow, type FlowLink } from "./model";

/** The "more" nodes stand for several, so their outline is dashed. */
const MoreCard = styled(Card)({ borderStyle: "dashed" });
import { connectionDetail, count, destinationDetail, type Flow, type FlowLink } from "./model";

interface NodeProps {
to: string;
icon: ReactNode;
name: string;
detail: string;
more?: boolean;
}

/** One 56 px node: an icon or colour dot, a name and a caption, linking to its page. */
export function FlowNode({ to, icon, name, detail, more = false }: NodeProps) {
const Frame = more ? MoreCard : Card;
export function FlowNode({ to, icon, name, detail }: NodeProps) {
return (
<Frame variant="outlined" sx={{ height: NODE_HEIGHT, flex: "none" }}>
<Card variant="outlined" sx={{ height: NODE_HEIGHT, flex: "none" }}>
<CardActionArea component={RouterLink} to={to} sx={{ height: 1, px: 1.5, display: "flex", gap: 1.5, justifyContent: "flex-start" }}>
{icon}
<Box sx={{ minWidth: 0 }}>
<Typography variant="subtitle2" noWrap>{name}</Typography>
<Typography variant="caption" color="text.secondary" component="div" noWrap>{detail}</Typography>
</Box>
</CardActionArea>
</Frame>
</Card>
);
}

Expand Down Expand Up @@ -71,12 +65,10 @@ function Links({ height, paths, links }: { height: number; paths: LinkPath[]; li
/** Sources, connections and destinations in three columns, joined by curved links (desktop only). */
export function FlowDiagram({ flow }: { flow: Flow }) {
const theme = useTheme();
const sourceRows = flow.sources.length + (flow.moreSources > 0 ? 1 : 0);
const connectionRows = flow.connections.length + (flow.moreConnections ? 1 : 0);
const destinationRows = flow.destinations.length + (flow.moreDestinations > 0 ? 1 : 0);
const height = columnHeight(Math.max(sourceRows, connectionRows, destinationRows));
const left = linkPaths(sourceRows, connectionRows, height, flow.sourceLinks);
const right = linkPaths(connectionRows, destinationRows, height, flow.destinationLinks);
const { sources, connections, destinations } = flow;
const height = columnHeight(Math.max(sources.length, connections.length, destinations.length));
const left = linkPaths(sources.length, connections.length, height, flow.sourceLinks);
const right = linkPaths(connections.length, destinations.length, height, flow.destinationLinks);
const heading = (label: string) => (
<Typography variant="overline" color="text.secondary" component="div" sx={{ width: NODE_WIDTH, flex: "none" }}>{label}</Typography>
);
Expand All @@ -93,39 +85,27 @@ export function FlowDiagram({ flow }: { flow: Flow }) {
</Box>
<Box sx={{ display: "flex", height }}>
<Column>
{flow.sources.map((s) => (
{sources.map((s) => (
<FlowNode
key={s.id} to={`/sources/${s.id}`} icon={<Dot color={sourceColor(theme, s.id)} />}
name={sourceName(s.id)} detail={`${count(s.matched)} matched`}
/>
))}
{flow.moreSources > 0 && (
<FlowNode to="/sources" icon={<Dot color="none" />} name={`+${flow.moreSources} more`} detail="View all" more />
)}
</Column>
<Links height={height} paths={left} links={flow.sourceLinks} />
<Column>
{flow.connections.map((c) => (
{connections.map((c) => (
<FlowNode
key={c.id} to={`/connections/${c.id}`} icon={<Dot color={sourceColor(theme, c.source)} />}
name={c.name} detail={connectionDetail(c)}
/>
))}
{flow.moreConnections && (
<FlowNode
to="/connections" icon={<Dot color="none" />} name={moreConnectionsName(flow.moreConnections.count)}
detail={`${count(flow.moreConnections.matched)} matched · View all`} more
/>
)}
</Column>
<Links height={height} paths={right} links={flow.destinationLinks} />
<Column>
{flow.destinations.map((d) => (
{destinations.map((d) => (
<FlowNode key={d.id} to={`/destinations/${d.id}`} icon={<DestinationIcon />} name={d.name} detail={destinationDetail(d)} />
))}
{flow.moreDestinations > 0 && (
<FlowNode to="/destinations" icon={<DestinationIcon />} name={`+${flow.moreDestinations} more`} detail="View all" more />
)}
</Column>
</Box>
</Box>
Expand Down
Loading
Loading