diff --git a/app/components/SideNav.tsx b/app/components/SideNav.tsx index 9dd7a8483..e9598ca10 100644 --- a/app/components/SideNav.tsx +++ b/app/components/SideNav.tsx @@ -92,7 +92,7 @@ export function ListLink({ overlayIconUrl?: string; user?: Pick; subtitle?: React.ReactNode; - badge?: string | null; + badge?: React.ReactNode; badgeVariant?: "default" | "warning"; }) { return ( @@ -123,7 +123,7 @@ export function ListLink({ {subtitle ? ( {subtitle} ) : null} - {badge ? ( + {typeof badge === "string" ? ( {badge} - ) : null} + ) : ( + badge + )} ) : null} diff --git a/app/components/layout/index.module.css b/app/components/layout/index.module.css index bd7d06cd0..603baecad 100644 --- a/app/components/layout/index.module.css +++ b/app/components/layout/index.module.css @@ -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; diff --git a/app/components/layout/index.tsx b/app/components/layout/index.tsx index dabbd1e3e..cad7c3d4e 100644 --- a/app/components/layout/index.tsx +++ b/app/components/layout/index.tsx @@ -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")} ) : 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 ( - - - {stream.peakXp} - - ) : stream.subtitle ? ( - stream.subtitle - ) : isMounted ? ( - formatDistanceToNow(startsAtDate, { - addSuffix: true, - language: i18n.language as LanguageCode, - }) - ) : ( - "" - ) - } - badge={ - isMounted && startsAtDate.getTime() < Date.now() - ? "LIVE" - : undefined - } - > - {stream.name} - + + {showUpcomingDivider ? ( +
+ {t("front:sideNav.streams.upcoming")} +
+ ) : null} + + + {stream.peakXp} + + ) : 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} + +
); })} @@ -483,3 +500,17 @@ function SideNavUserPanel() { ); } + +function streamTierBadge(stream: SidebarStream): React.ReactNode { + const tier = stream.tier ?? stream.tentativeTier; + if (!tier) return undefined; + + return ( +
+ +
+ ); +} diff --git a/app/features/core/streams/streams.server.ts b/app/features/core/streams/streams.server.ts index 7a3753118..7c5077b47 100644 --- a/app/features/core/streams/streams.server.ts +++ b/app/features/core/streams/streams.server.ts @@ -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), diff --git a/app/features/front-page/core/ShowcaseTournaments.server.ts b/app/features/front-page/core/ShowcaseTournaments.server.ts index 87fa768c4..b40f92214 100644 --- a/app/features/front-page/core/ShowcaseTournaments.server.ts +++ b/app/features/front-page/core/ShowcaseTournaments.server.ts @@ -26,6 +26,11 @@ interface ParticipationInfo { organizers: Set; } +export async function upcomingTournaments(): Promise { + const tournaments = await cachedTournaments(); + return tournaments.upcoming; +} + export async function frontPageTournamentsByUserId( userId: number | null, ): Promise { diff --git a/app/features/sendouq-streams/core/streams.server.ts b/app/features/sendouq-streams/core/streams.server.ts index 6511f0e94..3aa880783 100644 --- a/app/features/sendouq-streams/core/streams.server.ts +++ b/app/features/sendouq-streams/core/streams.server.ts @@ -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` diff --git a/app/features/sidebar/core/StreamRanking.ts b/app/features/sidebar/core/StreamRanking.ts index 3ed338a7d..10c53c358 100644 --- a/app/features/sidebar/core/StreamRanking.ts +++ b/app/features/sidebar/core/StreamRanking.ts @@ -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; diff --git a/app/features/sidebar/core/sidebar.server.ts b/app/features/sidebar/core/sidebar.server.ts index 6ec2c7561..6fcf3e71f 100644 --- a/app/features/sidebar/core/sidebar.server.ts +++ b/app/features/sidebar/core/sidebar.server.ts @@ -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 { async function combinedStreams(): Promise { 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 { 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 { }); } + 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); } diff --git a/locales/da/front.json b/locales/da/front.json index d7c797f82..3b3405431 100644 --- a/locales/da/front.json +++ b/locales/da/front.json @@ -27,6 +27,7 @@ "sideNav.friends.notLoggedIn": "", "sideNav.friends.noFriends": "", "sideNav.streams": "", + "sideNav.streams.upcoming": "", "sideNav.scrimVs": "", "sideNav.lookingForScrim": "", "mobileNav.menu": "", diff --git a/locales/de/front.json b/locales/de/front.json index d7c797f82..3b3405431 100644 --- a/locales/de/front.json +++ b/locales/de/front.json @@ -27,6 +27,7 @@ "sideNav.friends.notLoggedIn": "", "sideNav.friends.noFriends": "", "sideNav.streams": "", + "sideNav.streams.upcoming": "", "sideNav.scrimVs": "", "sideNav.lookingForScrim": "", "mobileNav.menu": "", diff --git a/locales/en/front.json b/locales/en/front.json index 3c68fb5b4..3adb85a58 100644 --- a/locales/en/front.json +++ b/locales/en/front.json @@ -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", diff --git a/locales/es-ES/front.json b/locales/es-ES/front.json index d7c797f82..3b3405431 100644 --- a/locales/es-ES/front.json +++ b/locales/es-ES/front.json @@ -27,6 +27,7 @@ "sideNav.friends.notLoggedIn": "", "sideNav.friends.noFriends": "", "sideNav.streams": "", + "sideNav.streams.upcoming": "", "sideNav.scrimVs": "", "sideNav.lookingForScrim": "", "mobileNav.menu": "", diff --git a/locales/es-US/front.json b/locales/es-US/front.json index d7c797f82..3b3405431 100644 --- a/locales/es-US/front.json +++ b/locales/es-US/front.json @@ -27,6 +27,7 @@ "sideNav.friends.notLoggedIn": "", "sideNav.friends.noFriends": "", "sideNav.streams": "", + "sideNav.streams.upcoming": "", "sideNav.scrimVs": "", "sideNav.lookingForScrim": "", "mobileNav.menu": "", diff --git a/locales/fr-CA/front.json b/locales/fr-CA/front.json index d7c797f82..3b3405431 100644 --- a/locales/fr-CA/front.json +++ b/locales/fr-CA/front.json @@ -27,6 +27,7 @@ "sideNav.friends.notLoggedIn": "", "sideNav.friends.noFriends": "", "sideNav.streams": "", + "sideNav.streams.upcoming": "", "sideNav.scrimVs": "", "sideNav.lookingForScrim": "", "mobileNav.menu": "", diff --git a/locales/fr-EU/front.json b/locales/fr-EU/front.json index 9311680d9..29c0e30ec 100644 --- a/locales/fr-EU/front.json +++ b/locales/fr-EU/front.json @@ -27,6 +27,7 @@ "sideNav.friends.notLoggedIn": "", "sideNav.friends.noFriends": "", "sideNav.streams": "", + "sideNav.streams.upcoming": "", "sideNav.scrimVs": "", "sideNav.lookingForScrim": "", "mobileNav.menu": "", diff --git a/locales/he/front.json b/locales/he/front.json index d7c797f82..3b3405431 100644 --- a/locales/he/front.json +++ b/locales/he/front.json @@ -27,6 +27,7 @@ "sideNav.friends.notLoggedIn": "", "sideNav.friends.noFriends": "", "sideNav.streams": "", + "sideNav.streams.upcoming": "", "sideNav.scrimVs": "", "sideNav.lookingForScrim": "", "mobileNav.menu": "", diff --git a/locales/it/front.json b/locales/it/front.json index 6051953f2..8c6e9d56e 100644 --- a/locales/it/front.json +++ b/locales/it/front.json @@ -27,6 +27,7 @@ "sideNav.friends.notLoggedIn": "", "sideNav.friends.noFriends": "", "sideNav.streams": "", + "sideNav.streams.upcoming": "", "sideNav.scrimVs": "", "sideNav.lookingForScrim": "", "mobileNav.menu": "", diff --git a/locales/ja/front.json b/locales/ja/front.json index d7c797f82..3b3405431 100644 --- a/locales/ja/front.json +++ b/locales/ja/front.json @@ -27,6 +27,7 @@ "sideNav.friends.notLoggedIn": "", "sideNav.friends.noFriends": "", "sideNav.streams": "", + "sideNav.streams.upcoming": "", "sideNav.scrimVs": "", "sideNav.lookingForScrim": "", "mobileNav.menu": "", diff --git a/locales/ko/front.json b/locales/ko/front.json index d7c797f82..3b3405431 100644 --- a/locales/ko/front.json +++ b/locales/ko/front.json @@ -27,6 +27,7 @@ "sideNav.friends.notLoggedIn": "", "sideNav.friends.noFriends": "", "sideNav.streams": "", + "sideNav.streams.upcoming": "", "sideNav.scrimVs": "", "sideNav.lookingForScrim": "", "mobileNav.menu": "", diff --git a/locales/nl/front.json b/locales/nl/front.json index d7c797f82..3b3405431 100644 --- a/locales/nl/front.json +++ b/locales/nl/front.json @@ -27,6 +27,7 @@ "sideNav.friends.notLoggedIn": "", "sideNav.friends.noFriends": "", "sideNav.streams": "", + "sideNav.streams.upcoming": "", "sideNav.scrimVs": "", "sideNav.lookingForScrim": "", "mobileNav.menu": "", diff --git a/locales/pl/front.json b/locales/pl/front.json index d7c797f82..3b3405431 100644 --- a/locales/pl/front.json +++ b/locales/pl/front.json @@ -27,6 +27,7 @@ "sideNav.friends.notLoggedIn": "", "sideNav.friends.noFriends": "", "sideNav.streams": "", + "sideNav.streams.upcoming": "", "sideNav.scrimVs": "", "sideNav.lookingForScrim": "", "mobileNav.menu": "", diff --git a/locales/pt-BR/front.json b/locales/pt-BR/front.json index d7c797f82..3b3405431 100644 --- a/locales/pt-BR/front.json +++ b/locales/pt-BR/front.json @@ -27,6 +27,7 @@ "sideNav.friends.notLoggedIn": "", "sideNav.friends.noFriends": "", "sideNav.streams": "", + "sideNav.streams.upcoming": "", "sideNav.scrimVs": "", "sideNav.lookingForScrim": "", "mobileNav.menu": "", diff --git a/locales/ru/front.json b/locales/ru/front.json index b8f74a426..c0b5706c1 100644 --- a/locales/ru/front.json +++ b/locales/ru/front.json @@ -27,6 +27,7 @@ "sideNav.friends.notLoggedIn": "", "sideNav.friends.noFriends": "", "sideNav.streams": "", + "sideNav.streams.upcoming": "", "sideNav.scrimVs": "", "sideNav.lookingForScrim": "", "mobileNav.menu": "", diff --git a/locales/zh/front.json b/locales/zh/front.json index d7c797f82..3b3405431 100644 --- a/locales/zh/front.json +++ b/locales/zh/front.json @@ -27,6 +27,7 @@ "sideNav.friends.notLoggedIn": "", "sideNav.friends.noFriends": "", "sideNav.streams": "", + "sideNav.streams.upcoming": "", "sideNav.scrimVs": "", "sideNav.lookingForScrim": "", "mobileNav.menu": "",