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
6 changes: 4 additions & 2 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -291,8 +291,10 @@ The design is `docs/superpowers/specs/2026-10-27-iota-design.md`.
catalog; until then never add a second island list.
- The `island` rule (`is` / `is_not`, references or continents) is validated by
`normalize_subscription_rules` and matched by `rule_matches`; the web offers it only when the
sources list DX Cluster. The catalog's IOTA entry has `via: "dxcluster"`: kept out of
`LIVE_SOURCES`, health from DX Cluster (`healthId`), "Create connection" opens a DX Cluster one.
sources list DX Cluster. The catalog's IOTA entry has `via: "dxcluster"`: health from DX Cluster (`healthId`). It is in
`LIVE_SOURCES`, so the source picker offers it; picking it (or its "Create connection",
`?source=iota`) starts a DX Cluster connection with an Any island rule (`blankSub`), never an
`iota` source.
- Profiles and group statistics count program `'IOTA'` (`kpis.activations.iota`,
`kpis.places.islands`); Stats only `daily[].iota` / `hourly[].iota`, for the catalog's rates.

Expand Down
4 changes: 2 additions & 2 deletions web/src/sources/SourceDetailPage.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -101,7 +101,7 @@ describe("SourceDetailPage", () => {
expect(screen.getByTestId("location")).toHaveAttribute("data-search", "?source=pota");
});

it("on IOTA, lists DX Cluster connections with an island rule and creates a DX Cluster one", async () => {
it("on IOTA, lists DX Cluster connections with an island rule and creates one from IOTA", async () => {
const user = userEvent.setup();
const island = [[{ field: "island", op: "is", values: ["EU"] }]];
open("/sources/iota", [
Expand All @@ -114,7 +114,7 @@ describe("SourceDetailPage", () => {
expect(screen.queryByRole("link", { name: "Parks" })).not.toBeInTheDocument();
expect(loadRecentSpots).toHaveBeenCalledWith("iota");
await user.click(screen.getByRole("link", { name: "Create connection" }));
expect(screen.getByTestId("location")).toHaveAttribute("data-search", "?source=dxcluster");
expect(screen.getByTestId("location")).toHaveAttribute("data-search", "?source=iota");
});

it("says when no subscription uses the source, and offers none on APRS-IS", () => {
Expand Down
2 changes: 1 addition & 1 deletion web/src/sources/SourceDetailPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -115,7 +115,7 @@ export function SourceDetailPage({ prefs, subs }: { prefs: TimePrefs; subs: Subs
</Button>
)}
{source.alerts && (
<Button variant="contained" startIcon={<AddIcon />} component={RouterLink} to={`/connections/new?source=${healthId(source)}`}>
<Button variant="contained" startIcon={<AddIcon />} component={RouterLink} to={`/connections/new?source=${source.id}`}>
Create connection
</Button>
)}
Expand Down
4 changes: 2 additions & 2 deletions web/src/sources/sources.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -102,9 +102,9 @@ describe("SOURCES catalog fields", () => {
expect(findLiveSource(undefined)).toBeUndefined();
});

it("carries IOTA on DX Cluster and never offers it as a connection source", () => {
it("carries IOTA on DX Cluster and offers both as connection sources", () => {
expect(SOURCES.filter((s) => s.via !== null).map((s) => [s.id, s.via])).toEqual([["iota", "dxcluster"]]);
expect(LIVE_SOURCES.map((s) => s.id)).not.toContain("iota");
expect(LIVE_SOURCES.map((s) => s.id)).toContain("iota");
expect(LIVE_SOURCES.map((s) => s.id)).toContain("dxcluster");
const iota = findLiveSource("iota")!;
expect(iota.schema!.find((f) => f.name === "reference")!.description).toBe("IOTA reference.");
Expand Down
7 changes: 5 additions & 2 deletions web/src/sources/sources.ts
Original file line number Diff line number Diff line change
Expand Up @@ -290,8 +290,11 @@ export function findLiveSource(id: string | undefined): SourceInfo | undefined {
return SOURCES.find((s) => s.id === id && s.status === "live");
}

/** Sources a subscription can pick and the recent-spots tabs show: live and alerting, and not carried by another. */
export const LIVE_SOURCES = SOURCES.filter((s) => s.status === "live" && s.alerts && s.via === null);
/**
* Sources a new connection can start from: live and alerting. A carried source (IOTA) starts a connection on
* its carrier with the rule that picks its spots (`blankSub`).
*/
export const LIVE_SOURCES = SOURCES.filter((s) => s.status === "live" && s.alerts);

/** The source whose ingest health stands for this one: its carrier, else itself. */
export function healthId(source: SourceInfo): string {
Expand Down
18 changes: 18 additions & 0 deletions web/src/subscriptions/SubscriptionEditorPage.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -167,6 +167,24 @@ describe("new subscription", () => {
}));
});

it("starts IOTA as a DX Cluster connection on every island, beside a profile's callsign", async () => {
const { user, unmount } = render({ source: "iota" });
expect(screen.getByText("DX Cluster")).toBeInTheDocument();
expect(screen.getByRole("button", { name: "Europe (EU)" })).toBeInTheDocument();
const every = rule("island", "is", "AF", "AN", "AS", "EU", "NA", "OC", "SA");
await waitFor(() => expect(previewSubscription).toHaveBeenCalledWith({ sources: ["dxcluster"], rules: [[every]] }));
await user.type(screen.getByLabelText(/^Name/), "Islands");
await user.click(save());
expect(saveSubscription).toHaveBeenCalledWith(expect.objectContaining({ name: "Islands", sources: ["dxcluster"], rules: [[every]] }));
unmount();

render({ source: "iota", callsign: "KK4PWJ" });
expect(screen.getByLabelText(/^Name/)).toHaveValue("KK4PWJ on the air");
await waitFor(() => expect(previewSubscription).toHaveBeenLastCalledWith({
sources: ["dxcluster"], rules: [[rule("callsign", "is", "KK4PWJ"), every]],
}));
});

it("starts filtered to the group a group's Get alerts passed, named once the groups load", async () => {
const { user } = render({ source: "rbn", group: "g2" });
expect(await screen.findByLabelText(/^Name/)).toHaveValue("RARS members on the air");
Expand Down
2 changes: 2 additions & 0 deletions web/src/subscriptions/SubscriptionsPage.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -166,6 +166,8 @@ describe("Pick your source", () => {
expect(within(dialog).getByRole("radio", { name: /WWFF/ })).toBeEnabled();
expect(within(dialog).queryByText("Not yet available")).not.toBeInTheDocument();
expect(within(dialog).getByRole("radio", { name: /Parks on the Air/ })).toBeChecked();
// IOTA rides on DX Cluster; the editor starts it as a DX Cluster connection with an island rule.
expect(within(dialog).getByRole("radio", { name: /IOTA/ })).toBeEnabled();

await user.click(within(dialog).getByRole("radio", { name: /Reverse Beacon Network/ }));
await user.click(within(dialog).getByRole("button", { name: "Continue" }));
Expand Down
12 changes: 10 additions & 2 deletions web/src/subscriptions/model.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,14 @@ describe("blankSub and toInput", () => {
expect(blankSub("pota", null, "g1", "US-2763").name).toBe("");
});

it("starts IOTA on DX Cluster with every island, beside a callsign, group or reference", () => {
const every: Rule = { field: "island", op: "is", values: CONTINENTS.map((c) => c.code) };
expect(blankSub("iota")).toMatchObject({ name: "", sources: ["dxcluster"], rules: [[every]] });
expect(blankSub("iota", "KK4PWJ").rules).toEqual([[{ field: "callsign", op: "is", values: ["KK4PWJ"] }, every]]);
expect(blankSub("iota", null, "g1").rules).toEqual([[{ field: "group", op: "is", values: ["g1"] }, every]]);
expect(blankSub("dxcluster").rules).toEqual([]);
});

it("reads exact reference values trimmed and upper-cased, each once", () => {
expect(referenceValues([" us-2763 ", "US-2763", "", "w4c/cm-001"])).toEqual(["US-2763", "W4C/CM-001"]);
// A list typed or pasted as one value is one chip per reference.
Expand Down Expand Up @@ -199,8 +207,8 @@ describe("sources", () => {
expect(pickableSource("aprs")).toBeNull();
expect(pickableSource("wwff")?.id).toBe("wwff");
expect(pickableSource("wwbota")?.id).toBe("wwbota");
// IOTA rides on DX Cluster: a connection picks DX Cluster and an island rule.
expect(pickableSource("iota")).toBeNull();
// IOTA rides on DX Cluster: picking it starts a DX Cluster connection with an island rule (blankSub).
expect(pickableSource("iota")?.id).toBe("iota");
expect(pickableSource(null)).toBeNull();
});

Expand Down
24 changes: 16 additions & 8 deletions web/src/subscriptions/model.ts
Original file line number Diff line number Diff line change
Expand Up @@ -294,7 +294,7 @@ export function singleSource(sources: string[]): SourceInfo | null {
return sources.length === 1 ? (SOURCES.find((s) => s.id === sources[0]) ?? null) : null;
}

/** A source a new subscription can be made from: live and alerting. */
/** A source a new subscription can be made from: live and alerting, or carried by one (IOTA). */
export function pickableSource(id: string | null): SourceInfo | null {
return LIVE_SOURCES.find((s) => s.id === id) ?? null;
}
Expand All @@ -311,13 +311,21 @@ export function needsCallsign(sources: string[], rules: Rules): boolean {
* A new subscription on one source, filtered to a callsign (a profile's Get alerts), else a group id (a group's),
* else one reference exactly (a reference page's).
*/
export const blankSub = (source: string, callsign?: string | null, group?: string | null, reference?: string | null): SubscriptionInput => ({
name: callsign ? `${callsign} on the air` : reference && !group ? `Activators at ${reference}` : "", enabled: true, sources: [source],
rules: callsign ? [[{ field: "callsign", op: "is", values: [callsign] }]]
: group ? [[{ field: "group", op: "is", values: [group] }]]
: reference ? [[{ field: "reference", op: "is", values: [reference] }]] : [],
quiet_minutes: 10, destinations: [],
});
/**
* A new subscription on one source. IOTA starts on DX Cluster, its carrier, with an island rule for
* every continent (Any island) beside any callsign, group or reference it starts with.
*/
export function blankSub(source: string, callsign?: string | null, group?: string | null, reference?: string | null): SubscriptionInput {
const iota = source === "iota";
const start: Rule[] = callsign ? [{ field: "callsign", op: "is", values: [callsign] }]
: group ? [{ field: "group", op: "is", values: [group] }]
: reference ? [{ field: "reference", op: "is", values: [reference] }] : [];
const rules: Rule[] = iota ? [...start, { field: "island", op: "is", values: islandValues([ANY_ISLAND]) }] : start;
return {
name: callsign ? `${callsign} on the air` : reference && !group ? `Activators at ${reference}` : "", enabled: true,
sources: [iota ? "dxcluster" : source], rules: rules.length ? [rules] : [], quiet_minutes: 10, destinations: [],
};
}

export function toInput(s: SubscriptionWithLinks): SubscriptionInput {
return {
Expand Down
Loading