Show upcoming events on streams section

This commit is contained in:
Kalle 2026-03-07 17:23:19 +02:00
parent baa287fd78
commit 8d2f57f8c2
24 changed files with 168 additions and 46 deletions

View File

@ -92,7 +92,7 @@ export function ListLink({
overlayIconUrl?: string;
user?: Pick<Tables["User"], "discordId" | "discordAvatar">;
subtitle?: React.ReactNode;
badge?: string | null;
badge?: React.ReactNode;
badgeVariant?: "default" | "warning";
}) {
return (
@ -123,7 +123,7 @@ export function ListLink({
{subtitle ? (
<span className={styles.listLinkSubtitle}>{subtitle}</span>
) : null}
{badge ? (
{typeof badge === "string" ? (
<span
className={clsx(styles.listLinkBadge, {
[styles.listLinkBadgeWarning]: badgeVariant === "warning",
@ -131,7 +131,9 @@ export function ListLink({
>
{badge}
</span>
) : null}
) : (
badge
)}
</div>
) : null}
</div>

View File

@ -14,6 +14,11 @@
height: 14px;
}
.streamTierBadge {
margin-left: auto;
flex-shrink: 0;
}
.header {
display: flex;
width: 100%;
@ -130,6 +135,24 @@
font-style: italic;
}
.streamUpcomingDivider {
display: flex;
align-items: center;
gap: var(--s-2);
padding: var(--s-1) var(--s-2);
font-size: var(--font-3xs);
color: var(--color-text-high);
text-transform: uppercase;
letter-spacing: 0.05em;
}
.streamUpcomingDivider::before,
.streamUpcomingDivider::after {
content: "";
flex: 1;
border-top: 1px solid var(--color-border);
}
.viewAllLink {
display: flex;
align-items: center;

View File

@ -15,6 +15,7 @@ import { Flipped, Flipper } from "react-flip-toolkit";
import { useTranslation } from "react-i18next";
import { Link, useFetcher, useLocation, useMatches } from "react-router";
import { useUser } from "~/features/auth/core/user";
import type { SidebarStream } from "~/features/core/streams/streams.server";
import { FriendMenu } from "~/features/friends/components/FriendMenu";
import { useIsMounted } from "~/hooks/useIsMounted";
import type { LanguageCode } from "~/modules/i18n/config";
@ -35,6 +36,7 @@ import { TwitchIcon } from "../icons/Twitch";
import { MobileNav } from "../MobileNav";
import { ListLink, SideNav, SideNavFooter, SideNavHeader } from "../SideNav";
import sideNavStyles from "../SideNav.module.css";
import { TierPill } from "../TierPill";
import { Footer } from "./Footer";
import styles from "./index.module.css";
import { LogInButtonContainer } from "./LogInButtonContainer";
@ -255,44 +257,59 @@ export function Layout({
{t("front:sideNav.noStreams")}
</div>
) : null}
{streams.map((stream) => {
{streams.map((stream, i) => {
const startsAtDate = databaseTimestampToDate(stream.startsAt);
const isUpcoming = startsAtDate.getTime() > Date.now();
const prevStream = streams.at(i - 1);
const prevIsLive =
prevStream &&
databaseTimestampToDate(prevStream.startsAt).getTime() <=
Date.now();
const showUpcomingDivider = isMounted && isUpcoming && prevIsLive;
return (
<ListLink
key={stream.id}
to={stream.url}
imageUrl={stream.imageUrl}
overlayIconUrl={stream.overlayIconUrl}
subtitle={
stream.peakXp ? (
<span className={styles.streamXpSubtitle}>
<img
src={`${navIconUrl("xsearch")}.png`}
alt=""
className={styles.streamXpIcon}
/>
{stream.peakXp}
</span>
) : stream.subtitle ? (
stream.subtitle
) : isMounted ? (
formatDistanceToNow(startsAtDate, {
addSuffix: true,
language: i18n.language as LanguageCode,
})
) : (
""
)
}
badge={
isMounted && startsAtDate.getTime() < Date.now()
? "LIVE"
: undefined
}
>
{stream.name}
</ListLink>
<React.Fragment key={stream.id}>
{showUpcomingDivider ? (
<div className={styles.streamUpcomingDivider}>
{t("front:sideNav.streams.upcoming")}
</div>
) : null}
<ListLink
to={stream.url}
imageUrl={stream.imageUrl}
overlayIconUrl={stream.overlayIconUrl}
subtitle={
stream.peakXp ? (
<span className={styles.streamXpSubtitle}>
<img
src={`${navIconUrl("xsearch")}.png`}
alt=""
className={styles.streamXpIcon}
/>
{stream.peakXp}
</span>
) : stream.subtitle ? (
stream.subtitle
) : isMounted ? (
isUpcoming ? (
formatRelativeDate(stream.startsAt)
) : (
formatDistanceToNow(startsAtDate, {
addSuffix: true,
language: i18n.language as LanguageCode,
})
)
) : (
""
)
}
badge={
isMounted && !isUpcoming ? "LIVE" : streamTierBadge(stream)
}
>
{stream.name}
</ListLink>
</React.Fragment>
);
})}
</SideNav>
@ -483,3 +500,17 @@ function SideNavUserPanel() {
</>
);
}
function streamTierBadge(stream: SidebarStream): React.ReactNode {
const tier = stream.tier ?? stream.tentativeTier;
if (!tier) return undefined;
return (
<div className={styles.streamTierBadge}>
<TierPill
tier={tier}
isTentative={!stream.tier && !!stream.tentativeTier}
/>
</div>
);
}

View File

@ -13,7 +13,7 @@ export function clearCombinedStreamsCache() {
}
export type SidebarStream = {
id: number;
id: string;
name: string;
imageUrl: string;
overlayIconUrl?: string;
@ -21,6 +21,7 @@ export type SidebarStream = {
subtitle: string;
startsAt: number;
tier: TournamentTierNumber | null;
tentativeTier?: number;
peakXp?: number;
twitchUsername?: string;
};
@ -30,7 +31,7 @@ export function getLiveTournamentStreams(): SidebarStream[] {
for (const tournament of RunningTournaments.all) {
streams.push({
id: tournament.ctx.id,
id: `tournament-${tournament.ctx.id}`,
name: tournament.ctx.name,
imageUrl: tournament.ctx.logoUrl,
url: tournamentStreamsPage(tournament.ctx.id),

View File

@ -26,6 +26,11 @@ interface ParticipationInfo {
organizers: Set<ShowcaseCalendarEvent["id"]>;
}
export async function upcomingTournaments(): Promise<ShowcaseCalendarEvent[]> {
const tournaments = await cachedTournaments();
return tournaments.upcoming;
}
export async function frontPageTournamentsByUserId(
userId: number | null,
): Promise<ShowcaseTournamentCollection> {

View File

@ -133,7 +133,7 @@ export async function getSendouQSidebarStreams(): Promise<
entries.push({
sidebarStream: {
id: -matchId,
id: `sendouq-${matchId}`,
name: `Match #${matchId}`,
imageUrl: averageTier
? `${tierImageUrl(averageTier.name)}.png`

View File

@ -8,10 +8,21 @@ export function rank(
streams: RankedStream[],
maxStreams: number,
): SidebarStream[] {
return streams
const now = Math.floor(Date.now() / 1000);
const selected = streams
.sort((a, b) => a.score - b.score || a.stream.startsAt - b.stream.startsAt)
.slice(0, maxStreams)
.map((rs) => rs.stream);
.slice(0, maxStreams);
const live = selected
.filter((rs) => rs.stream.startsAt <= now)
.sort((a, b) => a.score - b.score);
const upcoming = selected
.filter((rs) => rs.stream.startsAt > now)
.sort((a, b) => a.stream.startsAt - b.stream.startsAt);
return [...live, ...upcoming].map((rs) => rs.stream);
}
export function tournamentTierToScore(
@ -20,6 +31,10 @@ export function tournamentTierToScore(
return tier ?? 9;
}
export function upcomingTournamentTierToScore(tier: number): number {
return Math.min(9, tier + 4);
}
export function sendouQTierToScore(tier: {
name: TierName;
isPlus: boolean;

View File

@ -1,4 +1,5 @@
import { cachified } from "@epic-web/cachified";
import { addDays } from "date-fns";
import { href } from "react-router";
import * as R from "remeda";
import type { ShowcaseCalendarEvent } from "~/features/calendar/calendar-types";
@ -13,7 +14,9 @@ import * as ShowcaseTournaments from "~/features/front-page/core/ShowcaseTournam
import * as LiveStreamRepository from "~/features/live-streams/LiveStreamRepository.server";
import * as ScrimPostRepository from "~/features/scrims/ScrimPostRepository.server";
import { getSendouQSidebarStreams } from "~/features/sendouq-streams/core/streams.server";
import type { TournamentTierNumber } from "~/features/tournament/core/tiering";
import { cache, ttl } from "~/utils/cache.server";
import { dateToDatabaseTimestamp } from "~/utils/dates";
import {
BLANK_IMAGE_URL,
discordAvatarUrl,
@ -47,6 +50,7 @@ export type SidebarFriend = {
const MAX_EVENTS_VISIBLE = 5;
const MAX_FRIENDS_VISIBLE = 4;
const MAX_STREAMS_VISIBLE = 5;
const UPCOMING_TOURNAMENT_WINDOW_DAYS = 3;
const SENDOUQ_QUOTA = 2;
const TOURNAMENT_SUB_QUOTA = 2;
@ -130,9 +134,10 @@ function combinedStreamsCached(): Promise<SidebarStream[]> {
async function combinedStreams(): Promise<SidebarStream[]> {
const tournamentStreams = getLiveTournamentStreams();
const [sendouQEntries, xRankRows] = await Promise.all([
const [sendouQEntries, xRankRows, upcomingTournaments] = await Promise.all([
getSendouQSidebarStreams(),
LiveStreamRepository.findXRankStreams(),
ShowcaseTournaments.upcomingTournaments(),
]);
const seenUsernames = new Set(
@ -176,7 +181,7 @@ async function combinedStreams(): Promise<SidebarStream[]> {
ranked.push({
stream: {
id: row.id,
id: `xrank-${row.id}`,
name: row.username,
imageUrl: row.discordAvatar
? discordAvatarUrl({
@ -197,6 +202,30 @@ async function combinedStreams(): Promise<SidebarStream[]> {
});
}
const nowTimestamp = dateToDatabaseTimestamp(new Date());
const threeDaysFromNow = dateToDatabaseTimestamp(addDays(new Date(), UPCOMING_TOURNAMENT_WINDOW_DAYS));
for (const event of upcomingTournaments) {
const effectiveTier = event.tier ?? event.tentativeTier;
if (effectiveTier === null) continue;
if (event.startTime < nowTimestamp) continue;
if (event.startTime > threeDaysFromNow) continue;
if (event.hidden) continue;
ranked.push({
stream: {
id: `upcoming-${event.id}`,
name: event.name,
imageUrl: event.logoUrl ?? BLANK_IMAGE_URL,
url: event.url,
subtitle: "",
startsAt: event.startTime,
tier: (event.tier as TournamentTierNumber) ?? null,
tentativeTier: event.tentativeTier ?? undefined,
},
score: StreamRanking.upcomingTournamentTierToScore(effectiveTier),
});
}
return StreamRanking.rank(ranked, MAX_STREAMS_VISIBLE);
}

View File

@ -27,6 +27,7 @@
"sideNav.friends.notLoggedIn": "",
"sideNav.friends.noFriends": "",
"sideNav.streams": "",
"sideNav.streams.upcoming": "",
"sideNav.scrimVs": "",
"sideNav.lookingForScrim": "",
"mobileNav.menu": "",

View File

@ -27,6 +27,7 @@
"sideNav.friends.notLoggedIn": "",
"sideNav.friends.noFriends": "",
"sideNav.streams": "",
"sideNav.streams.upcoming": "",
"sideNav.scrimVs": "",
"sideNav.lookingForScrim": "",
"mobileNav.menu": "",

View File

@ -27,6 +27,7 @@
"sideNav.friends.notLoggedIn": "Log in to follow your friends' activity",
"sideNav.friends.noFriends": "Add friends to see their activity here",
"sideNav.streams": "Streams",
"sideNav.streams.upcoming": "Upcoming",
"sideNav.scrimVs": "vs. {{opponent}}",
"sideNav.lookingForScrim": "Looking for scrim",
"mobileNav.menu": "Menu",

View File

@ -27,6 +27,7 @@
"sideNav.friends.notLoggedIn": "",
"sideNav.friends.noFriends": "",
"sideNav.streams": "",
"sideNav.streams.upcoming": "",
"sideNav.scrimVs": "",
"sideNav.lookingForScrim": "",
"mobileNav.menu": "",

View File

@ -27,6 +27,7 @@
"sideNav.friends.notLoggedIn": "",
"sideNav.friends.noFriends": "",
"sideNav.streams": "",
"sideNav.streams.upcoming": "",
"sideNav.scrimVs": "",
"sideNav.lookingForScrim": "",
"mobileNav.menu": "",

View File

@ -27,6 +27,7 @@
"sideNav.friends.notLoggedIn": "",
"sideNav.friends.noFriends": "",
"sideNav.streams": "",
"sideNav.streams.upcoming": "",
"sideNav.scrimVs": "",
"sideNav.lookingForScrim": "",
"mobileNav.menu": "",

View File

@ -27,6 +27,7 @@
"sideNav.friends.notLoggedIn": "",
"sideNav.friends.noFriends": "",
"sideNav.streams": "",
"sideNav.streams.upcoming": "",
"sideNav.scrimVs": "",
"sideNav.lookingForScrim": "",
"mobileNav.menu": "",

View File

@ -27,6 +27,7 @@
"sideNav.friends.notLoggedIn": "",
"sideNav.friends.noFriends": "",
"sideNav.streams": "",
"sideNav.streams.upcoming": "",
"sideNav.scrimVs": "",
"sideNav.lookingForScrim": "",
"mobileNav.menu": "",

View File

@ -27,6 +27,7 @@
"sideNav.friends.notLoggedIn": "",
"sideNav.friends.noFriends": "",
"sideNav.streams": "",
"sideNav.streams.upcoming": "",
"sideNav.scrimVs": "",
"sideNav.lookingForScrim": "",
"mobileNav.menu": "",

View File

@ -27,6 +27,7 @@
"sideNav.friends.notLoggedIn": "",
"sideNav.friends.noFriends": "",
"sideNav.streams": "",
"sideNav.streams.upcoming": "",
"sideNav.scrimVs": "",
"sideNav.lookingForScrim": "",
"mobileNav.menu": "",

View File

@ -27,6 +27,7 @@
"sideNav.friends.notLoggedIn": "",
"sideNav.friends.noFriends": "",
"sideNav.streams": "",
"sideNav.streams.upcoming": "",
"sideNav.scrimVs": "",
"sideNav.lookingForScrim": "",
"mobileNav.menu": "",

View File

@ -27,6 +27,7 @@
"sideNav.friends.notLoggedIn": "",
"sideNav.friends.noFriends": "",
"sideNav.streams": "",
"sideNav.streams.upcoming": "",
"sideNav.scrimVs": "",
"sideNav.lookingForScrim": "",
"mobileNav.menu": "",

View File

@ -27,6 +27,7 @@
"sideNav.friends.notLoggedIn": "",
"sideNav.friends.noFriends": "",
"sideNav.streams": "",
"sideNav.streams.upcoming": "",
"sideNav.scrimVs": "",
"sideNav.lookingForScrim": "",
"mobileNav.menu": "",

View File

@ -27,6 +27,7 @@
"sideNav.friends.notLoggedIn": "",
"sideNav.friends.noFriends": "",
"sideNav.streams": "",
"sideNav.streams.upcoming": "",
"sideNav.scrimVs": "",
"sideNav.lookingForScrim": "",
"mobileNav.menu": "",

View File

@ -27,6 +27,7 @@
"sideNav.friends.notLoggedIn": "",
"sideNav.friends.noFriends": "",
"sideNav.streams": "",
"sideNav.streams.upcoming": "",
"sideNav.scrimVs": "",
"sideNav.lookingForScrim": "",
"mobileNav.menu": "",

View File

@ -27,6 +27,7 @@
"sideNav.friends.notLoggedIn": "",
"sideNav.friends.noFriends": "",
"sideNav.streams": "",
"sideNav.streams.upcoming": "",
"sideNav.scrimVs": "",
"sideNav.lookingForScrim": "",
"mobileNav.menu": "",