From f7b68e6ac92b1d5afd956336c7774dc76dd5643f Mon Sep 17 00:00:00 2001 From: Ian Jones Date: Sun, 22 Mar 2026 16:11:56 -0600 Subject: [PATCH 1/4] feat: redesign public competitions page with card layout and advanced filters Replaces the utilitarian list view with a card-based grid layout featuring profile images, status-aware filter tabs, text search with deferred updates, and advanced filters (location, organizer, type). Cards show competition logo or deterministic gradient fallback with initials. - New CompetitionCard component with profile image / gradient fallback - Status filter tabs (All, Open, Live, Upcoming, Past) synced to URL - Advanced filters panel (location, organizer, competition type) - Search input with useTransition + useDeferredValue for responsiveness - Staggered card entrance animation with prefers-reduced-motion support - Accessibility fixes: aria-labels, semantic search input, sr-only text - Card enter keyframe animation in styles.css Co-Authored-By: Claude Opus 4.6 (1M context) --- .../src/components/competition-card.tsx | 208 +++++++ .../src/components/competition-search.tsx | 6 +- .../src/routes/compete/index.tsx | 585 +++++++++++++++--- apps/wodsmith-start/src/styles.css | 21 + lat.md/series-event-templates.md | 10 +- 5 files changed, 725 insertions(+), 105 deletions(-) create mode 100644 apps/wodsmith-start/src/components/competition-card.tsx diff --git a/apps/wodsmith-start/src/components/competition-card.tsx b/apps/wodsmith-start/src/components/competition-card.tsx new file mode 100644 index 000000000..ef2d1fc2a --- /dev/null +++ b/apps/wodsmith-start/src/components/competition-card.tsx @@ -0,0 +1,208 @@ +"use client" + +import { CalendarIcon, GlobeIcon, MapPinIcon } from "lucide-react" +import type { CompetitionWithOrganizingTeam } from "@/server-fns/competition-fns" +import { formatLocationBadge } from "@/utils/address" +import { cn } from "@/utils/cn" +import { isSameUTCDay } from "@/utils/date-utils" + +type CompetitionStatus = + | "registration-open" + | "active" + | "coming-soon" + | "registration-closed" + | "past" + +interface CompetitionCardProps { + competition: CompetitionWithOrganizingTeam + status: CompetitionStatus + index: number +} + +const STATUS_CONFIG: Record< + CompetitionStatus, + { label: string; className: string } +> = { + active: { + label: "Live Now", + className: + "text-emerald-700 dark:text-emerald-400 bg-emerald-50 dark:bg-emerald-950/50 border-emerald-200 dark:border-emerald-800", + }, + "registration-open": { + label: "Register", + className: "text-primary bg-primary/10 border-primary/20", + }, + "coming-soon": { + label: "Upcoming", + className: "text-muted-foreground bg-muted border-transparent", + }, + "registration-closed": { + label: "Closed", + className: "text-muted-foreground bg-muted border-transparent", + }, + past: { + label: "Completed", + className: "text-muted-foreground bg-muted border-transparent", + }, +} + +// Curated gradient pairs that look good in both light and dark mode +const GRADIENT_PAIRS = [ + ["#f97316", "#ea580c"], // orange + ["#3b82f6", "#2563eb"], // blue + ["#8b5cf6", "#7c3aed"], // violet + ["#ec4899", "#db2777"], // pink + ["#14b8a6", "#0d9488"], // teal + ["#f59e0b", "#d97706"], // amber + ["#6366f1", "#4f46e5"], // indigo + ["#10b981", "#059669"], // emerald + ["#ef4444", "#dc2626"], // red + ["#06b6d4", "#0891b2"], // cyan +] as const + +function hashString(str: string): number { + let hash = 0 + for (let i = 0; i < str.length; i++) { + hash = ((hash << 5) - hash + str.charCodeAt(i)) | 0 + } + return Math.abs(hash) +} + +function getGradient(name: string) { + const idx = hashString(name) % GRADIENT_PAIRS.length + return GRADIENT_PAIRS[idx] +} + +function getInitials(name: string): string { + return name + .split(/\s+/) + .slice(0, 2) + .map((w) => w[0]) + .join("") + .toUpperCase() +} + +function formatDateRange(startDate: string, endDate: string) { + const start = new Date(startDate) + const end = new Date(endDate) + const opts: Intl.DateTimeFormatOptions = { + month: "short", + day: "numeric", + timeZone: "UTC", + } + if (isSameUTCDay(startDate, endDate)) { + return start.toLocaleDateString("en-US", { ...opts, year: "numeric" }) + } + const sameMonth = + start.getUTCMonth() === end.getUTCMonth() && + start.getUTCFullYear() === end.getUTCFullYear() + if (sameMonth) { + return `${start.toLocaleDateString("en-US", { month: "short", day: "numeric", timeZone: "UTC" })}–${end.getUTCDate()}, ${end.getUTCFullYear()}` + } + return `${start.toLocaleDateString("en-US", opts)} – ${end.toLocaleDateString("en-US", { ...opts, year: "numeric" })}` +} + +export function CompetitionCard({ + competition, + status, + index, +}: CompetitionCardProps) { + const locationBadge = formatLocationBadge( + competition.address, + competition.competitionType, + competition.organizingTeam?.name, + ) + const cfg = STATUS_CONFIG[status] + const dateRange = formatDateRange(competition.startDate, competition.endDate) + const profileImage = competition.profileImageUrl + const [gradFrom, gradTo] = getGradient(competition.name) + const initials = getInitials(competition.name) + + return ( + + {/* Profile image / fallback */} +
+ {profileImage ? ( + + ) : ( + + )} +
+ + {/* Content */} +
+ {/* Name */} +

+ {competition.name} +

+ + {/* Organizer */} + {competition.organizingTeam && ( +

+ {competition.organizingTeam.name} +

+ )} + + {/* Date + Location + Status */} +
+ + + + {locationBadge.icon === "globe" ? ( + + + {cfg.label} + +
+
+
+ ) +} diff --git a/apps/wodsmith-start/src/components/competition-search.tsx b/apps/wodsmith-start/src/components/competition-search.tsx index 9198596b1..ccb234e94 100644 --- a/apps/wodsmith-start/src/components/competition-search.tsx +++ b/apps/wodsmith-start/src/components/competition-search.tsx @@ -25,9 +25,12 @@ export function CompetitionSearch({ onSearchChange(e.target.value)} + autoComplete="off" + spellCheck={false} className="pl-10 pr-10" /> {search && ( @@ -38,6 +41,7 @@ export function CompetitionSearch({ onClick={() => onSearchChange("")} > + Clear search )} diff --git a/apps/wodsmith-start/src/routes/compete/index.tsx b/apps/wodsmith-start/src/routes/compete/index.tsx index 57872fe35..c05caea41 100644 --- a/apps/wodsmith-start/src/routes/compete/index.tsx +++ b/apps/wodsmith-start/src/routes/compete/index.tsx @@ -1,25 +1,51 @@ +import { useDeferredValue, useMemo, useState, useTransition } from "react" import { createFileRoute, useNavigate } from "@tanstack/react-router" -import { CompetitionRow } from "@/components/competition-row" -import { CompetitionSearch } from "@/components/competition-search" -import { CompetitionSection } from "@/components/competition-section" +import { FilterIcon, SearchIcon, X } from "lucide-react" +import { CompetitionCard } from "@/components/competition-card" +import { Button } from "@/components/ui/button" +import { Input } from "@/components/ui/input" +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select" import { getSessionFn } from "@/server-fns/auth-fns" import { type CompetitionWithOrganizingTeam, getPublicCompetitionsFn, } from "@/server-fns/competition-fns" +import { cn } from "@/utils/cn" import { getTodayInTimezone } from "@/utils/date-utils" +// --------------------------------------------------------------------------- +// Route +// --------------------------------------------------------------------------- + +type StatusFilter = "all" | "registration-open" | "active" | "upcoming" | "past" + type CompeteSearch = { q?: string - past?: boolean + filter?: StatusFilter + location?: string + organizer?: string + type?: "in-person" | "online" } export const Route = createFileRoute("/compete/")({ component: CompetePage, - staleTime: 30_000, // Cache for 30 seconds - competition list doesn't change frequently + staleTime: 30_000, validateSearch: (search: Record): CompeteSearch => ({ q: typeof search.q === "string" ? search.q : undefined, - past: search.past === "true" || search.past === true, + filter: isValidStatusFilter(search.filter) ? search.filter : undefined, + location: typeof search.location === "string" ? search.location : undefined, + organizer: + typeof search.organizer === "string" ? search.organizer : undefined, + type: + search.type === "in-person" || search.type === "online" + ? search.type + : undefined, }), loader: async () => { const [result, session] = await Promise.all([ @@ -33,147 +59,502 @@ export const Route = createFileRoute("/compete/")({ }, }) -// Helper to determine competition status -// Compares YYYY-MM-DD date strings using the competition's timezone -// so registration closes at 11:59pm in the competition's local time -function getCompetitionStatus(comp: CompetitionWithOrganizingTeam) { - const { startDate, endDate, registrationOpensAt, registrationClosesAt } = comp - const today = getTodayInTimezone(comp.timezone ?? "America/Denver") +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- - // Past if already ended (end date is before today) - if (endDate < today) { - return "past" - } +function isValidStatusFilter(value: unknown): value is StatusFilter { + return ( + typeof value === "string" && + ["all", "registration-open", "active", "upcoming", "past"].includes(value) + ) +} - // Active if currently happening - if (startDate <= today && endDate >= today) { - return "active" - } +type CompetitionStatus = + | "registration-open" + | "active" + | "coming-soon" + | "registration-closed" + | "past" - // Registration open if starts in future AND registration window is active +function getCompetitionStatus( + comp: CompetitionWithOrganizingTeam, +): CompetitionStatus { + const { startDate, endDate, registrationOpensAt, registrationClosesAt } = comp + const today = getTodayInTimezone(comp.timezone ?? "America/Denver") + + if (endDate < today) return "past" + if (startDate <= today && endDate >= today) return "active" if ( startDate > today && registrationOpensAt && registrationClosesAt && registrationOpensAt <= today && registrationClosesAt >= today - ) { + ) return "registration-open" - } - - // Registration closed if starts in future AND reg window closed if ( startDate > today && registrationOpensAt && registrationClosesAt && registrationClosesAt < today - ) { + ) return "registration-closed" - } + return "coming-soon" +} - // Coming soon if starts in future AND (no reg window OR reg not yet open) - if ( - startDate > today && - (!registrationOpensAt || registrationOpensAt > today) - ) { - return "coming-soon" - } +const STATUS_TABS: { value: StatusFilter; label: string }[] = [ + { value: "all", label: "All" }, + { value: "registration-open", label: "Open" }, + { value: "active", label: "Live" }, + { value: "upcoming", label: "Upcoming" }, + { value: "past", label: "Past" }, +] - // Default fallback - return "coming-soon" +function matchesStatusFilter( + status: CompetitionStatus, + filter: StatusFilter, +): boolean { + if (filter === "all") return status !== "past" + if (filter === "past") return status === "past" + if (filter === "active") return status === "active" + if (filter === "registration-open") return status === "registration-open" + if (filter === "upcoming") + return status === "coming-soon" || status === "registration-closed" + return true } -type CompetitionStatus = - | "registration-open" - | "active" - | "coming-soon" - | "registration-closed" - | "past" +function getLocationLabel(comp: CompetitionWithOrganizingTeam): string | null { + const city = comp.address?.city?.trim() + const state = comp.address?.stateProvince?.trim() + if (city && state) return `${city}, ${state}` + if (city) return city + if (state) return state + return null +} + +// --------------------------------------------------------------------------- +// Page +// --------------------------------------------------------------------------- function CompetePage() { - const { competitions, isAuthenticated } = Route.useLoaderData() - const { q: searchQuery, past } = Route.useSearch() + const { competitions } = Route.useLoaderData() + const search = Route.useSearch() const navigate = useNavigate({ from: Route.fullPath }) - const showPast = past === true + const activeStatus = search.filter ?? "all" + const [, startTransition] = useTransition() + const [localSearch, setLocalSearch] = useState(search.q ?? "") + const deferredSearchQuery = useDeferredValue(search.q) + const [showAdvanced, setShowAdvanced] = useState( + Boolean(search.location || search.organizer || search.type), + ) - // Handlers for search state updates - const handleSearchChange = (value: string) => { - navigate({ - search: (prev) => ({ ...prev, q: value || undefined }), + const updateSearch = (updates: Partial) => { + startTransition(() => { + navigate({ + search: (prev) => { + const next = { ...prev, ...updates } + // Clean undefined values + for (const key of Object.keys(next) as (keyof CompeteSearch)[]) { + if (next[key] === undefined) delete next[key] + } + return next + }, + }) }) } - const handleShowPastChange = (value: boolean) => { - navigate({ - search: (prev) => ({ ...prev, past: value || undefined }), - }) + const handleSearchChange = (value: string) => { + setLocalSearch(value) + updateSearch({ q: value || undefined }) } - // Filter by search query if provided - const filteredCompetitions = searchQuery - ? competitions.filter( - (comp) => - comp.name.toLowerCase().includes(searchQuery.toLowerCase()) || - comp.description?.toLowerCase().includes(searchQuery.toLowerCase()) || - comp.organizingTeam?.name - .toLowerCase() - .includes(searchQuery.toLowerCase()), - ) - : competitions + // Derive statuses + const competitionsWithStatus = useMemo( + () => + competitions.map((comp) => ({ + ...comp, + _status: getCompetitionStatus(comp), + _location: getLocationLabel(comp), + })), + [competitions], + ) + + // Build filter option lists from data + const filterOptions = useMemo(() => { + const locations = new Set() + const organizers = new Set() - // Filter out past competitions unless showPast is true - const visibleCompetitions = showPast - ? filteredCompetitions - : filteredCompetitions.filter( - (comp) => getCompetitionStatus(comp) !== "past", + for (const comp of competitionsWithStatus) { + if (comp._location) locations.add(comp._location) + if (comp.organizingTeam?.name) organizers.add(comp.organizingTeam.name) + } + + return { + locations: [...locations].sort(), + organizers: [...organizers].sort(), + } + }, [competitionsWithStatus]) + + // Counts per status tab + const counts = useMemo(() => { + const c = { + all: 0, + "registration-open": 0, + active: 0, + upcoming: 0, + past: 0, + } + for (const comp of competitionsWithStatus) { + if (comp._status !== "past") c.all++ + if (comp._status === "registration-open") c["registration-open"]++ + if (comp._status === "active") c.active++ + if ( + comp._status === "coming-soon" || + comp._status === "registration-closed" ) + c.upcoming++ + if (comp._status === "past") c.past++ + } + return c + }, [competitionsWithStatus]) - // Sort competitions by start date - const sortedCompetitions = [...visibleCompetitions].sort( - (a, b) => new Date(a.startDate).getTime() - new Date(b.startDate).getTime(), + // Check if any advanced filter is active + const hasAdvancedFilters = Boolean( + search.location || search.organizer || search.type, ) - const hasNoCompetitions = sortedCompetitions.length === 0 + const clearAdvancedFilters = () => { + updateSearch({ + location: undefined, + organizer: undefined, + type: undefined, + }) + } + + // Filter + search + sort + const sorted = useMemo(() => { + let list = competitionsWithStatus.filter((c) => + matchesStatusFilter(c._status, activeStatus), + ) + + // Advanced filters + if (search.location) { + list = list.filter((c) => c._location === search.location) + } + if (search.organizer) { + list = list.filter((c) => c.organizingTeam?.name === search.organizer) + } + if (search.type) { + list = list.filter((c) => c.competitionType === search.type) + } + + // Text search + if (deferredSearchQuery) { + const q = deferredSearchQuery.toLowerCase() + list = list.filter( + (c) => + c.name.toLowerCase().includes(q) || + c.description?.toLowerCase().includes(q) || + c.organizingTeam?.name.toLowerCase().includes(q), + ) + } + + const priority: Record = { + active: 0, + "registration-open": 1, + "coming-soon": 2, + "registration-closed": 3, + past: 4, + } + return [...list].sort((a, b) => { + const p = priority[a._status] - priority[b._status] + if (p !== 0) return p + return new Date(a.startDate).getTime() - new Date(b.startDate).getTime() + }) + }, [ + competitionsWithStatus, + activeStatus, + search.location, + search.organizer, + search.type, + deferredSearchQuery, + ]) return ( -
-
-

All Competitions

-

- Find and register for CrossFit competitions +

+ {/* ── Header ─────────────────────────────────────────── */} +
+

+ Competitions +

+

+ Discover events, register to compete, and track your performance.

+
+ + {/* ── Toolbar ────────────────────────────────────────── */} +
+ {/* Status tabs */} + + + {/* Search + filter toggle */} +
+
+
+ +
- - - {/* All Competitions Section */} - {hasNoCompetitions ? ( -
-

- {searchQuery - ? "No competitions match your search." - : "No competitions available right now."} -

+ {/* ── Advanced filters ───────────────────────────────── */} + {showAdvanced && ( +
+ {/* Location */} +
+ + +
+ + {/* Organizer */} +
+ + +
+ + {/* Competition type */} +
+ + +
+ + {/* Clear */} + {hasAdvancedFilters && ( + + )}
+ )} + + {/* ── Divider ────────────────────────────────────────── */} +
+ + {/* ── Grid ───────────────────────────────────────────── */} + {sorted.length === 0 ? ( + ) : ( - - {sortedCompetitions.map((comp) => ( - + {sorted.map((comp, i) => ( + ))} - +
+ )} +
+ ) +} + +// --------------------------------------------------------------------------- +// Empty state +// --------------------------------------------------------------------------- + +function EmptyState({ + searchQuery, + hasFilters, +}: { + searchQuery?: string + hasFilters: boolean +}) { + const navigate = useNavigate({ from: Route.fullPath }) + + return ( +
+
+

+ {searchQuery ? "No results" : "Nothing here yet"} +

+

+ {searchQuery + ? `Nothing matches "${searchQuery}".` + : hasFilters + ? "Try adjusting your filters." + : "Check back soon — new competitions are added regularly."} +

+ {(searchQuery || hasFilters) && ( + )}
) diff --git a/apps/wodsmith-start/src/styles.css b/apps/wodsmith-start/src/styles.css index 45b023d2f..3b14fa1b0 100644 --- a/apps/wodsmith-start/src/styles.css +++ b/apps/wodsmith-start/src/styles.css @@ -64,6 +64,7 @@ /* Animations */ --animate-accordion-down: accordion-down 0.2s ease-out; --animate-accordion-up: accordion-up 0.2s ease-out; + --animate-card-enter: card-enter 0.4s ease-out backwards; } @keyframes accordion-down { @@ -84,6 +85,26 @@ } } +@keyframes card-enter { + from { + opacity: 0; + transform: translateY(8px); + } + to { + opacity: 1; + transform: translateY(0); + } +} + +@media (prefers-reduced-motion: reduce) { + @keyframes card-enter { + from { + opacity: 1; + transform: none; + } + } +} + /* CSS Variables for light/dark mode */ @layer base { :root { diff --git a/lat.md/series-event-templates.md b/lat.md/series-event-templates.md index 138fd6e3f..85d937caf 100644 --- a/lat.md/series-event-templates.md +++ b/lat.md/series-event-templates.md @@ -59,7 +59,9 @@ The `templateEventIds` optional parameter filters which template events to sync. ### Sync Preview -`previewSyncEventsToCompetitionsFn` generates a detailed diff showing what would change per competition per event. Changes include field-level diffs (e.g., "name: Old Name to New Name"), order changes, "movements updated", "N resources to add", "N judging sheets to add". +`previewSyncEventsToCompetitionsFn` generates a detailed diff showing what would change per competition per event. + +Changes include field-level diffs (e.g., "name: Old Name to New Name"), order changes, "movements updated", "N resources to add", "N judging sheets to add". ## Event Matching @@ -76,7 +78,9 @@ Each template event can only be claimed once (no duplicate mappings). ### Persistence -Mappings are stored in `series_event_mappings` (groupId, competitionId, competitionEventId, templateEventId). `saveSeriesEventMappingsFn` does a full replace — deletes all existing mappings for the group, then inserts the new set atomically in a transaction. +Mappings are stored in `series_event_mappings` with four keys: groupId, competitionId, competitionEventId, templateEventId. + +`saveSeriesEventMappingsFn` does a full replace — deletes all existing mappings for the group, then inserts the new set atomically in a transaction. ## Competition Creation Integration @@ -109,6 +113,8 @@ All defined in `src/server-fns/series-event-template-fns.ts`: ## Routes +All series event template routes are nested under the organizer dashboard. + - `/series/{groupId}/events` — Layout route with `` - `/series/{groupId}/events/` — Event list with template creator or editor + sync button - `/series/{groupId}/events/{eventId}` — Full event edit page (standalone or parent with tabbed sub-events) From a1ce4d181df72d072e53bbebd99476747748c83c Mon Sep 17 00:00:00 2001 From: Ian Jones Date: Tue, 24 Mar 2026 18:21:45 -0600 Subject: [PATCH 2/4] fix: use Router Link for competition cards and improve search accessibility Replace raw with TanStack Router for client-side navigation and preloading. Add aria-label and type="search" to competition search input. Fix broken lat.md refs for registration sections. Co-Authored-By: Claude Opus 4.6 (1M context) --- apps/wodsmith-start/src/components/competition-card.tsx | 8 +++++--- apps/wodsmith-start/src/routes/compete/index.tsx | 3 ++- apps/wodsmith-start/src/server-fns/registration-fns.ts | 4 ++-- 3 files changed, 9 insertions(+), 6 deletions(-) diff --git a/apps/wodsmith-start/src/components/competition-card.tsx b/apps/wodsmith-start/src/components/competition-card.tsx index ef2d1fc2a..835159902 100644 --- a/apps/wodsmith-start/src/components/competition-card.tsx +++ b/apps/wodsmith-start/src/components/competition-card.tsx @@ -1,5 +1,6 @@ "use client" +import { Link } from "@tanstack/react-router" import { CalendarIcon, GlobeIcon, MapPinIcon } from "lucide-react" import type { CompetitionWithOrganizingTeam } from "@/server-fns/competition-fns" import { formatLocationBadge } from "@/utils/address" @@ -119,8 +120,9 @@ export function CompetitionCard({ const initials = getInitials(competition.name) return ( -
- + ) } diff --git a/apps/wodsmith-start/src/routes/compete/index.tsx b/apps/wodsmith-start/src/routes/compete/index.tsx index c05caea41..e94353dbc 100644 --- a/apps/wodsmith-start/src/routes/compete/index.tsx +++ b/apps/wodsmith-start/src/routes/compete/index.tsx @@ -350,7 +350,8 @@ function CompetePage() { aria-hidden="true" /> initiateRegistrationPaymentInputSchema.parse(data), @@ -1836,7 +1836,7 @@ const createManualRegistrationInputSchema = z.object({ * with isOrganizerOverride to bypass registration window checks, sets * the appropriate payment status, stores answers, and sends confirmation. */ -// @lat: [[registration#Organizer Manual Registration]] +// @lat: [[registration#Registration Flow#Organizer Manual Registration]] export const createManualRegistrationFn = createServerFn({ method: "POST" }) .inputValidator((data: unknown) => createManualRegistrationInputSchema.parse(data), From 0a788712057b91c85d3e7e31e73a7dfee9d683e2 Mon Sep 17 00:00:00 2001 From: Ian Jones Date: Tue, 24 Mar 2026 18:28:22 -0600 Subject: [PATCH 3/4] fix: resolve lint errors for CI Remove unused totalPlatformFeeCents variable, replace nav with div for tablist role, remove useless undefined initializations, simplify ternary. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../src/components/competition-leaderboard-table.tsx | 2 +- .../src/components/online-competition-leaderboard-table.tsx | 2 +- .../src/components/series-leaderboard-table.tsx | 2 +- apps/wodsmith-start/src/routes/compete/index.tsx | 4 ++-- apps/wodsmith-start/src/server-fns/registration-fns.ts | 2 -- 5 files changed, 5 insertions(+), 7 deletions(-) diff --git a/apps/wodsmith-start/src/components/competition-leaderboard-table.tsx b/apps/wodsmith-start/src/components/competition-leaderboard-table.tsx index 878970e94..ef2d7addc 100644 --- a/apps/wodsmith-start/src/components/competition-leaderboard-table.tsx +++ b/apps/wodsmith-start/src/components/competition-leaderboard-table.tsx @@ -683,7 +683,7 @@ export function CompetitionLeaderboardTable({ // Add placeholder spans for leading columns groups.push({ label: null, colSpan: leadingCols }) - let currentParentId: string | null | undefined = undefined + let currentParentId: string | null | undefined let currentSpan = 0 let currentName: string | null = null diff --git a/apps/wodsmith-start/src/components/online-competition-leaderboard-table.tsx b/apps/wodsmith-start/src/components/online-competition-leaderboard-table.tsx index 153b9d887..534cecf3b 100644 --- a/apps/wodsmith-start/src/components/online-competition-leaderboard-table.tsx +++ b/apps/wodsmith-start/src/components/online-competition-leaderboard-table.tsx @@ -871,7 +871,7 @@ export function OnlineCompetitionLeaderboardTable({ groups.push({ label: null, colSpan: leadingCols }) - let currentParentId: string | null | undefined = undefined + let currentParentId: string | null | undefined let currentSpan = 0 let currentName: string | null = null diff --git a/apps/wodsmith-start/src/components/series-leaderboard-table.tsx b/apps/wodsmith-start/src/components/series-leaderboard-table.tsx index 83e8e31c6..056726055 100644 --- a/apps/wodsmith-start/src/components/series-leaderboard-table.tsx +++ b/apps/wodsmith-start/src/components/series-leaderboard-table.tsx @@ -253,7 +253,7 @@ export function SeriesLeaderboardTable({