From cf4e5b8de94c3d276ef13516a50bb1e5e2dbc339 Mon Sep 17 00:00:00 2001 From: Kalle <38327916+Sendouc@users.noreply.github.com> Date: Mon, 26 Jan 2026 20:35:00 +0200 Subject: [PATCH] SQ streams to sidebar initial (also load sq streams from DB) --- app/components/SideNav.module.css | 16 ++++ app/components/SideNav.tsx | 13 ++- app/components/layout/index.tsx | 44 ++++++--- app/features/core/streams/streams.server.ts | 4 +- .../QStreamsRepository.server.ts | 4 + .../sendouq-streams/core/streams.server.ts | 94 ++++++++++++++++--- app/features/sidebar/core/sidebar.server.ts | 19 +++- 7 files changed, 163 insertions(+), 31 deletions(-) diff --git a/app/components/SideNav.module.css b/app/components/SideNav.module.css index 2d6370975..6b3220ba9 100644 --- a/app/components/SideNav.module.css +++ b/app/components/SideNav.module.css @@ -255,6 +255,11 @@ } } +.sideNavLinkImageContainer { + position: relative; + flex-shrink: 0; +} + .sideNavLinkImage { width: 32px; height: 32px; @@ -263,6 +268,17 @@ flex-shrink: 0; } +.sideNavLinkOverlayIcon { + position: absolute; + bottom: -2px; + right: -2px; + width: 16px; + height: 16px; + border-radius: var(--radius-field); + background-color: var(--color-bg); + padding: 2px; +} + .sideNavLinkContent { display: flex; flex-direction: column; diff --git a/app/components/SideNav.tsx b/app/components/SideNav.tsx index 824d4cfe0..83be9f03c 100644 --- a/app/components/SideNav.tsx +++ b/app/components/SideNav.tsx @@ -79,6 +79,7 @@ export function SideNavLink({ onClick, isActive, imageUrl, + overlayIconUrl, user, subtitle, badge, @@ -89,6 +90,7 @@ export function SideNavLink({ onClick?: (event: React.MouseEvent) => void; isActive?: boolean; imageUrl?: string; + overlayIconUrl?: string; user?: Pick; subtitle?: string; badge?: string; @@ -104,7 +106,16 @@ export function SideNavLink({ {user ? ( ) : imageUrl ? ( - +
+ + {overlayIconUrl ? ( + + ) : null} +
) : null}
{children} diff --git a/app/components/layout/index.tsx b/app/components/layout/index.tsx index 5f53bcbb1..16d61ddf4 100644 --- a/app/components/layout/index.tsx +++ b/app/components/layout/index.tsx @@ -15,7 +15,9 @@ import { useTranslation } from "react-i18next"; import { Link, useFetcher, useLocation, useMatches } from "react-router"; import { useUser } from "~/features/auth/core/user"; import { useIsMounted } from "~/hooks/useIsMounted"; +import type { LanguageCode } from "~/modules/i18n/config"; import type { RootLoaderData } from "~/root"; +import { databaseTimestampToDate, formatDistanceToNow } from "~/utils/dates"; import type { Breadcrumb, SendouRouteHandle } from "~/utils/remix.server"; import { navIconUrl, SETTINGS_PAGE, userPage } from "~/utils/urls"; import { Avatar } from "../Avatar"; @@ -168,7 +170,7 @@ export function Layout({ data?.sidenavCollapsed ?? false, ); - const { t } = useTranslation(["front"]); + const { t, i18n } = useTranslation(["front"]); const { formatRelativeDate } = useTimeFormat(); const location = useLocation(); const navOffset = useNavOffset(); @@ -273,19 +275,33 @@ export function Layout({ }> {t("front:sideNav.streams")} - {streams.map((stream) => ( - - {stream.name} - - ))} + {streams.map((stream) => { + const startsAtDate = databaseTimestampToDate(stream.startsAt); + + return ( + + {stream.name} + + ); + })}
diff --git a/app/features/core/streams/streams.server.ts b/app/features/core/streams/streams.server.ts index 08b551376..202693b27 100644 --- a/app/features/core/streams/streams.server.ts +++ b/app/features/core/streams/streams.server.ts @@ -4,6 +4,7 @@ import { RunningTournaments } from "~/features/tournament-bracket/core/RunningTo import type { Tournament } from "~/features/tournament-bracket/core/Tournament"; import { Status } from "~/modules/brackets-model"; import { cache, ttl } from "~/utils/cache.server"; +import { dateToDatabaseTimestamp } from "~/utils/dates"; import { tournamentStreamsPage } from "~/utils/urls"; const FIVE_MINUTES = 5 * 60 * 1000; @@ -17,6 +18,7 @@ export type SidebarStream = { id: number; name: string; imageUrl: string; + overlayIconUrl?: string; url: string; subtitle: string; startsAt: number; @@ -38,7 +40,7 @@ export function getLiveTournamentStreams(): Promise { imageUrl: tournament.ctx.logoUrl, url: tournamentStreamsPage(tournament.ctx.id), subtitle: deriveCurrentRound(tournament), - startsAt: tournament.ctx.startTime.getTime(), + startsAt: dateToDatabaseTimestamp(tournament.ctx.startTime), tier: tournament.ctx.tier, }); } diff --git a/app/features/sendouq-streams/QStreamsRepository.server.ts b/app/features/sendouq-streams/QStreamsRepository.server.ts index e1ed6e4f9..98234cbd9 100644 --- a/app/features/sendouq-streams/QStreamsRepository.server.ts +++ b/app/features/sendouq-streams/QStreamsRepository.server.ts @@ -19,9 +19,13 @@ export function activeMatchPlayers() { ), ) .innerJoin("GroupMember", "GroupMember.groupId", "Group.id") + .innerJoin("LiveStream", "LiveStream.userId", "GroupMember.userId") .select(({ eb }) => [ "GroupMatch.id as groupMatchId", "GroupMatch.createdAt as groupMatchCreatedAt", + "LiveStream.twitch as streamTwitch", + "LiveStream.viewerCount as streamViewerCount", + "LiveStream.thumbnailUrl as streamThumbnailUrl", jsonObjectFrom( eb .selectFrom("User") diff --git a/app/features/sendouq-streams/core/streams.server.ts b/app/features/sendouq-streams/core/streams.server.ts index 289f903ca..60c48ce84 100644 --- a/app/features/sendouq-streams/core/streams.server.ts +++ b/app/features/sendouq-streams/core/streams.server.ts @@ -1,15 +1,17 @@ import cachified from "@epic-web/cachified"; +import * as R from "remeda"; +import type { SidebarStream } from "~/features/core/streams/streams.server"; import { cachedFullUserLeaderboard, type UserLeaderboardWithAdditionsItem, } from "~/features/leaderboards/core/leaderboards.server"; import * as Seasons from "~/features/mmr/core/Seasons"; -import { TIERS } from "~/features/mmr/mmr-constants"; +import { TIERS, type TierName } from "~/features/mmr/mmr-constants"; +import { SendouQ } from "~/features/sendouq/core/SendouQ.server"; import * as QStreamsRepository from "~/features/sendouq-streams/QStreamsRepository.server"; -import { getStreams } from "~/modules/twitch"; -import type { MappedStream } from "~/modules/twitch/streams"; import { cache, IN_MILLISECONDS, ttl } from "~/utils/cache.server"; import { logger } from "~/utils/logger"; +import { navIconUrl, SENDOUQ_STREAMS_PAGE, tierImageUrl } from "~/utils/urls"; import { SENDOUQ_STREAMS_KEY } from "../q-streams-constants"; export function cachedStreams() { @@ -22,7 +24,6 @@ export function cachedStreams() { async getFreshValue() { return streamedMatches({ matchPlayers: await QStreamsRepository.activeMatchPlayers(), - streams: await getStreams(), leaderboard: await cachedFullUserLeaderboard(season.nth), }).sort((a, b) => { const aTierIndex = TIERS.findIndex( @@ -66,19 +67,13 @@ export function refreshStreamsCache() { function streamedMatches({ matchPlayers, - streams, leaderboard, }: { matchPlayers: QStreamsRepository.ActiveMatchPlayersItem[]; - streams: MappedStream[]; leaderboard: UserLeaderboardWithAdditionsItem[]; }) { return matchPlayers.flatMap((player) => { - const stream = streams.find( - (stream) => stream.twitchUserName === player.user?.twitch?.toLowerCase(), - ); - - if (!stream) { + if (!player.streamTwitch) { return []; } @@ -87,7 +82,11 @@ function streamedMatches({ ); return { - stream, + stream: { + thumbnailUrl: player.streamThumbnailUrl, + twitchUserName: player.streamTwitch, + viewerCount: player.streamViewerCount, + }, match: { id: player.groupMatchId, createdAt: player.groupMatchCreatedAt, @@ -101,3 +100,74 @@ function streamedMatches({ }; }); } + +export async function getSendouQSidebarStreams(): Promise { + const streams = await cachedStreams(); + + const matchIdToStream = R.groupBy(streams, (s) => s.match.id); + + const sidebarStreams: SidebarStream[] = []; + + for (const [matchIdStr, matchStreams] of Object.entries(matchIdToStream)) { + const matchId = Number(matchIdStr); + const firstStream = matchStreams[0]; + + const matchGroups = SendouQ.groups.filter((g) => g.matchId === matchId); + const averageTier = calculateAverageTierForMatch(matchGroups); + + sidebarStreams.push({ + id: -matchId, + name: `Match #${matchId}`, + imageUrl: averageTier + ? `${tierImageUrl(averageTier.name)}.png` + : `${navIconUrl("sendouq")}.png`, + overlayIconUrl: averageTier ? `${navIconUrl("sendouq")}.png` : undefined, + url: SENDOUQ_STREAMS_PAGE, + subtitle: averageTier + ? `${averageTier.name}${averageTier.isPlus ? "+" : ""}` + : "", + startsAt: firstStream.match.createdAt, + tier: null, + }); + } + + return sidebarStreams.sort((a, b) => { + const aTierIndex = getTierIndexFromSubtitle(a.subtitle); + const bTierIndex = getTierIndexFromSubtitle(b.subtitle); + return aTierIndex - bTierIndex; + }); +} + +function calculateAverageTierForMatch( + matchGroups: (typeof SendouQ.groups)[number][], +): { name: TierName; isPlus: boolean } | null { + if (matchGroups.length !== 2) return null; + + const allTiers = matchGroups + .map((g) => g.tier) + .filter((t): t is NonNullable => t !== null); + + if (allTiers.length !== 2) return null; + + const tierIndexSum = allTiers.reduce((sum, tier) => { + const baseIndex = TIERS.findIndex((t) => t.name === tier.name); + const indexWithPlus = baseIndex * 2 + (tier.isPlus ? 0 : 1); + return sum + indexWithPlus; + }, 0); + + const averageIndex = tierIndexSum / 2; + const baseTierIndex = Math.floor(averageIndex / 2); + const isPlus = averageIndex % 2 < 1; + + const tierName = TIERS[baseTierIndex]?.name ?? "IRON"; + + return { name: tierName, isPlus }; +} + +function getTierIndexFromSubtitle(subtitle: string): number { + const tierName = subtitle.replace("+", ""); + const isPlus = subtitle.endsWith("+"); + const baseIndex = TIERS.findIndex((t) => t.name === tierName); + if (baseIndex === -1) return 999; + return baseIndex * 2 + (isPlus ? 0 : 1); +} diff --git a/app/features/sidebar/core/sidebar.server.ts b/app/features/sidebar/core/sidebar.server.ts index 6bc0bd176..94cb61cd8 100644 --- a/app/features/sidebar/core/sidebar.server.ts +++ b/app/features/sidebar/core/sidebar.server.ts @@ -1,10 +1,14 @@ import { href } from "react-router"; -import { getLiveTournamentStreams } from "~/features/core/streams/streams.server"; +import { + getLiveTournamentStreams, + type SidebarStream, +} 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"; import { SendouQ } from "~/features/sendouq/core/SendouQ.server"; import { FULL_GROUP_SIZE } from "~/features/sendouq/q-constants"; +import { getSendouQSidebarStreams } from "~/features/sendouq-streams/core/streams.server"; import { RunningTournaments } from "~/features/tournament-bracket/core/RunningTournaments.server"; import { navIconUrl, @@ -51,7 +55,7 @@ export async function resolveSidebarData(userId: number | null) { logoUrl: string | null; } | null, friends: [] as SidebarFriend[], - streams: await getLiveTournamentStreams(), + streams: await combinedStreams(), }; } @@ -109,10 +113,19 @@ export async function resolveSidebarData(userId: number | null) { : null, tournamentMatchStatus, friends, - streams: await getLiveTournamentStreams(), + streams: await combinedStreams(), }; } +async function combinedStreams(): Promise { + const [tournamentStreams, sendouQStreams] = await Promise.all([ + getLiveTournamentStreams(), + getSendouQSidebarStreams(), + ]); + + return [...tournamentStreams, ...sendouQStreams]; +} + function resolveTournamentMatchStatus(userId: number) { const tournament = RunningTournaments.getUserTournament(userId); if (!tournament) return null;