Fan Tier 2: venue pages, attack zones, broadcasts, push prefs, squads + UI/UX polish

Features (all from already-stored data):
- Venue pages: /venues hub + /venue/:name with each stadium's schedule,
  altitude badge (>1000 m), local timezone. Linked from the match hero,
  nav More sheet, and footer.
- Attacking-zones card on the match Stats tab (left/center/right share per
  side, from the stored FotMob payload).
- US broadcast networks on the pre-match hero (SUMMARY_V 6).
- Notification preferences: follow multiple teams + an all-matches feed
  ('*'); push_subs keyed by (endpoint, team) with a safe one-time migration;
  device drops its browser subscription only when the last follow is removed.
- FIFA tournament squad card on team pages (26-man list, position groups).
- Player match logs now show DNP rows for unused-squad appearances.
- OG/share meta for /player and /venue.

Polish (adversarial audit: 25 confirmed findings, all fixed):
- Mobile horizontal-scroll eliminated on /teams and /venues (grid-item
  min-w-0 blowout); two-line venue fixture rows.
- Truncation actually fires now across team fixtures, road-to-final,
  recent-form, H2H meetings, venue rows (min-w-0 on flex children).
- Team hero no longer detaches the standing block on wrap; bell denied-state
  no longer overflows the h1.
- AllMatchesBell: 36px touch target, aria-label, own i18n key, even rhythm.
- Server hardening: cached player index (one cross-fixture scan, not per
  request), migration rollback guard, 400 (not 500) on malformed name URLs,
  all-zero zones render nothing, tidyName fixes shouted particles (DE→De).
- German polish: removed stray English ("anytime", "Build-Geocode"), clearer
  squad note.

Gates: 170 tests, build, typecheck:server — all green.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-06-13 10:05:27 +02:00
parent 0fd13d4c24
commit a1fb6d48c0
26 changed files with 726 additions and 104 deletions
+41 -8
View File
@@ -97,10 +97,11 @@ CREATE TABLE IF NOT EXISTS scorer_props (
CREATE INDEX IF NOT EXISTS idx_scorer_props ON scorer_props(fixture_num, market, athlete, captured_at);
CREATE TABLE IF NOT EXISTS push_subs (
endpoint TEXT PRIMARY KEY,
endpoint TEXT NOT NULL,
json TEXT NOT NULL,
team TEXT,
created_at INTEGER NOT NULL
team TEXT NOT NULL,
created_at INTEGER NOT NULL,
PRIMARY KEY (endpoint, team)
);
CREATE TABLE IF NOT EXISTS inplay_history (
@@ -147,6 +148,34 @@ export function db(): DatabaseSync {
// Dead tables from abandoned plans — never written or read. (`injuries`
// stays: the planned API-Football sweep will fill it.)
d.exec('DROP TABLE IF EXISTS squad; DROP TABLE IF EXISTS goalscorers;');
// push_subs grew from one team per device to one row per (endpoint, team);
// a pre-existing table keeps its old single-column PK, so rebuild it once.
const pushPk = d
.prepare("SELECT COUNT(*) AS c FROM pragma_table_info('push_subs') WHERE pk > 0")
.get() as { c: number };
if (pushPk.c === 1) {
d.exec('DROP TABLE IF EXISTS push_subs_new;'); // clear any half-done prior attempt
try {
d.exec(`
BEGIN;
CREATE TABLE push_subs_new (
endpoint TEXT NOT NULL,
json TEXT NOT NULL,
team TEXT NOT NULL,
created_at INTEGER NOT NULL,
PRIMARY KEY (endpoint, team)
);
INSERT INTO push_subs_new SELECT endpoint, json, COALESCE(team, '*'), created_at FROM push_subs;
DROP TABLE push_subs;
ALTER TABLE push_subs_new RENAME TO push_subs;
COMMIT;
`);
console.log('[db] push_subs migrated to (endpoint, team) rows');
} catch (e) {
try { d.exec('ROLLBACK;'); } catch { /* no open txn */ }
console.error('[db] push_subs migration failed, left as-is', e);
}
}
_db = d;
console.log(`[db] sqlite ready → ${file}`);
return d;
@@ -242,24 +271,28 @@ export function injuriesFor(team: string): { player: string; status: string | nu
export interface PushSub {
endpoint: string;
json: string;
team: string | null;
/** Followed team, or '*' for the all-matches feed. */
team: string;
}
export function addPushSub(endpoint: string, json: string, team: string | null): void {
export function addPushSub(endpoint: string, json: string, team: string): void {
db()
.prepare('INSERT OR REPLACE INTO push_subs (endpoint, json, team, created_at) VALUES (?, ?, ?, ?)')
.run(endpoint, json, team, Date.now());
}
export function removePushSub(endpoint: string): void {
db().prepare('DELETE FROM push_subs WHERE endpoint = ?').run(endpoint);
/** Drop one followed team, or the whole device when no team is given. */
export function removePushSub(endpoint: string, team?: string): void {
if (team != null) db().prepare('DELETE FROM push_subs WHERE endpoint = ? AND team = ?').run(endpoint, team);
else db().prepare('DELETE FROM push_subs WHERE endpoint = ?').run(endpoint);
}
/** Subscriptions following any of these teams — or every match ('*'). */
export function pushSubsForTeams(teams: string[]): PushSub[] {
if (teams.length === 0) return [];
const marks = teams.map(() => '?').join(',');
return db()
.prepare(`SELECT endpoint, json, team FROM push_subs WHERE team IN (${marks})`)
.prepare(`SELECT endpoint, json, team FROM push_subs WHERE team IN (${marks}) OR team = '*'`)
.all(...teams) as unknown as PushSub[];
}
+31 -6
View File
@@ -12,7 +12,7 @@ import { buildPreview, buildTeamProfile } from './preview';
import { buildPlayerProfile, playersIndex } from './player';
import { buildScoreboard, snapshotCheck } from './scoreboard';
import { buildStatsHub, fixtureEvents, goldenBootRows } from './stats';
import { normalizeMatchStats } from './ingest/fotmobStatsNormalize';
import { normalizeAttackingZones, normalizeMatchStats } from './ingest/fotmobStatsNormalize';
import { confirmedXI } from './ingest/fifaLineupNormalize';
import { buildIcs } from './ics';
import { db, healthAll, oddsFor, latestOddsAll, dbCounts, inplayHistory, recordInplay, getMatchProvider, allFixtureResults, saveFixtureResult, allModelHistory, saveModelHistory } from './db/db';
@@ -23,6 +23,12 @@ import { loadStatic } from './preview';
import { canonicalTeam } from '../../src/lib/teams';
import type { MatchStatus, ServerMessage } from '../../src/lib/types';
/** decodeURIComponent that returns null on malformed input (a stray "%GG"
* would otherwise throw a URIError → 500). */
function safeDecode(s: string): string | null {
try { return decodeURIComponent(s); } catch { return null; }
}
const PORT = Number(process.env.PORT ?? 8787);
const STATIC_DIR = path.resolve(process.env.STATIC_DIR ?? path.join(process.cwd(), 'dist'));
const IS_PROD = process.env.NODE_ENV === 'production';
@@ -120,9 +126,9 @@ export function buildServer() {
});
app.post('/api/push/unsubscribe', async (req, reply) => {
if (!pushEnabled) return reply.code(404).send({ error: 'push disabled' });
const b = (req.body ?? {}) as { endpoint?: string };
const b = (req.body ?? {}) as { endpoint?: string; team?: string };
if (!b.endpoint) return reply.code(400).send({ error: 'endpoint required' });
pushUnsubscribe(b.endpoint);
pushUnsubscribe(b.endpoint, typeof b.team === 'string' ? b.team : undefined);
return { ok: true };
});
@@ -143,6 +149,7 @@ export function buildServer() {
homeTeamId?: number | null;
shots?: FmShot[];
stats?: unknown;
attackingZones?: unknown;
momentum?: { main?: { data?: { minute: number; value: number }[] } } | null;
matchFacts?: { playerOfTheMatch?: { name?: { fullName?: string }; rating?: { num?: string }; teamName?: string } } | null;
lineup?: { lineupType?: string; homeTeam?: FmLineupTeam; awayTeam?: FmLineupTeam } | null;
@@ -218,6 +225,7 @@ export function buildServer() {
: null,
weather: weather ? weather.data : null,
fullStats: fullStats.length ? fullStats : null,
zones: normalizeAttackingZones(fm?.data.attackingZones),
// Official XI published by FIFA, or FotMob no longer calling its
// lineup "predicted" — either way, stop labeling them as projections.
lineupConfirmed: confirmedXI(fifa?.data.live) != null || fm?.data.lineup?.lineupType === 'standard',
@@ -258,14 +266,16 @@ export function buildServer() {
return preview ?? reply.code(404).send({ error: 'no such fixture' });
});
app.get('/api/team/:name', async (req, reply) => {
const name = canonicalTeam(decodeURIComponent((req.params as { name: string }).name));
const profile = buildTeamProfile(name, state, model);
const raw = safeDecode((req.params as { name: string }).name);
if (raw == null) return reply.code(400).send({ error: 'bad name' });
const profile = buildTeamProfile(canonicalTeam(raw), state, model);
return profile ?? reply.code(404).send({ error: 'no such team' });
});
// Player profiles, assembled from stored boards, lineups, shots and events.
// Either source's spelling of a name resolves ("Hwang In-Beom" = "In-Beom Hwang").
app.get('/api/player/:name', async (req, reply) => {
const name = decodeURIComponent((req.params as { name: string }).name);
const name = safeDecode((req.params as { name: string }).name);
if (name == null) return reply.code(400).send({ error: 'bad name' });
const profile = buildPlayerProfile(name, state);
return profile ?? reply.code(404).send({ error: 'unknown player' });
});
@@ -392,6 +402,21 @@ export function buildServer() {
`World Cup 2026 · title odds ${(odds.champion * 100).toFixed(1)}% by the Cup26 model`,
);
}
const mPlayer = /^\/player\/([^/?]+)(?:\?|$)/.exec(url);
if (mPlayer) {
const p = buildPlayerProfile(decodeURIComponent(mPlayer[1]!), state);
if (!p) return null;
const goals = p.totals['goals']?.value ?? p.bootGoals;
const goalsText = goals >= 1 ? ` · ${Math.round(goals)} goal${goals >= 2 ? 's' : ''}` : '';
return withMeta(`${p.name} — Cup26`, `World Cup 2026 · ${p.team}${goalsText}`);
}
const mVenue = /^\/venue\/([^/?]+)(?:\?|$)/.exec(url);
if (mVenue) {
const venue = decodeURIComponent(mVenue[1]!);
const n = state.allFixtures().filter((f) => f.venue === venue).length;
if (!n) return null;
return withMeta(`${venue} — Cup26`, `World Cup 2026 venue · ${n} matches`);
}
} catch { /* fall through to the plain shell */ }
return null;
};
+14 -3
View File
@@ -75,7 +75,7 @@ export interface EspnTimelineEvent {
shootout?: boolean;
}
/** Current normalizer schema — bump to make the boot backfill re-store. */
export const SUMMARY_V = 5;
export const SUMMARY_V = 6;
export interface EspnCommentaryLine { minute: number | null; text: string }
@@ -102,6 +102,8 @@ export interface EspnSummary {
report?: { headline: string; story: string } | null;
/** Match video clips (highlights, reactions) — links open on ESPN. */
videos?: EspnVideo[];
/** US broadcast networks (ESPN only lists its home market). */
broadcasts?: string[];
}
export interface RawSummary {
@@ -109,7 +111,10 @@ export interface RawSummary {
commentary?: { sequence?: number | string; time?: { displayValue?: string }; text?: string }[];
article?: { headline?: string; story?: string };
videos?: { headline?: string; duration?: number; thumbnail?: string; links?: { web?: { href?: string } } }[];
header?: { competitions?: { competitors?: { id: string; team?: { displayName?: string }; homeAway: 'home' | 'away' }[] }[] };
header?: { competitions?: {
competitors?: { id: string; team?: { displayName?: string }; homeAway: 'home' | 'away' }[];
broadcasts?: { media?: { shortName?: string } }[];
}[] };
headToHeadGames?: { events?: { gameDate: string; homeTeamId: string; awayTeamId: string; homeTeamScore: string; awayTeamScore: string }[] }[];
boxscore?: {
form?: { team?: { id: string }; events?: { gameResult?: string }[] }[];
@@ -206,5 +211,11 @@ export function normalizeSummary(raw: RawSummary): EspnSummary {
})
.slice(0, 6);
return { v: SUMMARY_V, fetchedAt: Date.now(), venue: raw.gameInfo?.venue?.fullName ?? null, teams, h2h, form, lineups, stats, events, commentary, report, videos };
const broadcasts = [...new Set(
(raw.header?.competitions?.[0]?.broadcasts ?? [])
.map((b) => b.media?.shortName?.trim() ?? '')
.filter(Boolean),
)].slice(0, 4);
return { v: SUMMARY_V, fetchedAt: Date.now(), venue: raw.gameInfo?.venue?.fullName ?? null, teams, h2h, form, lineups, stats, events, commentary, report, videos, broadcasts };
}
+31 -1
View File
@@ -1,5 +1,5 @@
import { describe, expect, it } from 'vitest';
import { confirmedXI } from './fifaLineupNormalize';
import { confirmedXI, squadList, tidyName } from './fifaLineupNormalize';
const squad = (starters: number, bench = 15, prefix = 'P') => ({
Players: [
@@ -32,3 +32,33 @@ describe('confirmedXI', () => {
expect(confirmedXI({ HomeTeam: { Players: 'nope' }, AwayTeam: squad(11) })).toBeNull();
});
});
describe('squadList', () => {
it('tidies the shouted surnames and orders by position group then shirt', () => {
const live = {
HomeTeam: {
Players: [
{ Status: 2, ShirtNumber: 9, Position: 3, ShortName: [{ Description: 'Raul JIMENEZ' }] },
{ Status: 1, ShirtNumber: 1, Position: 0, ShortName: [{ Description: 'Raul RANGEL' }] },
{ Status: 1, ShirtNumber: 3, Position: 1, ShortName: [{ Description: 'Cesar MONTES' }] },
{ Status: 1, ShirtNumber: 6, Position: 2, ShortName: [{ Description: 'J. AL-HUSSAIN' }] },
],
},
};
expect(squadList(live, 'home')).toEqual([
{ name: 'Raul Rangel', num: 1, pos: 0 },
{ name: 'Cesar Montes', num: 3, pos: 1 },
{ name: 'J. Al-Hussain', num: 6, pos: 2 },
{ name: 'Raul Jimenez', num: 9, pos: 3 },
]);
expect(squadList(live, 'away')).toEqual([]);
expect(squadList(null, 'home')).toEqual([]);
});
it('tidyName keeps initials, fixes short particles, leaves mixed-case', () => {
expect(tidyName('J. GALLARDO')).toBe('J. Gallardo');
expect(tidyName('Hirving Lozano')).toBe('Hirving Lozano');
expect(tidyName('Kevin DE BRUYNE')).toBe('Kevin De Bruyne');
expect(tidyName('EL YAMIQ')).toBe('El Yamiq');
});
});
+35 -2
View File
@@ -1,8 +1,14 @@
// Pure normalizer for the FIFA live payload's lineups — no network or DB.
// Pure normalizers for the FIFA live payload — no network or DB.
// FIFA publishes the official XI ~7590 min before kickoff; Status === 1
// marks starters within the 26-man squad list.
interface RawFifaPlayer { Status?: number; ShortName?: { Description?: string }[] }
interface RawFifaPlayer {
Status?: number;
ShirtNumber?: number;
/** 0 = GK, 1 = DF, 2 = MF, 3 = FW (verified against the match-1 squads). */
Position?: number;
ShortName?: { Description?: string }[];
}
interface RawFifaTeam { Players?: RawFifaPlayer[] }
/** The `live` value stored under match_provider provider='fifa'. */
export interface RawFifaLive { HomeTeam?: RawFifaTeam; AwayTeam?: RawFifaTeam }
@@ -27,3 +33,30 @@ export function confirmedXI(raw: unknown): { home: string[]; away: string[] } |
const away = side(live?.AwayTeam);
return home && away ? { home, away } : null;
}
/** "Cesar MONTES" → "Cesar Montes", "Kevin DE BRUYNE" → "Kevin De Bruyne"
* (FIFA shouts surnames); initials like "J." and mixed-case words survive. */
const tidyWord = (w: string): string =>
w.includes('.') || w !== w.toUpperCase()
? w
: w.split('-').map((p) => (p ? p.charAt(0) + p.slice(1).toLowerCase() : p)).join('-');
export const tidyName = (s: string): string => s.split(' ').map(tidyWord).join(' ');
/** One side's full tournament squad from a stored FIFA live payload,
* position groups first (GK → FW), shirt order within. [] on any surprise. */
export function squadList(raw: unknown, side: 'home' | 'away'): { name: string; num: number | null; pos: number | null }[] {
const live = raw as RawFifaLive | null | undefined;
const players = (side === 'home' ? live?.HomeTeam : live?.AwayTeam)?.Players;
if (!Array.isArray(players)) return [];
return players
.flatMap((p) => {
const n = p?.ShortName?.[0]?.Description;
if (typeof n !== 'string' || !n.trim()) return [];
return [{
name: tidyName(n.trim()),
num: typeof p.ShirtNumber === 'number' ? p.ShirtNumber : null,
pos: typeof p.Position === 'number' ? p.Position : null,
}];
})
.sort((a, b) => (a.pos ?? 9) - (b.pos ?? 9) || (a.num ?? 99) - (b.num ?? 99));
}
@@ -72,6 +72,19 @@ describe('normalizeMatchStats', () => {
expect(normalizeMatchStats({ Periods: {} })).toEqual([]);
});
it('normalizes attacking zones and rejects partial shapes', async () => {
const { normalizeAttackingZones } = await import('./fotmobStatsNormalize');
// real shape from the match-1 capture
const zones = normalizeAttackingZones({
home: { total: { left: 45, center: 25, right: 30 }, firstHalf: { left: 49, center: 24, right: 27 } },
away: { total: { left: 27, center: 27, right: 46 } },
});
expect(zones).toEqual({ home: { left: 45, center: 25, right: 30 }, away: { left: 27, center: 27, right: 46 } });
expect(normalizeAttackingZones(null)).toBeNull();
expect(normalizeAttackingZones({ home: { total: { left: 45, center: 25 } }, away: { total: { left: 1, center: 1, right: 1 } } })).toBeNull();
expect(normalizeAttackingZones({ home: { total: { left: '45', center: 25, right: 30 } }, away: {} })).toBeNull();
});
it('skips rows with missing or empty values', () => {
const rows = normalizeMatchStats({
Periods: { All: { stats: [{ stats: [
+22
View File
@@ -35,6 +35,28 @@ export interface RawMatchStats { Periods?: { All?: { stats?: RawStatGroup[] } }
const usable = (v: unknown): v is string | number =>
(typeof v === 'number' && Number.isFinite(v)) || (typeof v === 'string' && v.trim() !== '');
export interface ZoneSplit { left: number; center: number; right: number }
interface RawZoneSide { total?: Partial<ZoneSplit> }
/** Full-match attacking-zone percentages per side, or null on any shape
* surprise (the payload is FotMob-unofficial like everything else here). */
export function normalizeAttackingZones(raw: unknown): { home: ZoneSplit; away: ZoneSplit } | null {
const z = raw as { home?: RawZoneSide; away?: RawZoneSide } | null | undefined;
const side = (s?: RawZoneSide): ZoneSplit | null => {
const v = s?.total;
if (!v) return null;
const nums = [v.left, v.center, v.right];
if (!nums.every((n): n is number => typeof n === 'number' && Number.isFinite(n) && n >= 0)) return null;
return { left: v.left!, center: v.center!, right: v.right! };
};
const home = side(z?.home);
const away = side(z?.away);
if (!home || !away) return null;
// A just-kicked-off match reports all zeros — nothing worth a card yet.
const empty = (s: ZoneSplit) => s.left + s.center + s.right === 0;
if (empty(home) && empty(away)) return null;
return { home, away };
}
/** Full-match stat rows from a raw FotMob `stats` payload. Any shape
* surprise degrades to fewer rows (or none) — never a throw. */
export function normalizeMatchStats(raw: unknown): MatchFullStat[] {
+12 -2
View File
@@ -73,7 +73,7 @@ function playerIndex(state: TournamentState): Map<string, PlayerRef> {
}
function assemble(nameParam: string, state: TournamentState): PlayerProfile | null {
const ref = resolvePlayer(nameParam, playerIndex(state));
const ref = resolvePlayer(nameParam, cachedPlayerIndex(state));
if (!ref) return null;
const { name, team } = ref;
@@ -147,6 +147,16 @@ function assemble(nameParam: string, state: TournamentState): PlayerProfile | nu
const TTL = 60_000;
const memo = new Map<string, { at: number; profile: PlayerProfile | null }>();
let listMemo: { at: number; players: PlayerRef[] } | null = null;
let indexMemo: { at: number; index: Map<string, PlayerRef> } | null = null;
/** The resolution index is a full cross-fixture scan (~128 DB reads at
* tournament end); share one build across all profile + search requests. */
function cachedPlayerIndex(state: TournamentState): Map<string, PlayerRef> {
if (indexMemo && Date.now() - indexMemo.at < TTL) return indexMemo.index;
const index = playerIndex(state);
indexMemo = { at: Date.now(), index };
return index;
}
export function buildPlayerProfile(nameParam: string, state: TournamentState): PlayerProfile | null {
const k = aliasKey(nameParam);
@@ -161,7 +171,7 @@ export function buildPlayerProfile(nameParam: string, state: TournamentState): P
/** The search palette's lightweight player list. */
export function playersIndex(state: TournamentState): PlayerRef[] {
if (listMemo && Date.now() - listMemo.at < TTL) return listMemo.players;
const players = [...playerIndex(state).values()].sort((a, b) => a.name.localeCompare(b.name));
const players = [...cachedPlayerIndex(state).values()].sort((a, b) => a.name.localeCompare(b.name));
listMemo = { at: Date.now(), players };
return players;
}
+19 -1
View File
@@ -1,6 +1,7 @@
import { readFileSync, existsSync } from 'node:fs';
import { getMatchExt, injuriesFor, scorerPropsFor, snapshotFor } from './db/db';
import { getMatchExt, getMatchProvider, injuriesFor, scorerPropsFor, snapshotFor } from './db/db';
import { whoScores } from './ingest/propsNormalize';
import { squadList } from './ingest/fifaLineupNormalize';
import { disciplineFor } from './stats';
import type { TournamentState } from './tournament';
import type { ModelEngine } from './model';
@@ -152,6 +153,7 @@ export function buildPreview(num: number, state: TournamentState, model: ModelEn
videos: summary?.videos ?? [],
// The board disappears at kickoff — in-play lines drift too fast to mirror.
scorerProps: fixture.status === 'scheduled' ? whoScores(scorerPropsFor(num)) : [],
broadcasts: summary?.broadcasts ?? [],
dataNote: !home || !away
? 'Knockout participants are decided once the bracket resolves.'
: ext
@@ -182,5 +184,21 @@ export function buildTeamProfile(teamRaw: string, state: TournamentState, model:
qualifyOdds: odds?.qualify ?? null,
discipline: disciplineFor(teamRaw, state).filter((p) => p.yellows + p.reds > 0),
injuries: injuriesFor(teamRaw),
squad: teamSquad(teamRaw, fixtures),
};
}
/** The team's 26-man FIFA squad from its most recent fixture that has a
* captured live payload (FIFA only covers matches from ~90min pre-kickoff). */
function teamSquad(team: string, fixtures: Fixture[]): TeamProfile['squad'] {
const candidates = fixtures
.filter((f) => f.status !== 'scheduled')
.sort((a, b) => b.kickoff.localeCompare(a.kickoff));
for (const f of candidates) {
const fifa = getMatchProvider<{ live?: unknown }>(f.num, 'fifa');
if (!fifa) continue;
const squad = squadList(fifa.data.live, f.home.team === team ? 'home' : 'away');
if (squad.length) return squad;
}
return [];
}
+7 -3
View File
@@ -17,17 +17,21 @@ if (pushEnabled) {
console.log('[push] web push enabled');
}
/** Follow a team — or every match, with team '*'. Additive per device. */
export function subscribe(subscription: { endpoint: string }, team: string): void {
addPushSub(subscription.endpoint, JSON.stringify(subscription), team);
}
export function unsubscribe(endpoint: string): void {
removePushSub(endpoint);
/** Unfollow one team, or the whole device when no team is given. */
export function unsubscribe(endpoint: string, team?: string): void {
removePushSub(endpoint, team);
}
async function send(team: string[], title: string, body: string, url: string): Promise<void> {
if (!pushEnabled) return;
const subs = pushSubsForTeams(team);
// A device may follow both sides (or '*' plus a side) — one alert, not two.
const seen = new Set<string>();
const subs = pushSubsForTeams(team).filter((s) => !seen.has(s.endpoint) && !!seen.add(s.endpoint));
await Promise.allSettled(
subs.map(async (s) => {
try {