SQ streams to sidebar initial (also load sq streams from DB)

This commit is contained in:
Kalle 2026-01-26 20:35:00 +02:00
parent e497ff39bc
commit cf4e5b8de9
7 changed files with 163 additions and 31 deletions

View File

@ -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;

View File

@ -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<HTMLAnchorElement>) => void;
isActive?: boolean;
imageUrl?: string;
overlayIconUrl?: string;
user?: Pick<Tables["User"], "discordId" | "discordAvatar">;
subtitle?: string;
badge?: string;
@ -104,7 +106,16 @@ export function SideNavLink({
{user ? (
<Avatar user={user} size="xxsm" />
) : imageUrl ? (
<img src={imageUrl} alt="" className={styles.sideNavLinkImage} />
<div className={styles.sideNavLinkImageContainer}>
<img src={imageUrl} alt="" className={styles.sideNavLinkImage} />
{overlayIconUrl ? (
<img
src={overlayIconUrl}
alt=""
className={styles.sideNavLinkOverlayIcon}
/>
) : null}
</div>
) : null}
<div className={styles.sideNavLinkContent}>
<span className={styles.sideNavLinkTitle}>{children}</span>

View File

@ -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({
<SideNavHeader icon={<TwitchIcon />}>
{t("front:sideNav.streams")}
</SideNavHeader>
{streams.map((stream) => (
<SideNavLink
key={stream.id}
to={stream.url}
imageUrl={stream.imageUrl}
subtitle={stream.subtitle}
badge={
isMounted && stream.startsAt < Date.now() ? "LIVE" : undefined
}
>
{stream.name}
</SideNavLink>
))}
{streams.map((stream) => {
const startsAtDate = databaseTimestampToDate(stream.startsAt);
return (
<SideNavLink
key={stream.id}
to={stream.url}
imageUrl={stream.imageUrl}
overlayIconUrl={stream.overlayIconUrl}
subtitle={
isMounted
? formatDistanceToNow(startsAtDate, {
addSuffix: true,
language: i18n.language as LanguageCode,
})
: ""
}
badge={
isMounted && startsAtDate.getTime() < Date.now()
? "LIVE"
: undefined
}
>
{stream.name}
</SideNavLink>
);
})}
</SideNav>
<MobileNav sidebarData={data?.sidebar} />
<div className={styles.container}>

View File

@ -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<SidebarStream[]> {
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,
});
}

View File

@ -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")

View File

@ -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<SidebarStream[]> {
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<typeof t> => 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);
}

View File

@ -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<SidebarStream[]> {
const [tournamentStreams, sendouQStreams] = await Promise.all([
getLiveTournamentStreams(),
getSendouQSidebarStreams(),
]);
return [...tournamentStreams, ...sendouQStreams];
}
function resolveTournamentMatchStatus(userId: number) {
const tournament = RunningTournaments.getUserTournament(userId);
if (!tournament) return null;