Compare commits
6
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8e42862d20 | ||
|
|
f593b2c7b8 | ||
|
|
fc65fdb451 | ||
|
|
8171354e2a | ||
|
|
cb16d9757e | ||
|
|
96d5535293 |
@@ -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>
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
+1
-1
@@ -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>
|
||||||
))}
|
))}
|
||||||
|
|||||||
+5
-1
@@ -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}>
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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
File diff suppressed because it is too large
Load Diff
+205
-165
File diff suppressed because it is too large
Load Diff
+205
-165
File diff suppressed because it is too large
Load Diff
+205
-165
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user