Files
flxn-app/src/features/bracket/components/match-dock.tsx
T
kyle 7bda138d69
CI/CD Pipeline / Build and Push PocketBase Docker Image (push) Successful in 9s
CI/CD Pipeline / i18n Catalog Check (push) Successful in 47s
CI/CD Pipeline / Build and Push App Docker Image (push) Successful in 2m18s
CI/CD Pipeline / Deploy to Kubernetes (push) Successful in 8m29s
feat(telemetry): matchup and direction context in timelines; live error groups
- h2h_viewed carries who-v-who, stats_sorted carries asc/desc via a
  timeline-only context prop
- error browser aggregates raw rows so new groups appear immediately
- catalogs re-extracted for shifted line references
2026-08-27 10:47:57 -07:00

237 lines
7.6 KiB
TypeScript

import { Suspense } from "react";
import {
ActionIcon,
Box,
Center,
CloseButton,
Group,
Indicator,
Loader,
Paper,
Stack,
Text,
Tooltip,
} from "@mantine/core";
import { AnimatePresence, motion, useReducedMotion } from "framer-motion";
import { FootballHelmetIcon } from "@phosphor-icons/react";
import { useNavigate } from "@tanstack/react-router";
import { Match } from "@/features/matches/types";
import TeamAvatar from "@/components/team-avatar";
import AnimatedScore from "@/features/matches/components/animated-score";
import EmojiBar from "@/features/reactions/components/emoji-bar";
import TeamHeadToHeadSheet from "@/features/matches/components/team-head-to-head-sheet";
import { MatchReport } from "./match-report";
import Sheet from "@/components/sheet/sheet";
import { useSheet } from "@/hooks/use-sheet";
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];
interface MatchDockProps {
match: Match | null;
onClose: () => void;
}
const TeamRow = ({
team,
cups,
isWinner,
isRegional,
ended,
}: {
team: NonNullable<Match["home"]>;
cups: number;
isWinner: boolean;
isRegional: boolean;
ended: boolean;
}) => {
const navigate = useNavigate();
return (
<Group justify="space-between" wrap="nowrap" gap="sm">
<Group
gap="sm"
wrap="nowrap"
style={{ flex: 1, minWidth: 0, cursor: "pointer" }}
onClick={() => navigate({ to: `/teams/${team.id}` })}
>
<TeamAvatar
team={team}
size={32}
radius="sm"
winner={ended && isWinner}
isRegional={isRegional}
/>
<Text size="sm" fw={ended && isWinner ? 700 : 500} lineClamp={1}>
{team.name}
</Text>
</Group>
{ended && (
<AnimatedScore
value={cups}
size="lg"
fw={700}
c={isWinner ? "green" : "dimmed"}
style={{ minWidth: 28, textAlign: "center" }}
/>
)}
</Group>
);
};
const MatchDock = ({ match, onClose }: MatchDockProps) => {
const reduceMotion = useReducedMotion();
const h2hSheet = useSheet();
const { t } = useLingui();
const hasPrivate = match?.home?.private || match?.away?.private;
const ended = match?.status === "ended";
const started = match?.status === "started";
return (
<>
<AnimatePresence>
{match && match.home && match.away && (
<motion.div
key="match-dock"
initial={reduceMotion ? { opacity: 0 } : { opacity: 0, y: 16 }}
animate={{ opacity: 1, y: 0 }}
exit={reduceMotion ? { opacity: 0 } : { opacity: 0, y: 16 }}
transition={{ duration: 0.2, ease: EASE }}
style={{
position: "absolute",
left: 0,
right: 0,
bottom: 0,
zIndex: 5,
padding: "0 12px 12px",
}}
>
<Paper withBorder shadow="md" radius="lg" px="md" py="sm">
<Stack gap="xs">
<Group justify="space-between" wrap="nowrap">
<Group gap="xs" wrap="nowrap">
{started && (
<Indicator
size={8}
color="red"
processing
position="middle-start"
offset={0}
/>
)}
<Text size="xs" fw={600} c="dimmed" lineClamp={1}>
{match.is_losers_bracket ? (
<Trans>Match {match.order} · Round {match.round + 1} (Losers)</Trans>
) : (
<Trans>Match {match.order} · Round {match.round + 1}</Trans>
)}
</Text>
</Group>
<CloseButton
size="sm"
onClick={onClose}
aria-label={t`Close match actions`}
/>
</Group>
<TeamRow
team={match.home}
cups={match.home_cups}
isWinner={match.home_cups > match.away_cups}
isRegional={match.tournament.regional === true}
ended={ended}
/>
<Box
style={{
height: 1,
backgroundColor: "var(--mantine-color-default-border)",
}}
/>
<TeamRow
team={match.away}
cups={match.away_cups}
isWinner={match.away_cups > match.home_cups}
isRegional={match.tournament.regional === true}
ended={ended}
/>
<MatchReport match={match} source="match_dock" />
<Group justify="space-between" wrap="nowrap" gap="sm">
<Box style={{ flex: 1, minWidth: 0 }}>
<Suspense
fallback={
<Center py={4}>
<Loader size="xs" />
</Center>
}
>
<EmojiBar matchId={match.id} source="match_dock" />
</Suspense>
</Box>
{!hasPrivate && (
<Tooltip label={t`Head to Head`} withArrow position="top">
<ActionIcon
variant="subtle"
size="sm"
onClick={() => {
track("h2h_viewed", {
source: "match_dock",
context: [match?.home?.name, match?.away?.name]
.filter(Boolean)
.join(" v "),
});
h2hSheet.open();
}}
aria-label={t`View head-to-head`}
w={40}
style={{ flexShrink: 0 }}
>
<Group
style={{ position: "relative", width: 27.5, height: 16 }}
>
<FootballHelmetIcon
size={14}
style={{
position: "absolute",
left: 0,
top: 0,
transform: "rotate(25deg)",
}}
/>
<FootballHelmetIcon
size={14}
style={{
position: "absolute",
right: 0,
top: 0,
transform: "scaleX(-1) rotate(25deg)",
}}
/>
</Group>
</ActionIcon>
</Tooltip>
)}
</Group>
</Stack>
</Paper>
</motion.div>
)}
</AnimatePresence>
{match?.home && match?.away && h2hSheet.isOpen && (
<Sheet title={t`Head to Head`} {...h2hSheet.props}>
<TeamHeadToHeadSheet
team1={match.home}
team2={match.away}
isOpen={h2hSheet.props.opened}
/>
</Sheet>
)}
</>
);
};
export default MatchDock;