6 Commits
Author SHA1 Message Date
kyle 8e42862d20 fix(telemetry): review nits
CI/CD Pipeline / Build and Push PocketBase Docker Image (push) Successful in 8s
CI/CD Pipeline / i18n Catalog Check (push) Failing after 49s
CI/CD Pipeline / Build and Push App Docker Image (push) Skipped
CI/CD Pipeline / Deploy to Kubernetes (push) Skipped
- dock h2h source corrected to match_dock; badge_viewed breakdown via value
- stats filter guards unified, ref-based, double-tap safe
- emoji picker reuses handleReactionClick; comment and import tidy
2026-08-26 23:19:45 -07:00
kyle f593b2c7b8 feat(telemetry): product interaction events
- h2h_viewed from match card, dock, profile, stats, predictions
- reaction_sent with emoji and surface; score_report_opened from all
  three report entry points
- stats_filtered and stats_sorted on stats route and profile
- rules/teams/players/bracket/tournament view events with sources
- match_history_viewed, badge_viewed, player_searched (count only)
2026-08-26 23:04:36 -07:00
kyle fc65fdb451 feat(telemetry): resolve profile and team names in session timelines 2026-08-26 22:56:11 -07:00
kyle 8171354e2a feat(telemetry): generic event aggregates and usage range presets
- rollups emit event.count (dim name) and event.detail.count (dim name|detail
  from props.source/emoji/tournament)
- usage screen: events section with tap-through breakdown; 7d/30d/180d/custom
  date-range presets
2026-08-26 22:53:58 -07:00
kyle cb16d9757e fix(telemetry): idle-based session rotation; faster sse keepalive
- session id rotates after 30 idle minutes so installed pwas produce
  visit-sized sessions instead of one eternal id
- sse ping every 8s, under the idle timeout observed killing streams
2026-08-26 22:43:25 -07:00
kyle 96d5535293 fix: countdown hydration mismatch; rate-limit sse-error session refreshes
- suppressHydrationWarning on countdown time text (server second differs
  from client second; first tick corrects)
- sse reconnects attempt a session refresh at most once per minute so a
  flaky stream cannot hammer the refresh endpoint
2026-08-26 22:41:05 -07:00
32 changed files with 1203 additions and 732 deletions
+13 -4
View File
@@ -1,7 +1,7 @@
import { createFileRoute } from "@tanstack/react-router"; import { createFileRoute } from "@tanstack/react-router";
import { playerQueries } from "@/features/players/queries"; import { playerQueries } from "@/features/players/queries";
import PlayerStatsTable from "@/features/players/components/player-stats-table"; import PlayerStatsTable from "@/features/players/components/player-stats-table";
import { Suspense, useState, useDeferredValue, useEffect } from "react"; import { Suspense, useState, useDeferredValue, useEffect, useRef } from "react";
import PlayerStatsTableSkeleton from "@/features/players/components/player-stats-table-skeleton"; import PlayerStatsTableSkeleton from "@/features/players/components/player-stats-table-skeleton";
import { prefetchServerQuery } from "@/lib/tanstack-query/utils/prefetch"; import { prefetchServerQuery } from "@/lib/tanstack-query/utils/prefetch";
import LeagueHeadToHead from "@/features/players/components/league-head-to-head"; import LeagueHeadToHead from "@/features/players/components/league-head-to-head";
@@ -9,6 +9,7 @@ import { Box, Loader, Tabs, Button, Group, Container, Stack } from "@mantine/cor
import { useQueryClient } from "@tanstack/react-query"; import { useQueryClient } from "@tanstack/react-query";
import { Trans } from "@lingui/react/macro"; import { Trans } from "@lingui/react/macro";
import { msg } from "@lingui/core/macro"; import { msg } from "@lingui/core/macro";
import { track } from "@/lib/telemetry/client";
export const Route = createFileRoute("/_authed/stats")({ export const Route = createFileRoute("/_authed/stats")({
component: Stats, component: Stats,
@@ -32,6 +33,14 @@ function Stats() {
const isStale = viewType !== deferredViewType; const isStale = viewType !== deferredViewType;
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const viewTypeRef = useRef(viewType);
const changeViewType = (next: typeof viewType) => {
if (viewTypeRef.current === next) return;
viewTypeRef.current = next;
track("stats_filtered", { value: next });
setViewType(next);
};
useEffect(() => { useEffect(() => {
const timeout = window.setTimeout(() => { const timeout = window.setTimeout(() => {
prefetchServerQuery(queryClient, playerQueries.allStats('mainline')); prefetchServerQuery(queryClient, playerQueries.allStats('mainline'));
@@ -54,21 +63,21 @@ function Stats() {
<Button <Button
variant={viewType === 'all' ? 'filled' : 'light'} variant={viewType === 'all' ? 'filled' : 'light'}
size="compact-xs" size="compact-xs"
onClick={() => setViewType('all')} onClick={() => changeViewType('all')}
> >
<Trans>All</Trans> <Trans>All</Trans>
</Button> </Button>
<Button <Button
variant={viewType === 'mainline' ? 'filled' : 'light'} variant={viewType === 'mainline' ? 'filled' : 'light'}
size="compact-xs" size="compact-xs"
onClick={() => setViewType('mainline')} onClick={() => changeViewType('mainline')}
> >
<Trans>Mainline</Trans> <Trans>Mainline</Trans>
</Button> </Button>
<Button <Button
variant={viewType === 'regional' ? 'filled' : 'light'} variant={viewType === 'regional' ? 'filled' : 'light'}
size="compact-xs" size="compact-xs"
onClick={() => setViewType('regional')} onClick={() => changeViewType('regional')}
> >
<Trans>Regional</Trans> <Trans>Regional</Trans>
</Button> </Button>
+2 -1
View File
@@ -39,9 +39,10 @@ export const Route = createFileRoute("/api/events/$")({
serverEvents.on(type, handleEvent); serverEvents.on(type, handleEvent);
} }
// Under the shortest idle timeout observed on proxies/PWA paths.
const pingInterval = setInterval(() => { const pingInterval = setInterval(() => {
send({ type: "ping", timestamp: Date.now() }); send({ type: "ping", timestamp: Date.now() });
}, 15000); }, 8000);
cleanup = () => { cleanup = () => {
if (cleanedUp) return; if (cleanedUp) return;
+3
View File
@@ -53,12 +53,14 @@ export function Countdown({ date, label, color }: CountdownProps) {
return ( return (
<Group gap="xs"> <Group gap="xs">
{label && <Text size='sm' fw={500}>{label}:</Text>} {label && <Text size='sm' fw={500}>{label}:</Text>}
{/* Server and client render different seconds; the first client tick corrects it. */}
<Text <Text
size='sm' size='sm'
fw={600} fw={600}
c={isFinalStretch ? 'red' : color} c={isFinalStretch ? 'red' : color}
ff="monospace" ff="monospace"
className={classes.time} className={classes.time}
suppressHydrationWarning
> >
{prefix} {prefix}
<span <span
@@ -66,6 +68,7 @@ export function Countdown({ date, label, color }: CountdownProps) {
className={`${classes.seconds} ${ className={`${classes.seconds} ${
isFinalStretch ? classes.finalTick : classes.tick isFinalStretch ? classes.finalTick : classes.tick
}`} }`}
suppressHydrationWarning
> >
{pad(timeLeft.seconds)} {pad(timeLeft.seconds)}
</span> </span>
@@ -19,6 +19,7 @@ import PlayerList from '@/features/players/components/player-list';
import { useAuth } from '@/contexts/auth-context'; import { useAuth } from '@/contexts/auth-context';
import { Player } from '@/features/players/types'; import { Player } from '@/features/players/types';
import { Trans, useLingui } from '@lingui/react/macro'; import { Trans, useLingui } from '@lingui/react/macro';
import { track } from '@/lib/telemetry/client';
const BadgeStatsTable = () => { const BadgeStatsTable = () => {
const { data: allBadges } = useAllBadges(); const { data: allBadges } = useAllBadges();
@@ -141,7 +142,13 @@ const BadgeStatRow: React.FC<BadgeStatRowProps> = ({
return ( return (
<Box key={badge.id}> <Box key={badge.id}>
<UnstyledButton onClick={badgeSheet.open} w='100%'> <UnstyledButton
onClick={() => {
track("badge_viewed", { value: badge.key });
badgeSheet.open();
}}
w='100%'
>
<Grid p={'xs'} align='center'> <Grid p={'xs'} align='center'>
<Grid.Col span={2} style={{ display: 'flex', alignItems: 'center' }}> <Grid.Col span={2} style={{ display: 'flex', alignItems: 'center' }}>
<BadgeIcon badge={badge} filled={true} /> <BadgeIcon badge={badge} filled={true} />
@@ -399,7 +399,7 @@ export const MatchCard: React.FC<MatchCardProps> = ({
</Sheet> </Sheet>
</Flex> </Flex>
{!showControls && <MatchReport match={match} compact />} {!showControls && <MatchReport match={match} compact source="match_card" />}
</Stack> </Stack>
); );
}; };
@@ -24,6 +24,7 @@ import { MatchReport } from "./match-report";
import Sheet from "@/components/sheet/sheet"; import Sheet from "@/components/sheet/sheet";
import { useSheet } from "@/hooks/use-sheet"; import { useSheet } from "@/hooks/use-sheet";
import { Trans, useLingui } from "@lingui/react/macro"; import { Trans, useLingui } from "@lingui/react/macro";
import { track } from "@/lib/telemetry/client";
const EASE: [number, number, number, number] = [0.32, 0.72, 0, 1]; const EASE: [number, number, number, number] = [0.32, 0.72, 0, 1];
@@ -155,7 +156,7 @@ const MatchDock = ({ match, onClose }: MatchDockProps) => {
ended={ended} ended={ended}
/> />
<MatchReport match={match} /> <MatchReport match={match} source="match_dock" />
<Group justify="space-between" wrap="nowrap" gap="sm"> <Group justify="space-between" wrap="nowrap" gap="sm">
<Box style={{ flex: 1, minWidth: 0 }}> <Box style={{ flex: 1, minWidth: 0 }}>
@@ -166,7 +167,7 @@ const MatchDock = ({ match, onClose }: MatchDockProps) => {
</Center> </Center>
} }
> >
<EmojiBar matchId={match.id} /> <EmojiBar matchId={match.id} source="match_dock" />
</Suspense> </Suspense>
</Box> </Box>
{!hasPrivate && ( {!hasPrivate && (
@@ -174,7 +175,10 @@ const MatchDock = ({ match, onClose }: MatchDockProps) => {
<ActionIcon <ActionIcon
variant="subtle" variant="subtle"
size="sm" size="sm"
onClick={h2hSheet.open} onClick={() => {
track("h2h_viewed", { source: "match_dock" });
h2hSheet.open();
}}
aria-label={t`View head-to-head`} aria-label={t`View head-to-head`}
w={40} w={40}
style={{ flexShrink: 0 }} style={{ flexShrink: 0 }}
@@ -13,6 +13,7 @@ import Sheet from "@/components/sheet/sheet";
import { MatchForm } from "./match-form"; import { MatchForm } from "./match-form";
import toast from "@/lib/sonner"; import toast from "@/lib/sonner";
import { Trans, useLingui } from "@lingui/react/macro"; import { Trans, useLingui } from "@lingui/react/macro";
import { track } from "@/lib/telemetry/client";
const teamId = (t: Match["home"]): string | undefined => const teamId = (t: Match["home"]): string | undefined =>
!t ? undefined : typeof t === "string" ? t : t.id; !t ? undefined : typeof t === "string" ? t : t.id;
@@ -26,9 +27,10 @@ const teamName = (t: Match["home"]): string | undefined =>
interface MatchReportProps { interface MatchReportProps {
match: Match; match: Match;
compact?: boolean; compact?: boolean;
source?: string;
} }
export const MatchReport: React.FC<MatchReportProps> = ({ match, compact }) => { export const MatchReport: React.FC<MatchReportProps> = ({ match, compact, source }) => {
const { user } = useAuth(); const { user } = useAuth();
const formSheet = useSheet(); const formSheet = useSheet();
const { t } = useLingui(); const { t } = useLingui();
@@ -129,8 +131,9 @@ export const MatchReport: React.FC<MatchReportProps> = ({ match, compact }) => {
const openForm = useCallback(() => { const openForm = useCallback(() => {
setAcknowledgedReport(pendingSignature); setAcknowledgedReport(pendingSignature);
track("score_report_opened", source ? { source } : undefined);
formSheet.open(); formSheet.open();
}, [pendingSignature, formSheet.open]); }, [pendingSignature, formSheet.open, source]);
const dismissTakeover = useCallback(() => { const dismissTakeover = useCallback(() => {
setAcknowledgedReport(pendingSignature); setAcknowledgedReport(pendingSignature);
@@ -10,6 +10,7 @@ import Sheet from "@/components/sheet/sheet";
import TeamHeadToHeadSheet from "./team-head-to-head-sheet"; import TeamHeadToHeadSheet from "./team-head-to-head-sheet";
import AnimatedScore from "./animated-score"; import AnimatedScore from "./animated-score";
import { Trans, useLingui } from "@lingui/react/macro"; import { Trans, useLingui } from "@lingui/react/macro";
import { track } from "@/lib/telemetry/client";
interface MatchCardProps { interface MatchCardProps {
match: Match; match: Match;
@@ -41,6 +42,7 @@ const MatchCard = ({ match, hideH2H = false }: MatchCardProps) => {
const handleH2HClick = (e: React.MouseEvent) => { const handleH2HClick = (e: React.MouseEvent) => {
e.stopPropagation(); e.stopPropagation();
track("h2h_viewed", { source: "match_card" });
h2hSheet.open(); h2hSheet.open();
}; };
@@ -218,7 +220,7 @@ const MatchCard = ({ match, hideH2H = false }: MatchCardProps) => {
}} }}
> >
<Suspense> <Suspense>
<EmojiBar matchId={match.id} /> <EmojiBar matchId={match.id} source="match_card" />
</Suspense> </Suspense>
</Paper> </Paper>
</Box> </Box>
@@ -1,5 +1,5 @@
import { Stack, Text, TextInput, Box, Paper, Group, Divider, ActionIcon, Button } from "@mantine/core"; import { Stack, Text, TextInput, Box, Paper, Group, Divider, ActionIcon, Button } from "@mantine/core";
import { useState, useMemo } from "react"; import { useState, useMemo, useRef } from "react";
import { MagnifyingGlassIcon, XIcon, ArrowRightIcon, PlusIcon } from "@phosphor-icons/react"; import { MagnifyingGlassIcon, XIcon, ArrowRightIcon, PlusIcon } from "@phosphor-icons/react";
import { Trans, useLingui } from "@lingui/react/macro"; import { Trans, useLingui } from "@lingui/react/macro";
import { useAllPlayerStats } from "../queries"; import { useAllPlayerStats } from "../queries";
@@ -8,6 +8,7 @@ import { useSheet } from "@/hooks/use-sheet";
import Sheet from "@/components/sheet/sheet"; import Sheet from "@/components/sheet/sheet";
import PlayerHeadToHeadSheet from "./player-head-to-head-sheet"; import PlayerHeadToHeadSheet from "./player-head-to-head-sheet";
import PlayerAvatar from "@/components/player-avatar"; import PlayerAvatar from "@/components/player-avatar";
import { track } from "@/lib/telemetry/client";
const LeagueHeadToHead = () => { const LeagueHeadToHead = () => {
const { t } = useLingui(); const { t } = useLingui();
@@ -15,6 +16,7 @@ const LeagueHeadToHead = () => {
const [player1Id, setPlayer1Id] = useState<string | null>(null); const [player1Id, setPlayer1Id] = useState<string | null>(null);
const [player2Id, setPlayer2Id] = useState<string | null>(null); const [player2Id, setPlayer2Id] = useState<string | null>(null);
const [search, setSearch] = useState(""); const [search, setSearch] = useState("");
const searchedRef = useRef(false);
const { data: allPlayerStats } = useAllPlayerStats(); const { data: allPlayerStats } = useAllPlayerStats();
const h2hSheet = useSheet(); const h2hSheet = useSheet();
@@ -51,6 +53,7 @@ const LeagueHeadToHead = () => {
setPlayer1Id(playerId); setPlayer1Id(playerId);
} else { } else {
setPlayer2Id(playerId); setPlayer2Id(playerId);
track("h2h_viewed", { source: "stats" });
h2hSheet.open(); h2hSheet.open();
} }
}; };
@@ -128,7 +131,14 @@ const LeagueHeadToHead = () => {
<TextInput <TextInput
placeholder={t`Search players`} placeholder={t`Search players`}
value={search} value={search}
onChange={(e) => setSearch(e.currentTarget.value)} onChange={(e) => {
const value = e.currentTarget.value;
if (value && !searchedRef.current) {
searchedRef.current = true;
track("player_searched");
}
setSearch(value);
}}
leftSection={<MagnifyingGlassIcon size={16} />} leftSection={<MagnifyingGlassIcon size={16} />}
size="md" size="md"
px="md" px="md"
@@ -28,6 +28,7 @@ import { useNavigate } from "@tanstack/react-router";
import { useAllPlayerStats } from "../queries"; import { useAllPlayerStats } from "../queries";
import { PlayerListItemSkeleton } from "./player-stats-table-skeleton"; import { PlayerListItemSkeleton } from "./player-stats-table-skeleton";
import { Trans, useLingui } from "@lingui/react/macro"; import { Trans, useLingui } from "@lingui/react/macro";
import { track } from "@/lib/telemetry/client";
type SortKey = keyof PlayerStats | "mmr"; type SortKey = keyof PlayerStats | "mmr";
type SortDirection = "asc" | "desc"; type SortDirection = "asc" | "desc";
@@ -177,6 +178,7 @@ const PlayerStatsTable = ({ viewType = 'all' }: PlayerStatsTableProps) => {
const navigate = useNavigate(); const navigate = useNavigate();
const [search, setSearch] = useState(""); const [search, setSearch] = useState("");
const deferredSearch = useDeferredValue(search); const deferredSearch = useDeferredValue(search);
const searchedRef = useRef(false);
const [sortConfig, setSortConfig] = useState<SortConfig>({ const [sortConfig, setSortConfig] = useState<SortConfig>({
key: "mmr" as SortKey, key: "mmr" as SortKey,
direction: "desc", direction: "desc",
@@ -291,6 +293,7 @@ const PlayerStatsTable = ({ viewType = 'all' }: PlayerStatsTableProps) => {
}, [navigate]); }, [navigate]);
const handleSort = (key: SortKey) => { const handleSort = (key: SortKey) => {
track("stats_sorted", { value: key });
setSortConfig((prev) => ({ setSortConfig((prev) => ({
key, key,
direction: prev.key === key && prev.direction === "desc" ? "asc" : "desc", direction: prev.key === key && prev.direction === "desc" ? "asc" : "desc",
@@ -328,7 +331,14 @@ const PlayerStatsTable = ({ viewType = 'all' }: PlayerStatsTableProps) => {
<TextInput <TextInput
placeholder={t`Search players`} placeholder={t`Search players`}
value={search} value={search}
onChange={(e) => setSearch(e.currentTarget.value)} onChange={(e) => {
const value = e.currentTarget.value;
if (value && !searchedRef.current) {
searchedRef.current = true;
track("player_searched");
}
setSearch(value);
}}
leftSection={<MagnifyingGlassIcon size={16} />} leftSection={<MagnifyingGlassIcon size={16} />}
size="md" size="md"
px="md" px="md"
@@ -9,6 +9,7 @@ import PlayerAvatar from "@/components/player-avatar";
import { useSheet } from "@/hooks/use-sheet"; import { useSheet } from "@/hooks/use-sheet";
import { Player } from "../../types"; import { Player } from "../../types";
import PlayerHeadToHeadSheet from "../player-head-to-head-sheet"; import PlayerHeadToHeadSheet from "../player-head-to-head-sheet";
import { track } from "@/lib/telemetry/client";
interface HeaderProps { interface HeaderProps {
player: Player; player: Player;
@@ -53,7 +54,10 @@ const Header = ({ player }: HeaderProps) => {
variant="subtle" variant="subtle"
size="sm" size="sm"
radius="xl" radius="xl"
onClick={h2hSheet.open} onClick={() => {
track("h2h_viewed", { source: "profile" });
h2hSheet.open();
}}
w={40} w={40}
display={!owner ? 'block' : 'none'} display={!owner ? 'block' : 'none'}
> >
@@ -1,5 +1,5 @@
import { Box, Stack, Text, Divider, Group, Button } from "@mantine/core"; import { Box, Stack, Text, Divider, Group, Button } from "@mantine/core";
import { Suspense, useState, useDeferredValue } from "react"; import { Suspense, useState, useDeferredValue, useRef } from "react";
import { Trans, useLingui } from "@lingui/react/macro"; import { Trans, useLingui } from "@lingui/react/macro";
import { Link } from "@tanstack/react-router"; import { Link } from "@tanstack/react-router";
import Header from "./header"; import Header from "./header";
@@ -10,6 +10,7 @@ import StatsOverview, { StatsSkeleton } from "@/components/stats-overview";
import MatchList from "@/features/matches/components/match-list"; import MatchList from "@/features/matches/components/match-list";
import BadgeShowcase from "@/features/badges/components/badge-showcase"; import BadgeShowcase from "@/features/badges/components/badge-showcase";
import BadgeShowcaseSkeleton from "@/features/badges/components/badge-showcase-skeleton"; import BadgeShowcaseSkeleton from "@/features/badges/components/badge-showcase-skeleton";
import { track } from "@/lib/telemetry/client";
interface ProfileProps { interface ProfileProps {
id: string; id: string;
@@ -20,14 +21,22 @@ const StatsWithFilter = ({ id }: { id: string }) => {
const deferredViewType = useDeferredValue(viewType); const deferredViewType = useDeferredValue(viewType);
const isStale = viewType !== deferredViewType; const isStale = viewType !== deferredViewType;
const viewTypeRef = useRef(viewType);
const changeViewType = (next: typeof viewType) => {
if (viewTypeRef.current === next) return;
viewTypeRef.current = next;
track("stats_filtered", { value: next, source: "profile" });
setViewType(next);
};
return ( return (
<Stack> <Stack>
<Group gap="xs" px="md" justify="space-between" align="center"> <Group gap="xs" px="md" justify="space-between" align="center">
<Text size="md" fw={700}><Trans>Statistics</Trans></Text> <Text size="md" fw={700}><Trans>Statistics</Trans></Text>
<Group gap="xs"> <Group gap="xs">
<Button variant={viewType === 'all' ? 'filled' : 'light'} size="compact-xs" onClick={() => setViewType('all')}><Trans>All</Trans></Button> <Button variant={viewType === 'all' ? 'filled' : 'light'} size="compact-xs" onClick={() => changeViewType('all')}><Trans>All</Trans></Button>
<Button variant={viewType === 'mainline' ? 'filled' : 'light'} size="compact-xs" onClick={() => setViewType('mainline')}><Trans>Mainline</Trans></Button> <Button variant={viewType === 'mainline' ? 'filled' : 'light'} size="compact-xs" onClick={() => changeViewType('mainline')}><Trans>Mainline</Trans></Button>
<Button variant={viewType === 'regional' ? 'filled' : 'light'} size="compact-xs" onClick={() => setViewType('regional')}><Trans>Regional</Trans></Button> <Button variant={viewType === 'regional' ? 'filled' : 'light'} size="compact-xs" onClick={() => changeViewType('regional')}><Trans>Regional</Trans></Button>
</Group> </Group>
</Group> </Group>
<Box style={{ opacity: isStale ? 0.6 : 1, transition: 'opacity 150ms' }}> <Box style={{ opacity: isStale ? 0.6 : 1, transition: 'opacity 150ms' }}>
@@ -90,7 +99,12 @@ const Profile = ({ id }: ProfileProps) => {
<> <>
<Header player={player} /> <Header player={player} />
<Box mt="lg"> <Box mt="lg">
<SwipeableTabs tabs={tabs} /> <SwipeableTabs
tabs={tabs}
onTabChange={(_, tab) => {
if (tab.value === "matches") track("match_history_viewed", { source: "profile" });
}}
/>
</Box> </Box>
</> </>
); );
@@ -26,6 +26,7 @@ import {
import { PredictionBracket } from "./prediction-bracket"; import { PredictionBracket } from "./prediction-bracket";
import { MatchupSheet } from "./matchup-sheet"; import { MatchupSheet } from "./matchup-sheet";
import { WinnerSelector } from "./winner-selector"; import { WinnerSelector } from "./winner-selector";
import { track } from "@/lib/telemetry/client";
interface PredictionEditorProps { interface PredictionEditorProps {
tournament: Tournament; tournament: Tournament;
@@ -215,7 +216,12 @@ export const PredictionEditor: React.FC<PredictionEditorProps> = ({
variant="default" variant="default"
size="lg" size="lg"
radius="md" radius="md"
onClick={matchupSheet.open} onClick={() => {
if (activeResolved?.home && activeResolved?.away) {
track("h2h_viewed", { source: "predictions" });
}
matchupSheet.open();
}}
disabled={!activeResolved?.home && !activeResolved?.away} disabled={!activeResolved?.home && !activeResolved?.away}
aria-label={t`Matchup details`} aria-label={t`Matchup details`}
> >
@@ -17,11 +17,13 @@ import EmojiPicker from "./emoji-picker";
import { useMatchReactions, useToggleMatchReaction } from "../queries"; import { useMatchReactions, useToggleMatchReaction } from "../queries";
import { useAuth } from "@/contexts/auth-context"; import { useAuth } from "@/contexts/auth-context";
import { Reaction } from "@/features/matches/server"; import { Reaction } from "@/features/matches/server";
import { track } from "@/lib/telemetry/client";
import classes from "./emoji-bar.module.css"; import classes from "./emoji-bar.module.css";
interface EmojiBarProps { interface EmojiBarProps {
matchId: string; matchId: string;
onReactionPress?: (emoji: string) => void; onReactionPress?: (emoji: string) => void;
source?: string;
} }
const EASE: [number, number, number, number] = [0.32, 0.72, 0, 1]; const EASE: [number, number, number, number] = [0.32, 0.72, 0, 1];
@@ -56,6 +58,7 @@ const TickingCount = ({ value, ...textProps }: TickingCountProps) => {
const EmojiBar = ({ const EmojiBar = ({
matchId, matchId,
onReactionPress, onReactionPress,
source,
}: EmojiBarProps) => { }: EmojiBarProps) => {
const { user } = useAuth(); const { user } = useAuth();
const { t } = useLingui(); const { t } = useLingui();
@@ -85,6 +88,7 @@ const EmojiBar = ({
if (onReactionPress) { if (onReactionPress) {
onReactionPress(emoji); onReactionPress(emoji);
} else { } else {
if (!userReactions.includes(emoji)) track("reaction_sent", { emoji, source });
toggleReaction.mutate({ data: { matchId, emoji } }); toggleReaction.mutate({ data: { matchId, emoji } });
} }
}; };
@@ -198,7 +202,7 @@ const EmojiBar = ({
</AnimatePresence> </AnimatePresence>
</Group> </Group>
<EmojiPicker <EmojiPicker
onSelect={onReactionPress || ((emoji) => toggleReaction.mutate({ data: { matchId, emoji } }))} onSelect={handleReactionClick}
userReactions={userReactions} userReactions={userReactions}
/> />
</Group> </Group>
@@ -77,6 +77,12 @@ export const SessionTimelineView = ({ sessionId }: { sessionId: string }) => {
style={{ minWidth: 0, flex: 1 }} style={{ minWidth: 0, flex: 1 }}
> >
{entry.label} {entry.label}
{entry.detail && entry.kind !== "error" && (
<Text component="span" size="sm" c="dimmed">
{" "}
· {entry.detail}
</Text>
)}
</Text> </Text>
{entry.kind === "server_fn" && entry.success === false && ( {entry.kind === "server_fn" && entry.success === false && (
<Text size="xs" c="red" style={{ flexShrink: 0 }}> <Text size="xs" c="red" style={{ flexShrink: 0 }}>
@@ -9,8 +9,10 @@ import {
Text, Text,
Title, Title,
} from "@mantine/core"; } from "@mantine/core";
import { DatePickerInput } from "@mantine/dates";
import { ListSkeleton, StatTileSkeleton } from "@/components/skeletons"; import { ListSkeleton, StatTileSkeleton } from "@/components/skeletons";
import { useRollupRange } from "../queries"; import { useRollupRange } from "../queries";
import type { RollupRow } from "../types";
import { fillDailySeries, lastNDays, latestValue, listDays, sumByDim } from "../range"; import { fillDailySeries, lastNDays, latestValue, listDays, sumByDim } from "../range";
import { BarList, Sparkline, StatTile } from "./charts"; import { BarList, Sparkline, StatTile } from "./charts";
import { DetailSheet } from "./shared"; import { DetailSheet } from "./shared";
@@ -25,6 +27,8 @@ export const USAGE_METRICS = [
"server_fn.count", "server_fn.count",
"client_error.count", "client_error.count",
"vital.p75", "vital.p75",
"event.count",
"event.detail.count",
]; ];
const TOP_LIST_SIZE = 8; const TOP_LIST_SIZE = 8;
@@ -122,10 +126,37 @@ const FnDurationDetail = ({ fn, from, to }: { fn: string; from: string; to: stri
); );
}; };
const EventBreakdown = ({ name, rows }: { name: string; rows: RollupRow[] }) => {
const items = useMemo(() => {
const totals = new Map<string, number>();
const prefix = `${name}|`;
for (const row of rows) {
if (row.metric !== "event.detail.count" || !row.dim.startsWith(prefix)) continue;
const detail = row.dim.slice(prefix.length);
totals.set(detail, (totals.get(detail) ?? 0) + row.value);
}
return [...totals.entries()]
.sort((a, b) => b[1] - a[1])
.map(([label, value]) => ({ label, value }));
}, [name, rows]);
if (items.length === 0) {
return (
<Text size="sm" c="dimmed">
<Trans>No breakdown recorded</Trans>
</Text>
);
}
return <BarList items={items} />;
};
const UsageSections = ({ from, to }: { from: string; to: string }) => { const UsageSections = ({ from, to }: { from: string; to: string }) => {
const { t, i18n } = useLingui(); const { t, i18n } = useLingui();
const [selectedFn, setSelectedFn] = useState<string | null>(null); const [selectedFn, setSelectedFn] = useState<string | null>(null);
const { isOpen: fnDetailOpened, open: openFnDetail, close: closeFnDetail } = useSheet(); const { isOpen: fnDetailOpened, open: openFnDetail, close: closeFnDetail } = useSheet();
const [selectedEvent, setSelectedEvent] = useState<string | null>(null);
const { isOpen: eventDetailOpened, open: openEventDetail, close: closeEventDetail } = useSheet();
const { data: rows } = useRollupRange({ metrics: USAGE_METRICS, from, to }); const { data: rows } = useRollupRange({ metrics: USAGE_METRICS, from, to });
@@ -142,6 +173,10 @@ const UsageSections = ({ from, to }: { from: string; to: string }) => {
setSelectedFn(dim); setSelectedFn(dim);
openFnDetail(); openFnDetail();
}); });
const eventItems = topItems(sumByDim(rows, "event.count"), (dim) => {
setSelectedEvent(dim);
openEventDetail();
});
const vitals = (["LCP", "INP", "CLS"] as VitalName[]).map((name) => ({ const vitals = (["LCP", "INP", "CLS"] as VitalName[]).map((name) => ({
name, name,
@@ -186,6 +221,26 @@ const UsageSections = ({ from, to }: { from: string; to: string }) => {
<Divider /> <Divider />
<Stack gap="sm">
<Title order={4}>
<Trans>Events</Trans>
</Title>
{eventItems.length > 0 ? (
<Stack gap="xs">
<Text size="xs" c="dimmed">
<Trans>Top interactions, tap for breakdown</Trans>
</Text>
<BarList items={eventItems} />
</Stack>
) : (
<Text size="sm" c="dimmed">
<Trans>No events recorded in this range</Trans>
</Text>
)}
</Stack>
<Divider />
<Stack gap="sm"> <Stack gap="sm">
<Title order={4}> <Title order={4}>
<Trans>Server functions</Trans> <Trans>Server functions</Trans>
@@ -230,6 +285,25 @@ const UsageSections = ({ from, to }: { from: string; to: string }) => {
</SimpleGrid> </SimpleGrid>
</Stack> </Stack>
<DetailSheet
title={selectedEvent ?? t`Event`}
opened={eventDetailOpened}
onClose={() => {
setSelectedEvent(null);
closeEventDetail();
}}
fields={
selectedEvent
? [
{
label: <Trans>Breakdown</Trans>,
value: <EventBreakdown name={selectedEvent} rows={rows} />,
},
]
: []
}
/>
<DetailSheet <DetailSheet
title={selectedFn ?? t`Function`} title={selectedFn ?? t`Function`}
opened={fnDetailOpened} opened={fnDetailOpened}
@@ -251,36 +325,61 @@ const UsageSections = ({ from, to }: { from: string; to: string }) => {
export const UsageScreen = () => { export const UsageScreen = () => {
const { t } = useLingui(); const { t } = useLingui();
const [rangeDays, setRangeDays] = useState<"7" | "30">("7"); const [preset, setPreset] = useState<"7" | "30" | "180" | "custom">("7");
const range = useMemo(() => lastNDays(Number(rangeDays)), [rangeDays]); const [customRange, setCustomRange] = useState<[string | null, string | null]>([null, null]);
const range = useMemo(() => {
if (preset !== "custom") return lastNDays(Number(preset));
const [start, end] = customRange;
if (!start || !end) return null;
return { from: start, to: end };
}, [preset, customRange]);
return ( return (
<Stack gap="md" px="md" pb="md"> <Stack gap="md" px="md" pb="md">
<SegmentedControl <SegmentedControl
value={rangeDays} value={preset}
onChange={(value) => setRangeDays(value as "7" | "30")} onChange={(value) => setPreset(value as typeof preset)}
data={[ data={[
{ value: "7", label: t`7d` }, { value: "7", label: t`7d` },
{ value: "30", label: t`30d` }, { value: "30", label: t`30d` },
{ value: "180", label: t`180d` },
{ value: "custom", label: t`Custom` },
]} ]}
fullWidth fullWidth
/> />
<Suspense {preset === "custom" && (
fallback={ <DatePickerInput
<Stack gap="lg"> type="range"
<StatTileSkeleton align="row" /> value={customRange}
<SimpleGrid cols={3} spacing="sm"> onChange={setCustomRange}
<StatTileSkeleton /> maxDate={new Date()}
<StatTileSkeleton /> placeholder={t`Pick a date range`}
<StatTileSkeleton /> />
</SimpleGrid> )}
<ListSkeleton rows={6} withTrailing="text" />
</Stack> {range ? (
} <Suspense
> fallback={
<UsageSections from={range.from} to={range.to} /> <Stack gap="lg">
</Suspense> <StatTileSkeleton align="row" />
<SimpleGrid cols={3} spacing="sm">
<StatTileSkeleton />
<StatTileSkeleton />
<StatTileSkeleton />
</SimpleGrid>
<ListSkeleton rows={6} withTrailing="text" />
</Stack>
}
>
<UsageSections from={range.from} to={range.to} />
</Suspense>
) : (
<Text size="sm" c="dimmed" ta="center" py="xl">
<Trans>Pick a date range to see usage</Trans>
</Text>
)}
</Stack> </Stack>
); );
}; };
+45 -3
View File
@@ -134,7 +134,7 @@ export const resolveClientErrorGroup = createServerFn({ method: "POST" })
); );
const rollupRangeSchema = z.object({ const rollupRangeSchema = z.object({
metrics: z.array(z.string()).min(1).max(8), metrics: z.array(z.string()).min(1).max(12),
from: z.string().regex(/^\d{4}-\d{2}-\d{2}$/), from: z.string().regex(/^\d{4}-\d{2}-\d{2}$/),
to: z.string().regex(/^\d{4}-\d{2}-\d{2}$/), to: z.string().regex(/^\d{4}-\d{2}-\d{2}$/),
dim: z.string().optional(), dim: z.string().optional(),
@@ -302,11 +302,17 @@ export const getSessionTimeline = createServerFn()
for (const event of events.items) { for (const event of events.items) {
if (event.name.startsWith("vital.")) continue; if (event.name.startsWith("vital.")) continue;
const props = event.props as
| { emoji?: string; source?: string; tournament?: string; value?: string }
| null
| undefined;
const breakdown =
props?.emoji ?? props?.value ?? props?.source ?? props?.tournament;
entries.push({ entries.push({
ts: event.created, ts: event.created,
kind: event.name === "page_view" ? "page_view" : "event", kind: event.name === "page_view" ? "page_view" : "event",
label: event.name === "page_view" ? event.path || event.route_id || "?" : event.name, label: event.name === "page_view" ? event.path || event.route_id || "?" : event.name,
detail: event.name === "page_view" ? undefined : event.path, detail: event.name === "page_view" ? undefined : (breakdown ?? event.path),
}); });
} }
@@ -346,7 +352,43 @@ export const getSessionTimeline = createServerFn()
entries.sort((a, b) => (a.ts < b.ts ? -1 : 1)); entries.sort((a, b) => (a.ts < b.ts ? -1 : 1));
const players = await playerInfoById(new Set(playerId ? [playerId] : [])); const profileIds = new Set<string>();
const teamIds = new Set<string>();
for (const entry of entries) {
if (entry.kind !== "page_view") continue;
const profileMatch = entry.label.match(/^\/profile\/([^/]+)$/);
if (profileMatch) profileIds.add(profileMatch[1]);
const teamMatch = entry.label.match(/^\/teams\/([^/]+)$/);
if (teamMatch) teamIds.add(teamMatch[1]);
}
const players = await playerInfoById(
new Set([...(playerId ? [playerId] : []), ...profileIds])
);
const teamNames = new Map<string, string>();
if (teamIds.size > 0) {
try {
const teams = await pbAdmin.listTeamInfos();
for (const team of teams) {
if (teamIds.has(team.id)) teamNames.set(team.id, team.name);
}
} catch {}
}
for (const entry of entries) {
if (entry.kind !== "page_view" || entry.detail) continue;
const profileMatch = entry.label.match(/^\/profile\/([^/]+)$/);
if (profileMatch) {
const player = players.get(profileMatch[1]);
if (player) {
entry.detail = [player.first_name, player.last_name].filter(Boolean).join(" ");
}
continue;
}
const teamMatch = entry.label.match(/^\/teams\/([^/]+)$/);
if (teamMatch) entry.detail = teamNames.get(teamMatch[1]);
}
return { return {
session: { session: {
@@ -1,4 +1,4 @@
import { useMemo } from "react"; import { useEffect, useMemo, useRef } from "react";
import { Box } from "@mantine/core"; import { Box } from "@mantine/core";
import Header from "./header"; import Header from "./header";
import TeamList from "@/features/teams/components/team-list"; import TeamList from "@/features/teams/components/team-list";
@@ -7,6 +7,7 @@ import { useTournament } from "../../queries";
import MatchList from "@/features/matches/components/match-list"; import MatchList from "@/features/matches/components/match-list";
import { TournamentStats } from "../tournament-stats"; import { TournamentStats } from "../tournament-stats";
import { useLingui } from "@lingui/react/macro"; import { useLingui } from "@lingui/react/macro";
import { track } from "@/lib/telemetry/client";
interface ProfileProps { interface ProfileProps {
id: string; id: string;
@@ -15,6 +16,14 @@ interface ProfileProps {
const Profile = ({ id }: ProfileProps) => { const Profile = ({ id }: ProfileProps) => {
const { t } = useLingui(); const { t } = useLingui();
const { data: tournament } = useTournament(id); const { data: tournament } = useTournament(id);
const trackedId = useRef<string | null>(null);
useEffect(() => {
if (!tournament || trackedId.current === tournament.id) return;
trackedId.current = tournament.id;
track("tournament_viewed", { tournament: tournament.name });
}, [tournament]);
if (!tournament) return null; if (!tournament) return null;
const tabs = useMemo(() => [ const tabs = useMemo(() => [
@@ -24,6 +24,7 @@ import { useSheet } from "@/hooks/use-sheet";
import TeamList from "@/features/teams/components/team-list"; import TeamList from "@/features/teams/components/team-list";
import WizardOrbIcon from "@/components/wizard-orb-icon"; import WizardOrbIcon from "@/components/wizard-orb-icon";
import { Tournament } from "../../../types"; import { Tournament } from "../../../types";
import { track } from "@/lib/telemetry/client";
import classes from "./nav-grid.module.css"; import classes from "./nav-grid.module.css";
const RulesContent = lazy( const RulesContent = lazy(
@@ -86,9 +87,10 @@ const NavGrid = ({
<NavTile <NavTile
label={t`Bracket`} label={t`Bracket`}
Icon={TreeStructureIcon} Icon={TreeStructureIcon}
onClick={() => onClick={() => {
navigate({ to: `/tournaments/${tournament.id}/bracket` }) track("bracket_viewed", { source: "nav_grid" });
} navigate({ to: `/tournaments/${tournament.id}/bracket` });
}}
/> />
{isPredictable && !predictionsLocked && ( {isPredictable && !predictionsLocked && (
<NavTile <NavTile
@@ -118,9 +120,19 @@ const NavGrid = ({
: t`Teams` : t`Teams`
} }
Icon={UsersIcon} Icon={UsersIcon}
onClick={teamsSheet.open} onClick={() => {
track("teams_viewed", { source: "nav_grid" });
teamsSheet.open();
}}
/>
<NavTile
label={t`Rules`}
Icon={ListIcon}
onClick={() => {
track("rules_viewed", { source: "nav_grid" });
rulesSheet.open();
}}
/> />
<NavTile label={t`Rules`} Icon={ListIcon} onClick={rulesSheet.open} />
</SimpleGrid> </SimpleGrid>
<Sheet title={t`Enrolled Teams`} {...teamsSheet.props}> <Sheet title={t`Enrolled Teams`} {...teamsSheet.props}>
@@ -4,6 +4,7 @@ import { useNavigate } from "@tanstack/react-router";
import { Trans, useLingui } from "@lingui/react/macro"; import { Trans, useLingui } from "@lingui/react/macro";
import MatchCard from "@/features/matches/components/match-card"; import MatchCard from "@/features/matches/components/match-card";
import { Match } from "@/features/matches/types"; import { Match } from "@/features/matches/types";
import { track } from "@/lib/telemetry/client";
import SectionHeading from "./section-heading"; import SectionHeading from "./section-heading";
interface RecentResultsProps { interface RecentResultsProps {
@@ -39,9 +40,10 @@ const RecentResults = ({ matches, tournamentId, max = 5 }: RecentResultsProps) =
type="button" type="button"
size="xs" size="xs"
fw={600} fw={600}
onClick={() => onClick={() => {
navigate({ to: `/tournaments/${tournamentId}/bracket` }) track("bracket_viewed", { source: "recent_results" });
} navigate({ to: `/tournaments/${tournamentId}/bracket` });
}}
> >
<Group gap={2} align="center" wrap="nowrap"> <Group gap={2} align="center" wrap="nowrap">
<Trans>View all results</Trans> <Trans>View all results</Trans>
@@ -77,7 +77,7 @@ const ScoreReportCta = ({ matches }: ScoreReportCtaProps) => {
)} )}
</Group> </Group>
</Group> </Group>
<MatchReport match={match} /> <MatchReport match={match} source="score_cta" />
</Stack> </Stack>
</Paper> </Paper>
))} ))}
@@ -9,6 +9,7 @@ import { Text } from "@mantine/core";
import PlayerList from "@/features/players/components/player-list"; import PlayerList from "@/features/players/components/player-list";
import { Player } from "@/features/players/types"; import { Player } from "@/features/players/types";
import { NavTile } from "../started-tournament/sections/nav-grid"; import { NavTile } from "../started-tournament/sections/nav-grid";
import { track } from "@/lib/telemetry/client";
interface EnrolledPlayersListButtonProps { interface EnrolledPlayersListButtonProps {
tournamentId: string; tournamentId: string;
@@ -33,7 +34,10 @@ const EnrolledPlayersListButton: React.FC<EnrolledPlayersListButtonProps> = ({ t
values: { count }, values: { count },
})} })}
Icon={UserListIcon} Icon={UserListIcon}
onClick={open} onClick={() => {
track("players_viewed", { source: "upcoming" });
open();
}}
/> />
<Sheet title={t`Enrolled Players`} opened={isOpen} onChange={toggle}> <Sheet title={t`Enrolled Players`} opened={isOpen} onChange={toggle}>
@@ -6,6 +6,7 @@ import { useTournament } from "../../queries"
import { Button, Flex, Loader, Stack } from "@mantine/core" import { Button, Flex, Loader, Stack } from "@mantine/core"
import { lazy, Suspense } from "react" import { lazy, Suspense } from "react"
import { NavTile } from "../started-tournament/sections/nav-grid" import { NavTile } from "../started-tournament/sections/nav-grid"
import { track } from "@/lib/telemetry/client"
const RulesContent = lazy(() => import("./rules-content")); const RulesContent = lazy(() => import("./rules-content"));
@@ -20,7 +21,14 @@ const RulesListButton: React.FC<RulesListButtonProps> = ({ tournamentId }) => {
return ( return (
<> <>
<NavTile label={t`Rules`} Icon={ListIcon} onClick={open} /> <NavTile
label={t`Rules`}
Icon={ListIcon}
onClick={() => {
track("rules_viewed", { source: "upcoming" });
open();
}}
/>
<Sheet title={t`Tournament Rules`} opened={isOpen} onChange={toggle}> <Sheet title={t`Tournament Rules`} opened={isOpen} onChange={toggle}>
<Stack gap="xs"> <Stack gap="xs">
@@ -7,6 +7,7 @@ import { useMemo } from "react"
import { useLingui } from "@lingui/react/macro" import { useLingui } from "@lingui/react/macro"
import { msg } from "@lingui/core/macro" import { msg } from "@lingui/core/macro"
import { NavTile } from "../started-tournament/sections/nav-grid" import { NavTile } from "../started-tournament/sections/nav-grid"
import { track } from "@/lib/telemetry/client"
interface TeamListButtonProps { interface TeamListButtonProps {
teams: TeamInfo[] teams: TeamInfo[]
@@ -25,7 +26,10 @@ const TeamListButton: React.FC<TeamListButtonProps> = ({ teams, isRegional }) =>
values: { count }, values: { count },
})} })}
Icon={UsersIcon} Icon={UsersIcon}
onClick={open} onClick={() => {
track("teams_viewed", { source: "upcoming" });
open();
}}
/> />
<Sheet title={t`Enrolled Teams`} opened={isOpen} onChange={toggle}> <Sheet title={t`Enrolled Teams`} opened={isOpen} onChange={toggle}>
+10 -5
View File
@@ -20,6 +20,8 @@ const INVALIDATE_JITTER_MS = 1500;
const invalidateTimers = new Map<string, ReturnType<typeof setTimeout>>(); const invalidateTimers = new Map<string, ReturnType<typeof setTimeout>>();
const WATCHDOG_MS = 45_000; const WATCHDOG_MS = 45_000;
const REFRESH_ATTEMPT_COOLDOWN_MS = 60_000;
let lastRefreshAttemptAt = 0;
function debouncedInvalidate( function debouncedInvalidate(
queryClient: ReturnType<typeof useQueryClient>, queryClient: ReturnType<typeof useQueryClient>,
@@ -164,11 +166,14 @@ export function useServerEvents() {
// session is genuinely gone, stop retrying so we don't hammer the // session is genuinely gone, stop retrying so we don't hammer the
// endpoint forever — a real re-login (or a wake event) will reconnect. // endpoint forever — a real re-login (or a wake event) will reconnect.
let sessionValid = true; let sessionValid = true;
try { if (Date.now() - lastRefreshAttemptAt > REFRESH_ATTEMPT_COOLDOWN_MS) {
const { attemptRefreshingSession } = await import('supertokens-web-js/recipe/session'); lastRefreshAttemptAt = Date.now();
sessionValid = await attemptRefreshingSession(); try {
} catch { const { attemptRefreshingSession } = await import('supertokens-web-js/recipe/session');
// Refresh call itself failed (likely offline) — treat as transient. sessionValid = await attemptRefreshingSession();
} catch {
// Refresh call itself failed (likely offline) — treat as transient.
}
} }
if (disposed) return; if (disposed) return;
+26 -7
View File
@@ -17,19 +17,37 @@ const recentErrors = new Map<string, number>();
const isEnabled = (): boolean => typeof window !== "undefined"; const isEnabled = (): boolean => typeof window !== "undefined";
const getSessionId = (): string => { // A session ends after 30 idle minutes, not when the tab closes — installed
// PWAs keep sessionStorage alive for days.
const SESSION_IDLE_MS = 30 * 60 * 1000;
const SESSION_KEY = "flxn-tsid";
const readSession = (): { id: string; t: number } | null => {
try { try {
let id = sessionStorage.getItem("flxn-tsid"); const raw = sessionStorage.getItem(SESSION_KEY);
if (!id) { if (!raw) return null;
id = crypto.randomUUID(); const parsed = JSON.parse(raw);
sessionStorage.setItem("flxn-tsid", id); if (typeof parsed?.id === "string" && typeof parsed?.t === "number") {
return parsed;
} }
return id; return null;
} catch { } catch {
return "anon-session"; return null;
} }
}; };
const touchSession = () => {
try {
const now = Date.now();
const current = readSession();
const id =
current && now - current.t <= SESSION_IDLE_MS ? current.id : crypto.randomUUID();
sessionStorage.setItem(SESSION_KEY, JSON.stringify({ id, t: now }));
} catch {}
};
const getSessionId = (): string => readSession()?.id ?? "anon-session";
const location = (): { path: string; routeId?: string } => ({ const location = (): { path: string; routeId?: string } => ({
path: window.location.pathname, path: window.location.pathname,
routeId: lastRouteId, routeId: lastRouteId,
@@ -64,6 +82,7 @@ const flush = (useBeacon = false) => {
const enqueue = (event: QueuedEvent) => { const enqueue = (event: QueuedEvent) => {
if (!isEnabled()) return; if (!isEnabled()) return;
touchSession();
queue.push(event); queue.push(event);
if (queue.length >= FLUSH_AT) { if (queue.length >= FLUSH_AT) {
flush(); flush();
+24
View File
@@ -14,6 +14,7 @@ export interface ClientEventRow {
session_id?: string; session_id?: string;
route_id?: string; route_id?: string;
value?: number; value?: number;
props?: { source?: string; emoji?: string; tournament?: string; value?: string } | null;
} }
export interface ClientErrorRow { export interface ClientErrorRow {
@@ -64,6 +65,8 @@ export interface ClientEventAccumulator {
sessions: Set<string>; sessions: Set<string>;
pageViewsByRoute: Map<string, number>; pageViewsByRoute: Map<string, number>;
vitals: Map<string, number[]>; vitals: Map<string, number[]>;
eventCounts: Map<string, number>;
eventDetails: Map<string, number>;
} }
export const newClientEventAccumulator = (): ClientEventAccumulator => ({ export const newClientEventAccumulator = (): ClientEventAccumulator => ({
@@ -71,8 +74,15 @@ export const newClientEventAccumulator = (): ClientEventAccumulator => ({
sessions: new Set(), sessions: new Set(),
pageViewsByRoute: new Map(), pageViewsByRoute: new Map(),
vitals: new Map(), vitals: new Map(),
eventCounts: new Map(),
eventDetails: new Map(),
}); });
// emoji first: reaction_sent carries both emoji and source, and its aggregate
// breakdown should be by emoji.
const eventDetail = (row: ClientEventRow): string | undefined =>
row.props?.emoji ?? row.props?.value ?? row.props?.source ?? row.props?.tournament;
export const accumulateClientEvents = (acc: ClientEventAccumulator, rows: ClientEventRow[]) => { export const accumulateClientEvents = (acc: ClientEventAccumulator, rows: ClientEventRow[]) => {
for (const row of rows) { for (const row of rows) {
if (row.player) acc.players.add(row.player); if (row.player) acc.players.add(row.player);
@@ -86,6 +96,13 @@ export const accumulateClientEvents = (acc: ClientEventAccumulator, rows: Client
const values = acc.vitals.get(metric) ?? []; const values = acc.vitals.get(metric) ?? [];
values.push(row.value); values.push(row.value);
acc.vitals.set(metric, values); acc.vitals.set(metric, values);
} else {
acc.eventCounts.set(row.name, (acc.eventCounts.get(row.name) ?? 0) + 1);
const detail = eventDetail(row);
if (detail) {
const key = `${row.name}|${detail}`;
acc.eventDetails.set(key, (acc.eventDetails.get(key) ?? 0) + 1);
}
} }
} }
}; };
@@ -157,6 +174,13 @@ export const buildDayRollups = (
push("page_view.count", "", totalPageViews); push("page_view.count", "", totalPageViews);
push("page_view.sessions", "", clientEvents.sessions.size); push("page_view.sessions", "", clientEvents.sessions.size);
for (const [name, count] of clientEvents.eventCounts) {
push("event.count", name, count);
}
for (const [key, count] of clientEvents.eventDetails) {
push("event.detail.count", key, count);
}
push("client_error.count", "", clientErrors.total); push("client_error.count", "", clientErrors.total);
for (const [group, count] of clientErrors.byGroup) { for (const [group, count] of clientErrors.byGroup) {
push("client_error.count", group, count); push("client_error.count", group, count);
+1 -1
View File
@@ -59,7 +59,7 @@ export const runRollups = async (
); );
await pbAdmin.pageCollection<ClientEventRow>( await pbAdmin.pageCollection<ClientEventRow>(
"client_events", "client_events",
{ filter: dayFilter(dateStr), fields: "name,player,session_id,route_id,value,created" }, { filter: dayFilter(dateStr), fields: "name,player,session_id,route_id,value,props,created" },
(rows) => accumulateClientEvents(clientEvents, rows) (rows) => accumulateClientEvents(clientEvents, rows)
); );
await pbAdmin.pageCollection<ClientErrorRow>( await pbAdmin.pageCollection<ClientErrorRow>(
+205 -165
View File
File diff suppressed because it is too large Load Diff
+205 -165
View File
File diff suppressed because it is too large Load Diff
+205 -165
View File
File diff suppressed because it is too large Load Diff
+205 -165
View File
File diff suppressed because it is too large Load Diff