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);
+}