From 96c62e547ba5fcd0e2cd27ed2a91403db8564af7 Mon Sep 17 00:00:00 2001 From: Kalle <38327916+Sendouc@users.noreply.github.com> Date: Sun, 25 Jan 2026 11:46:37 +0200 Subject: [PATCH] Real streams initial --- app/components/MobileNav.tsx | 7 +- app/components/layout/index.tsx | 8 +- app/features/core/streams/streams.server.ts | 83 +++++++++++++++++++ app/features/sidebar/routes/sidebar.ts | 31 +------ .../core/RunningTournaments.server.ts | 4 + .../core/Tournament.server.ts | 22 +++++ .../tournament/TournamentRepository.server.ts | 39 +++++++++ 7 files changed, 160 insertions(+), 34 deletions(-) create mode 100644 app/features/core/streams/streams.server.ts diff --git a/app/components/MobileNav.tsx b/app/components/MobileNav.tsx index 1f6a3dbcf..6b8165995 100644 --- a/app/components/MobileNav.tsx +++ b/app/components/MobileNav.tsx @@ -7,6 +7,7 @@ import { useTranslation } from "react-i18next"; import { href, Link } from "react-router"; import { useUser } from "~/features/auth/core/user"; import type { loader as sidebarLoader } from "~/features/sidebar/routes/sidebar"; +import { useIsMounted } from "~/hooks/useIsMounted"; import type { MainWeaponId } from "~/modules/in-game-lists/types"; import { weaponCategories } from "~/modules/in-game-lists/weapon-ids"; import { mySlugify, navIconUrl, SETTINGS_PAGE, userPage } from "~/utils/urls"; @@ -271,10 +272,8 @@ function MenuOverlay({ {stream.subtitle} ) : null} - {stream.badge ? ( - - {stream.badge} - + {stream.startsAt < Date.now() ? ( + LIVE ) : null} diff --git a/app/components/layout/index.tsx b/app/components/layout/index.tsx index d1d76948c..8ad9248b8 100644 --- a/app/components/layout/index.tsx +++ b/app/components/layout/index.tsx @@ -15,6 +15,7 @@ import { useTranslation } from "react-i18next"; import { Link, useFetcher, useLocation, useMatches } from "react-router"; import { useUser } from "~/features/auth/core/user"; import type { loader as sidebarLoader } from "~/features/sidebar/routes/sidebar"; +import { useIsMounted } from "~/hooks/useIsMounted"; import type { RootLoaderData } from "~/root"; import type { Breadcrumb, SendouRouteHandle } from "~/utils/remix.server"; import { navIconUrl, SETTINGS_PAGE, userPage } from "~/utils/urls"; @@ -185,6 +186,7 @@ export function Layout({ const location = useLocation(); const sidebarData = useSidebarData(); const navOffset = useNavOffset(); + const isMounted = useIsMounted(); const events = sidebarData?.events ?? []; const matchStatus = sidebarData?.matchStatus; @@ -295,10 +297,12 @@ export function Layout({ {streams.map((stream) => ( {stream.name} diff --git a/app/features/core/streams/streams.server.ts b/app/features/core/streams/streams.server.ts new file mode 100644 index 000000000..08b551376 --- /dev/null +++ b/app/features/core/streams/streams.server.ts @@ -0,0 +1,83 @@ +import { cachified } from "@epic-web/cachified"; +import type { TournamentTierNumber } from "~/features/tournament/core/tiering"; +import { RunningTournaments } from "~/features/tournament-bracket/core/RunningTournaments.server"; +import type { Tournament } from "~/features/tournament-bracket/core/Tournament"; +import { Status } from "~/modules/brackets-model"; +import { cache, ttl } from "~/utils/cache.server"; +import { tournamentStreamsPage } from "~/utils/urls"; + +const FIVE_MINUTES = 5 * 60 * 1000; +const MAX_STREAMS = 5; + +export function clearLiveStreamsCache() { + cache.delete("live-tournament-streams"); +} + +export type SidebarStream = { + id: number; + name: string; + imageUrl: string; + url: string; + subtitle: string; + startsAt: number; + tier: TournamentTierNumber | null; +}; + +export function getLiveTournamentStreams(): Promise { + return cachified({ + key: "live-tournament-streams", + cache, + ttl: ttl(FIVE_MINUTES), + async getFreshValue() { + const streams: SidebarStream[] = []; + + for (const tournament of RunningTournaments.all) { + streams.push({ + id: tournament.ctx.id, + name: tournament.ctx.name, + imageUrl: tournament.ctx.logoUrl, + url: tournamentStreamsPage(tournament.ctx.id), + subtitle: deriveCurrentRound(tournament), + startsAt: tournament.ctx.startTime.getTime(), + tier: tournament.ctx.tier, + }); + } + + return streams.sort(sortByTierAscending).slice(0, MAX_STREAMS); + }, + }); +} + +// xxx: this could be moved to Tournament class +// xxx: not always reporting furthest round +function deriveCurrentRound(tournament: Tournament): string { + for (const bracket of tournament.brackets) { + if (bracket.preview) continue; + + for (const match of bracket.data.match) { + const isActive = + match.status === Status.Ready || match.status === Status.Running; + const hasParticipants = match.opponent1 && match.opponent2; + const isNotFinished = + !match.opponent1?.result && !match.opponent2?.result; + + if (isActive && hasParticipants && isNotFinished) { + const context = tournament.matchContextNamesById(match.id); + if (context?.roundNameWithoutMatchIdentifier) { + return context.roundNameWithoutMatchIdentifier; + } + } + } + + return bracket.name; + } + + return ""; +} + +function sortByTierAscending(a: SidebarStream, b: SidebarStream): number { + if (a.tier === null && b.tier === null) return 0; + if (a.tier === null) return 1; + if (b.tier === null) return -1; + return a.tier - b.tier; +} diff --git a/app/features/sidebar/routes/sidebar.ts b/app/features/sidebar/routes/sidebar.ts index 8f0fe6e25..0571c1344 100644 --- a/app/features/sidebar/routes/sidebar.ts +++ b/app/features/sidebar/routes/sidebar.ts @@ -1,5 +1,6 @@ import { href, type LoaderFunctionArgs } from "react-router"; import { getUser } from "~/features/auth/core/user.server"; +import { getLiveTournamentStreams } from "~/features/core/streams/streams.server"; import * as FriendRepository from "~/features/friends/FriendRepository.server"; import * as ShowcaseTournaments from "~/features/front-page/core/ShowcaseTournaments.server"; import * as ScrimPostRepository from "~/features/scrims/ScrimPostRepository.server"; @@ -40,7 +41,7 @@ export const loader = async (_args: LoaderFunctionArgs) => { logoUrl: string | null; } | null, friends: [] as ReturnType, - streams: getMockStreams(), + streams: await getLiveTournamentStreams(), }; } @@ -98,7 +99,7 @@ export const loader = async (_args: LoaderFunctionArgs) => { : null, tournamentMatchStatus, friends, - streams: getMockStreams(), + streams: await getLiveTournamentStreams(), }; }; @@ -220,29 +221,3 @@ function resolveFriends(friendsWithActivity: FriendWithActivity[]) { return result; } - -function getMockStreams() { - return [ - { - id: 3, - name: "Paddling Pool 252", - imageUrl: "https://i.pravatar.cc/150?u=stream1", - subtitle: "Losers Finals", - badge: "LIVE", - }, - { - id: 1, - name: "Splash Go!", - imageUrl: - "https://liquipedia.net/commons/images/7/73/Splash_Go_allmode.png", - subtitle: "Tomorrow, 9:00 AM", - }, - { - id: 2, - name: "Area Cup", - imageUrl: - "https://pbs.twimg.com/profile_images/1830601967821017088/4SDZVKdj_400x400.jpg", - subtitle: "Saturday, 10 AM", - }, - ]; -} diff --git a/app/features/tournament-bracket/core/RunningTournaments.server.ts b/app/features/tournament-bracket/core/RunningTournaments.server.ts index 576c15478..9859c8168 100644 --- a/app/features/tournament-bracket/core/RunningTournaments.server.ts +++ b/app/features/tournament-bracket/core/RunningTournaments.server.ts @@ -34,6 +34,10 @@ class RunningTournamentsRegistry { return this.tournaments.get(tournamentId); } + get all() { + return Array.from(this.tournaments.values()); + } + has(tournamentId: number) { return this.tournaments.has(tournamentId); } diff --git a/app/features/tournament-bracket/core/Tournament.server.ts b/app/features/tournament-bracket/core/Tournament.server.ts index 417dbe39e..8c899a709 100644 --- a/app/features/tournament-bracket/core/Tournament.server.ts +++ b/app/features/tournament-bracket/core/Tournament.server.ts @@ -1,3 +1,4 @@ +import { clearLiveStreamsCache } from "~/features/core/streams/streams.server"; import * as TournamentRepository from "~/features/tournament/TournamentRepository.server"; import { getTentativeTier } from "~/features/tournament-organization/core/tentativeTiers.server"; import type { TournamentManagerDataSet } from "~/modules/brackets-manager/types"; @@ -139,10 +140,31 @@ export function clearAllTournamentDataCache() { function syncTournamentToRegistry(tournament: Tournament) { const isRunning = tournament.hasStarted && !tournament.everyBracketOver; + const wasInRegistry = RunningTournaments.has(tournament.ctx.id); if (isRunning) { RunningTournaments.add(tournament); + if (!wasInRegistry) { + clearLiveStreamsCache(); + } } else { + if (wasInRegistry) { + clearLiveStreamsCache(); + } RunningTournaments.remove(tournament.ctx.id); } } + +async function primeRunningTournamentsCache() { + const tournamentIds = await TournamentRepository.findRunningTournamentIds(); + + for (const tournamentId of tournamentIds) { + const data = await tournamentData({ user: undefined, tournamentId }); + if (!data) continue; + + const tournament = new Tournament({ ...data, simulateBrackets: false }); + syncTournamentToRegistry(tournament); + } +} + +await primeRunningTournamentsCache(); diff --git a/app/features/tournament/TournamentRepository.server.ts b/app/features/tournament/TournamentRepository.server.ts index e8c5632f6..a40713cc8 100644 --- a/app/features/tournament/TournamentRepository.server.ts +++ b/app/features/tournament/TournamentRepository.server.ts @@ -1,3 +1,4 @@ +import { sub } from "date-fns"; import { type Insertable, type NotNull, sql, type Transaction } from "kysely"; import { jsonArrayFrom, jsonObjectFrom } from "kysely/helpers/sqlite"; import { db } from "~/db/sql"; @@ -1252,3 +1253,41 @@ export function updateTournamentTier({ .where("id", "=", tournamentId) .execute(); } + +export async function findRunningTournamentIds() { + const now = new Date(); + const oneDayAgo = sub(now, { days: 1 }); + + const rows = await db + .selectFrom("Tournament") + .innerJoin("CalendarEvent", "Tournament.id", "CalendarEvent.tournamentId") + .innerJoin( + "CalendarEventDate", + "CalendarEvent.id", + "CalendarEventDate.eventId", + ) + .select("Tournament.id") + .where("Tournament.isFinalized", "=", 0) + .where("CalendarEventDate.startTime", "<", dateToDatabaseTimestamp(now)) + .where( + "CalendarEventDate.startTime", + ">", + dateToDatabaseTimestamp(oneDayAgo), + ) + .where((eb) => + eb.exists( + eb + .selectFrom("TournamentStage") + .select("TournamentStage.id") + .whereRef("TournamentStage.tournamentId", "=", "Tournament.id"), + ), + ) + .where( + sql`json_extract("Tournament"."settings", '$.isTest')`, + "is not", + 1, + ) + .execute(); + + return rows.map((row) => row.id); +}