Favorite team: star a side, get its next match pinned on Live

A star on the team page (localStorage, no accounts) pins that team's
next or running match in a My team section at the top of the Live tab.
EN/DE strings + aria-pressed on the toggle.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-06-12 00:59:15 +02:00
parent c2f4b4be83
commit 8e6c9416fa
5 changed files with 78 additions and 2 deletions
+19
View File
@@ -3,6 +3,7 @@ import { Radio } from 'lucide-react';
import { PageHeader } from '@/components/ui/PageHeader'; import { PageHeader } from '@/components/ui/PageHeader';
import { MatchCard } from '@/components/MatchCard'; import { MatchCard } from '@/components/MatchCard';
import { useTournamentStore } from '@/stores/tournamentStore'; import { useTournamentStore } from '@/stores/tournamentStore';
import { useFavoriteStore } from '@/stores/favoriteStore';
import { isToday } from '@/lib/format'; import { isToday } from '@/lib/format';
import { upcomingByDay, type DayGroup } from '@/lib/fixtures'; import { upcomingByDay, type DayGroup } from '@/lib/fixtures';
import { fmt, useFormat } from '@/lib/i18n'; import { fmt, useFormat } from '@/lib/i18n';
@@ -24,8 +25,17 @@ function Section({ title, fixtures }: { title: string; fixtures: Fixture[] }) {
export function LivePage() { export function LivePage() {
const snapshot = useTournamentStore((s) => s.snapshot); const snapshot = useTournamentStore((s) => s.snapshot);
const favorite = useFavoriteStore((s) => s.favorite);
const { t, kickoffDay } = useFormat(); const { t, kickoffDay } = useFormat();
// The starred team's next (or running) match, pinned on top.
const favMatch = useMemo(() => {
if (!favorite || !snapshot) return null;
return snapshot.fixtures
.filter((f) => f.status !== 'finished' && (f.home.team === favorite || f.away.team === favorite))
.sort((a, b) => a.kickoff.localeCompare(b.kickoff))[0] ?? null;
}, [favorite, snapshot]);
const groups = useMemo(() => { const groups = useMemo(() => {
const fixtures = snapshot?.fixtures ?? []; const fixtures = snapshot?.fixtures ?? [];
const live = fixtures.filter((f) => f.status === 'live').sort((a, b) => a.kickoff.localeCompare(b.kickoff)); const live = fixtures.filter((f) => f.status === 'live').sort((a, b) => a.kickoff.localeCompare(b.kickoff));
@@ -66,6 +76,15 @@ export function LivePage() {
subtitle={fmt(t.live.subtitle, { season: snapshot.season })} subtitle={fmt(t.live.subtitle, { season: snapshot.season })}
/> />
{favMatch && (
<section className="mb-7">
<h2 className="smallcaps mb-2.5"> {t.live.myTeam}</h2>
<div className="grid gap-3 sm:grid-cols-2">
<MatchCard f={favMatch} />
</div>
</section>
)}
<Section title={t.live.liveNowHeading} fixtures={groups.live} /> <Section title={t.live.liveNowHeading} fixtures={groups.live} />
<Section title={t.live.todayHeading} fixtures={groups.today} /> <Section title={t.live.todayHeading} fixtures={groups.today} />
{groups.upcoming.map((g, i) => ( {groups.upcoming.map((g, i) => (
+24 -2
View File
@@ -1,6 +1,7 @@
import { useEffect, useState } from 'react'; import { useEffect, useState } from 'react';
import { Link, useParams } from '@tanstack/react-router'; import { Link, useParams } from '@tanstack/react-router';
import { ArrowLeft, Shield } from 'lucide-react'; import { ArrowLeft, Shield, Star } from 'lucide-react';
import { useFavoriteStore } from '@/stores/favoriteStore';
import { Card, CardBody, CardHeader } from '@/components/ui/Card'; import { Card, CardBody, CardHeader } from '@/components/ui/Card';
import { Badge } from '@/components/ui/Badge'; import { Badge } from '@/components/ui/Badge';
import { teamFlag } from '@/lib/teams'; import { teamFlag } from '@/lib/teams';
@@ -31,6 +32,24 @@ function FixtureRow({ f, team }: { f: Fixture; team: string }) {
); );
} }
function FavoriteStar({ team }: { team: string }) {
const t = useT();
const favorite = useFavoriteStore((s) => s.favorite);
const toggle = useFavoriteStore((s) => s.toggleFavorite);
const active = favorite === team;
return (
<button
type="button"
onClick={() => toggle(team)}
aria-label={active ? t.team.unfollow : t.team.follow}
aria-pressed={active}
className={`grid h-9 w-9 place-items-center rounded-md transition-colors ${active ? 'text-gold' : 'text-faint hover:text-ink'}`}
>
<Star size={20} fill={active ? 'currentColor' : 'none'} />
</button>
);
}
export function TeamProfilePage() { export function TeamProfilePage() {
const { name } = useParams({ strict: false }) as { name: string }; const { name } = useParams({ strict: false }) as { name: string };
const t = useT(); const t = useT();
@@ -62,7 +81,10 @@ export function TeamProfilePage() {
<CardBody className="flex flex-wrap items-center gap-4"> <CardBody className="flex flex-wrap items-center gap-4">
<span aria-hidden className="text-5xl">{teamFlag(profile.team)}</span> <span aria-hidden className="text-5xl">{teamFlag(profile.team)}</span>
<div> <div>
<h1 className="font-display text-2xl font-extrabold text-ink">{profile.team}</h1> <h1 className="flex items-center gap-2 font-display text-2xl font-extrabold text-ink">
{profile.team}
<FavoriteStar team={profile.team} />
</h1>
<div className="mt-1 flex flex-wrap items-center gap-2 text-sm"> <div className="mt-1 flex flex-wrap items-center gap-2 text-sm">
{profile.group && <Badge tone="neutral">{fmt(t.common.group, { group: profile.group })}</Badge>} {profile.group && <Badge tone="neutral">{fmt(t.common.group, { group: profile.group })}</Badge>}
<Badge tone="muted">{fmt(t.team.eloBadge, { rating: profile.elo })}</Badge> <Badge tone="muted">{fmt(t.team.eloBadge, { rating: profile.elo })}</Badge>
+3
View File
@@ -66,6 +66,7 @@ export const de: Dict = {
nextUp: "Als Nächstes", nextUp: "Als Nächstes",
nextUpDay: "Als Nächstes · {day}", nextUpDay: "Als Nächstes · {day}",
comingUp: "Demnächst", comingUp: "Demnächst",
myTeam: "Mein Team",
latestResults: "Letzte Ergebnisse", latestResults: "Letzte Ergebnisse",
empty: "Der Spielplan ist geladen. Sobald das Turnier läuft, tauchen die Spiele hier auf.", empty: "Der Spielplan ist geladen. Sobald das Turnier läuft, tauchen die Spiele hier auf.",
}, },
@@ -371,6 +372,8 @@ export const de: Dict = {
team: { team: {
unknown: "Unbekanntes Team.", unknown: "Unbekanntes Team.",
allTeams: "Alle Teams", allTeams: "Alle Teams",
follow: "Diesem Team folgen",
unfollow: "Diesem Team nicht mehr folgen",
eloBadge: "Elo {rating}", eloBadge: "Elo {rating}",
titleOddsBadge: "Titel {pct}", titleOddsBadge: "Titel {pct}",
advanceOddsBadge: "Weiterkommen {pct}", advanceOddsBadge: "Weiterkommen {pct}",
+3
View File
@@ -65,6 +65,7 @@ export const en = {
nextUp: "Next up", nextUp: "Next up",
nextUpDay: "Next up · {day}", nextUpDay: "Next up · {day}",
comingUp: "Coming up", comingUp: "Coming up",
myTeam: "My team",
latestResults: "Latest results", latestResults: "Latest results",
empty: "The schedule is ready. Matches will appear here once the tournament kicks off.", empty: "The schedule is ready. Matches will appear here once the tournament kicks off.",
}, },
@@ -370,6 +371,8 @@ export const en = {
team: { team: {
unknown: "Unknown team.", unknown: "Unknown team.",
allTeams: "All teams", allTeams: "All teams",
follow: "Follow this team",
unfollow: "Unfollow this team",
eloBadge: "Elo {rating}", eloBadge: "Elo {rating}",
titleOddsBadge: "Title {pct}", titleOddsBadge: "Title {pct}",
advanceOddsBadge: "Advance {pct}", advanceOddsBadge: "Advance {pct}",
+29
View File
@@ -0,0 +1,29 @@
import { create } from 'zustand';
const STORAGE_KEY = 'cup26:favorite';
function readFavorite(): string | null {
try {
return localStorage.getItem(STORAGE_KEY);
} catch {
return null;
}
}
interface FavoriteState {
favorite: string | null;
toggleFavorite: (team: string) => void;
}
/** One starred team — its next match gets pinned on the Live tab. */
export const useFavoriteStore = create<FavoriteState>((set, get) => ({
favorite: readFavorite(),
toggleFavorite: (team) => {
const next = get().favorite === team ? null : team;
try {
if (next) localStorage.setItem(STORAGE_KEY, next);
else localStorage.removeItem(STORAGE_KEY);
} catch { /* private mode */ }
set({ favorite: next });
},
}));