From e4760173c0d84f2766009f77b84e1fbf513c8c25 Mon Sep 17 00:00:00 2001 From: MrAlders0n <55921894+MrAlders0n@users.noreply.github.com> Date: Sat, 10 Oct 2026 09:39:15 -0400 Subject: [PATCH] Sort top advertisers by flood or direct on the server, bump to 2.0.4 (#155) --- package-lock.json | 4 ++-- package.json | 2 +- src/api/client.ts | 5 +++-- src/features/stats/TalkersTab.tsx | 18 ++++++++++++++---- src/features/stats/types.ts | 2 ++ src/features/stats/useStats.ts | 8 ++++---- src/i18n/locales/en.json | 2 +- src/i18n/locales/fr.json | 2 +- src/i18n/locales/sv.json | 2 +- tests/api/client.test.ts | 5 +++-- tests/features/stats/TalkersTab.test.tsx | 22 ++++++++++++++++++---- tests/features/stats/region-gate.test.tsx | 2 +- 12 files changed, 51 insertions(+), 23 deletions(-) diff --git a/package-lock.json b/package-lock.json index 5217bc1..83f9550 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "beacon-web", - "version": "2.0.3", + "version": "2.0.4", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "beacon-web", - "version": "2.0.3", + "version": "2.0.4", "license": "AGPL-3.0-or-later", "dependencies": { "@nazka/map-gl-js-spiderfy": "^2.0.0", diff --git a/package.json b/package.json index 30f62db..13e195c 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "beacon-web", "private": true, - "version": "2.0.3", + "version": "2.0.4", "license": "AGPL-3.0-or-later", "type": "module", "scripts": { diff --git a/src/api/client.ts b/src/api/client.ts index 877aa2d..b6e59df 100644 --- a/src/api/client.ts +++ b/src/api/client.ts @@ -13,6 +13,7 @@ import type { PayloadBreakdownItem, TopNode, TopObserver, + AdvertiserSort, TopAdvertiser, TopTalker, RadioPreset, @@ -420,8 +421,8 @@ export function getTopObservers(iatas?: StatsRegion, since?: number, limit = 10) return request("/stats/top-observers", { ...statsRegionParams(iatas), since, limit }); } -export function getTopAdvertisers(iatas?: StatsRegion, since?: number, limit = 10): Promise { - return request("/stats/top-advertisers", { ...statsRegionParams(iatas), since, limit }); +export function getTopAdvertisers(iatas: StatsRegion | undefined, since: number | undefined, sort: AdvertiserSort, limit = 10): Promise { + return request("/stats/top-advertisers", { ...statsRegionParams(iatas), since, sort, limit }); } export function getTopTalkers(iatas?: StatsRegion, since?: number, limit = 10): Promise { diff --git a/src/features/stats/TalkersTab.tsx b/src/features/stats/TalkersTab.tsx index f60b5a3..ff5f031 100644 --- a/src/features/stats/TalkersTab.tsx +++ b/src/features/stats/TalkersTab.tsx @@ -1,15 +1,16 @@ -import { useMemo } from "react"; +import { useMemo, useState } from "react"; import { useTranslation } from "react-i18next"; import { useChartColors } from "./chartTheme"; import { rolledWindow, useTopAdvertisers, useTopTalkers } from "./useStats"; import { leaderboardOption } from "./chartOptions"; import { Card, ChartCard } from "./cards"; +import { Segmented } from "./Segmented"; import { DataTable, type Column } from "../../components/DataTable"; import { Badge } from "../../components/Badge"; import { IataChip } from "../../components/IataChip"; import { formatCount, formatRatePerDay } from "../../lib/formatters"; import { RANGE_MS } from "./types"; -import type { TopAdvertiser, StatsRange } from "./types"; +import type { AdvertiserSort, TopAdvertiser, StatsRange } from "./types"; interface TalkersTabProps { range: StatsRange; @@ -32,7 +33,8 @@ function coveredMs(range: StatsRange, fetchedAt: number) { export function TalkersTab({ range, onViewNode }: TalkersTabProps) { const { t } = useTranslation(); const colors = useChartColors(); - const topAdvertisers = useTopAdvertisers(range, 20); + const [advertSort, setAdvertSort] = useState("flood"); + const topAdvertisers = useTopAdvertisers(range, advertSort, 20); const topTalkers = useTopTalkers(range, 20); const advertisersLoading = topAdvertisers.isPending || topAdvertisers.isPlaceholderData; @@ -76,7 +78,15 @@ export function TalkersTab({ range, onViewNode }: TalkersTabProps) { const rangeLabel = t(`stats.ranges.${range}`); return (
- {t("talkers.floodDirect")}}> + setAdvertSort(v as AdvertiserSort)} + options={[{ value: "flood", label: t("talkers.flood") }, { value: "direct", label: t("talkers.direct") }]} + /> + }>
getTopAdvertisers(where, sinceFor(range), limit)), + queryKey: ["stats-top-advertisers", regionKey, range, sort, limit], + ...gatedOn(isResolved, () => getTopAdvertisers(where, sinceFor(range), sort, limit)), ...common, }); } diff --git a/src/i18n/locales/en.json b/src/i18n/locales/en.json index 0db96f8..a945368 100644 --- a/src/i18n/locales/en.json +++ b/src/i18n/locales/en.json @@ -815,7 +815,7 @@ "flood": "Flood", "direct": "Direct", "topAdvertisers": "Top advertisers · {{range}}", - "floodDirect": "flood · direct", + "sortBy": "Sort advertisers by", "noAdvertisers": "No advertisers", "topTalkers": "Top talkers · {{range}}", "byName": "by name" diff --git a/src/i18n/locales/fr.json b/src/i18n/locales/fr.json index 0503e69..c00b5e4 100644 --- a/src/i18n/locales/fr.json +++ b/src/i18n/locales/fr.json @@ -847,7 +847,7 @@ "flood": "Flood", "direct": "Direct", "topAdvertisers": "Principaux annonceurs · {{range}}", - "floodDirect": "flood · direct", + "sortBy": "Trier les annonceurs par", "noAdvertisers": "Aucun annonceur", "topTalkers": "Principaux émetteurs · {{range}}", "byName": "par nom" diff --git a/src/i18n/locales/sv.json b/src/i18n/locales/sv.json index 197255b..d455120 100644 --- a/src/i18n/locales/sv.json +++ b/src/i18n/locales/sv.json @@ -815,7 +815,7 @@ "flood": "Flood", "direct": "Direkt", "topAdvertisers": "Mest aktiva annonsörer · {{range}}", - "floodDirect": "flood · direkt", + "sortBy": "Sortera annonsörer efter", "noAdvertisers": "Inga annonsörer", "topTalkers": "Mest aktiva sändare · {{range}}", "byName": "efter namn" diff --git a/tests/api/client.test.ts b/tests/api/client.test.ts index df55de4..a637077 100644 --- a/tests/api/client.test.ts +++ b/tests/api/client.test.ts @@ -431,15 +431,16 @@ describe("stats endpoints", () => { expect(url.searchParams.get("limit")).toBe("15"); }); - it("hits /stats/top-advertisers with iatas/since/limit", async () => { + it("hits /stats/top-advertisers with iatas/since/sort/limit", async () => { const getUrl = mockFetchOnce([]); - await getTopAdvertisers(["YOW", "YYZ"], 1700000000000, 10); + await getTopAdvertisers(["YOW", "YYZ"], 1700000000000, "direct", 10); const url = new URL(getUrl()); expect(url.pathname).toContain("/stats/top-advertisers"); expect(url.searchParams.get("iatas")).toBe("YOW,YYZ"); expect(url.searchParams.get("since")).toBe("1700000000000"); + expect(url.searchParams.get("sort")).toBe("direct"); expect(url.searchParams.get("limit")).toBe("10"); }); diff --git a/tests/features/stats/TalkersTab.test.tsx b/tests/features/stats/TalkersTab.test.tsx index de7f887..5880b07 100644 --- a/tests/features/stats/TalkersTab.test.tsx +++ b/tests/features/stats/TalkersTab.test.tsx @@ -1,5 +1,5 @@ import { afterEach, beforeEach, expect, it, onTestFinished, vi } from "vitest"; -import { act, fireEvent, render, screen, waitFor } from "@testing-library/react"; +import { act, fireEvent, render, screen, waitFor, within } from "@testing-library/react"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { TalkersTab } from "../../../src/features/stats/TalkersTab"; import { getTopAdvertisers, getTopTalkers } from "../../../src/api/client"; @@ -59,7 +59,8 @@ it.each(["region", "range"] as const)("hides previous results while the new %s i await screen.findByText("New sender"); const request = vi.mocked(getTopAdvertisers).mock.calls[1]!; expect(request[0]).toEqual(filter === "region" ? ["YOW"] : ["YVR"]); - expect(request[2]).toBe(20); + expect(request[2]).toBe("flood"); + expect(request[3]).toBe(20); const windowMs = (filter === "range" ? 7 : 1) * 86_400_000; expect(request[1]).toBeGreaterThanOrEqual(requestedAt - windowMs); expect(request[1]).toBeLessThanOrEqual(Date.now() - windowMs); @@ -73,7 +74,7 @@ it("shows an advertiser refetch error instead of cached rows, keeps the sender p await screen.findByText("Failed to load"); expect(screen.queryByText("Old advertiser")).not.toBeInTheDocument(); expect(screen.getByText("Old sender")).toBeInTheDocument(); - expect(client.getQueryData(["stats-top-advertisers", "YVR", "24h", 20])).toEqual(advertisers); + expect(client.getQueryData(["stats-top-advertisers", "YVR", "24h", "flood", 20])).toEqual(advertisers); vi.mocked(getTopAdvertisers).mockResolvedValue([{ ...advertisers[0]!, nodeName: "Recovered advertiser" }]); await act(() => client.refetchQueries({ queryKey: ["stats-top-advertisers"] })); await screen.findByText("Recovered advertiser"); @@ -103,7 +104,7 @@ it("retains valid rows and rates during an ordinary background refresh", async ( vi.mocked(getTopAdvertisers).mockReturnValue(next.promise); let refresh!: Promise; await act(async () => { refresh = client.refetchQueries({ queryKey: ["stats-top-advertisers"] }); }); - expect(client.getQueryState(["stats-top-advertisers", "YVR", "24h", 20])?.fetchStatus).toBe("fetching"); + expect(client.getQueryState(["stats-top-advertisers", "YVR", "24h", "flood", 20])?.fetchStatus).toBe("fetching"); expect(screen.getByText("Old advertiser")).toBeInTheDocument(); expect(screen.getByText("10/d")).toBeInTheDocument(); expect(screen.getByText("4/d")).toBeInTheDocument(); @@ -158,3 +159,16 @@ it("divides advert rates by the hours the rollup actually covers", async () => { expect(await screen.findByText("21/d")).toBeInTheDocument(); expect(screen.getByText("4.2/d")).toBeInTheDocument(); }); + +it("asks the server for flood-ranked advertisers by default and refetches direct-ranked from the toggle", async () => { + mount(); + await screen.findByText("Old advertiser"); + expect(vi.mocked(getTopAdvertisers).mock.calls[0]!.slice(2)).toEqual(["flood", 20]); + const sort = screen.getByRole("group", { name: "Sort advertisers by" }); + expect(within(sort).getByRole("button", { name: "Flood" })).toHaveAttribute("aria-pressed", "true"); + vi.mocked(getTopAdvertisers).mockResolvedValue([{ ...advertisers[0]!, nodeName: "Direct advertiser" }]); + fireEvent.click(within(sort).getByRole("button", { name: "Direct" })); + await screen.findByText("Direct advertiser"); + expect(vi.mocked(getTopAdvertisers).mock.calls[1]!.slice(2)).toEqual(["direct", 20]); + expect(within(sort).getByRole("button", { name: "Direct" })).toHaveAttribute("aria-pressed", "true"); +}); diff --git a/tests/features/stats/region-gate.test.tsx b/tests/features/stats/region-gate.test.tsx index 4956933..d8f2ede 100644 --- a/tests/features/stats/region-gate.test.tsx +++ b/tests/features/stats/region-gate.test.tsx @@ -22,7 +22,7 @@ const hooks = [ ["usePayloadBreakdown", () => usePayloadBreakdown("24h"), client.getPayloadBreakdown], ["useTopNodes", () => useTopNodes("24h"), client.getTopNodes], ["useTopObservers", () => useTopObservers("24h"), client.getTopObservers], - ["useTopAdvertisers", () => useTopAdvertisers("24h"), client.getTopAdvertisers], + ["useTopAdvertisers", () => useTopAdvertisers("24h", "flood"), client.getTopAdvertisers], ["useTopTalkers", () => useTopTalkers("24h"), client.getTopTalkers], ["useRadioPresets", () => useRadioPresets(), client.getRadioPresets], ["useNodeTypes", () => useNodeTypes(), client.getStatsNodeTypes],