From 978eaf0d357ac12180892d4eb9865fe766d2d3f8 Mon Sep 17 00:00:00 2001 From: Kalle <38327916+Sendouc@users.noreply.github.com> Date: Fri, 6 Mar 2026 19:20:00 +0200 Subject: [PATCH] Basically just locked in --- app/components/MobileNav.module.css | 81 ++----- app/components/MobileNav.tsx | 75 ++++--- app/components/SideNav.tsx | 2 +- app/components/icons/LogOut.tsx | 18 -- app/components/layout/UserItem.tsx | 41 ---- app/components/layout/WeaponSearch.tsx | 3 +- app/components/layout/index.module.css | 36 ++- app/components/layout/index.tsx | 75 +++++-- app/features/core/streams/streams.server.ts | 55 ++--- .../friends/FriendRepository.server.ts | 134 +++++++---- .../friends/components/FriendMenu.tsx | 14 +- .../friends/loaders/friends.server.ts | 66 ++++-- .../friends/routes/friends.module.css | 22 ++ app/features/friends/routes/friends.tsx | 71 +++++- .../lfg/components/LFGPost.module.css | 2 +- app/features/lfg/routes/lfg.tsx | 1 + .../LiveStreamRepository.server.ts | 23 ++ .../scrims/ScrimPostRepository.server.ts | 74 ++++--- .../sendouq-streams/core/streams.server.ts | 60 +++-- app/features/settings/routes/settings.tsx | 36 +-- .../sidebar/core/StreamRanking.test.ts | 55 +++++ app/features/sidebar/core/StreamRanking.ts | 47 ++++ app/features/sidebar/core/sidebar.server.ts | 209 +++++++++++++++--- .../core/Tournament.server.ts | 11 +- .../tournament/routes/to.$id.register.tsx | 4 +- app/root.tsx | 30 +++ app/styles/vars.css | 36 +-- locales/da/friends.json | 7 +- locales/da/front.json | 3 + locales/de/friends.json | 7 +- locales/de/front.json | 3 + locales/en/common.json | 6 +- locales/en/friends.json | 7 +- locales/en/front.json | 3 + locales/es-ES/friends.json | 7 +- locales/es-ES/front.json | 3 + locales/es-US/friends.json | 7 +- locales/es-US/front.json | 3 + locales/fr-CA/friends.json | 7 +- locales/fr-CA/front.json | 3 + locales/fr-EU/friends.json | 7 +- locales/fr-EU/front.json | 3 + locales/he/friends.json | 7 +- locales/he/front.json | 3 + locales/it/friends.json | 7 +- locales/it/front.json | 3 + locales/ja/friends.json | 7 +- locales/ja/front.json | 3 + locales/ko/friends.json | 7 +- locales/ko/front.json | 3 + locales/nl/friends.json | 7 +- locales/nl/front.json | 3 + locales/pl/friends.json | 7 +- locales/pl/front.json | 3 + locales/pt-BR/friends.json | 7 +- locales/pt-BR/front.json | 3 + locales/ru/friends.json | 7 +- locales/ru/front.json | 3 + locales/zh/friends.json | 7 +- locales/zh/front.json | 3 + migrations/120-friends-system.js | 4 + 61 files changed, 1024 insertions(+), 427 deletions(-) delete mode 100644 app/components/icons/LogOut.tsx delete mode 100644 app/components/layout/UserItem.tsx create mode 100644 app/features/sidebar/core/StreamRanking.test.ts create mode 100644 app/features/sidebar/core/StreamRanking.ts diff --git a/app/components/MobileNav.module.css b/app/components/MobileNav.module.css index 67e47f895..4f3d5f6f0 100644 --- a/app/components/MobileNav.module.css +++ b/app/components/MobileNav.module.css @@ -3,7 +3,7 @@ bottom: 0; left: 0; right: 0; - z-index: 10; + z-index: 50; } @media screen and (min-width: 600px) { @@ -63,6 +63,14 @@ height: 24px; } +.sideNavEmpty { + font-size: var(--font-2xs); + color: var(--color-text-high); + padding: var(--s-1) var(--s-2); + text-align: center; + font-style: italic; +} + .notificationDot { position: absolute; top: -2px; @@ -236,66 +244,6 @@ margin: 0; } -.streamItem { - display: flex; - align-items: center; - gap: var(--s-2); - padding: var(--s-1) var(--s-2); - border-radius: var(--radius-field); -} - -.streamItem:hover { - background-color: var(--color-bg-higher); -} - -.streamItemImage { - width: 32px; - height: 32px; - border-radius: var(--radius-field); - object-fit: cover; - flex-shrink: 0; -} - -.streamItemContent { - display: flex; - flex-direction: column; - gap: var(--s-0-5); - min-width: 0; - flex: 1; -} - -.streamItemName { - font-size: var(--font-xs); - color: var(--color-text); - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; -} - -.streamItemMeta { - display: flex; - align-items: center; - gap: var(--s-2); -} - -.streamItemSubtitle { - font-size: var(--font-2xs); - color: var(--color-text-high); - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; -} - -.streamItemBadge { - font-size: var(--font-2xs); - font-weight: var(--weight-semi); - color: var(--color-text-inverse); - background-color: var(--color-text-accent); - padding: 0 var(--s-1); - border-radius: var(--radius-field); - flex-shrink: 0; -} - .navGrid { list-style: none; margin: 0; @@ -403,3 +351,14 @@ text-transform: uppercase; letter-spacing: 0.05em; } + +.streamXpSubtitle { + display: flex; + align-items: center; + gap: 2px; +} + +.streamXpIcon { + width: 14px; + height: 14px; +} diff --git a/app/components/MobileNav.tsx b/app/components/MobileNav.tsx index bfd443670..8e6db6863 100644 --- a/app/components/MobileNav.tsx +++ b/app/components/MobileNav.tsx @@ -6,6 +6,7 @@ import { Dialog, Modal, ModalOverlay } from "react-aria-components"; import { useTranslation } from "react-i18next"; import { Link } from "react-router"; import { useUser } from "~/features/auth/core/user"; +import { FriendMenu } from "~/features/friends/components/FriendMenu"; import type { RootLoaderData } from "~/root"; import { FRIENDS_PAGE, @@ -254,28 +255,36 @@ function MenuOverlay({

{t("front:sideNav.streams")}

+ {streams.length === 0 ? ( +
+ {t("front:sideNav.noStreams")} +
+ ) : null} @@ -317,23 +326,21 @@ function FriendsPanel({ onClose: () => void; }) { const { t } = useTranslation(["front", "common"]); + const user = useUser(); return ( - {friends.map((friend) => ( - - {friend.name} - - ))} + {friends.length > 0 ? ( + friends.map((friend) => ( + + )) + ) : ( +
+ {user + ? t("front:sideNav.friends.noFriends") + : t("front:sideNav.friends.notLoggedIn")} +
+ )} ; - subtitle?: string | null; + subtitle?: React.ReactNode; badge?: string | null; badgeVariant?: "default" | "warning"; }) { diff --git a/app/components/icons/LogOut.tsx b/app/components/icons/LogOut.tsx deleted file mode 100644 index 7f758a8ae..000000000 --- a/app/components/icons/LogOut.tsx +++ /dev/null @@ -1,18 +0,0 @@ -export function LogOutIcon({ className }: { className?: string }) { - return ( - - - - ); -} diff --git a/app/components/layout/UserItem.tsx b/app/components/layout/UserItem.tsx deleted file mode 100644 index f8d99f206..000000000 --- a/app/components/layout/UserItem.tsx +++ /dev/null @@ -1,41 +0,0 @@ -import clsx from "clsx"; -import { LogIn } from "lucide-react"; -import { useTranslation } from "react-i18next"; -import { Link } from "react-router"; -import { useUser } from "~/features/auth/core/user"; -import { userPage } from "~/utils/urls"; -import { Avatar } from "../Avatar"; -import { LogInButtonContainer } from "./LogInButtonContainer"; -import styles from "./UserItem.module.css"; - -export function UserItem({ className }: { className?: string }) { - const { t } = useTranslation(); - const user = useUser(); - - if (user) { - return ( - - - - ); - } - - return ( - - - - ); -} diff --git a/app/components/layout/WeaponSearch.tsx b/app/components/layout/WeaponSearch.tsx index 75b7aead3..b9cdba4fe 100644 --- a/app/components/layout/WeaponSearch.tsx +++ b/app/components/layout/WeaponSearch.tsx @@ -21,7 +21,6 @@ import { } from "~/modules/in-game-lists/weapon-ids"; import { ANALYZER_URL, - artPage, LFG_PAGE, mainWeaponImageUrl, mySlugify, @@ -90,7 +89,7 @@ export function getWeaponDestinationUrl( stats: weaponBuildStatsPage(weapon.slug), analyzer: `${ANALYZER_URL}?weapon=${weapon.id}`, vods: `${VODS_PAGE}?weapon=${weapon.id}`, - art: artPage(weapon.slug), + art: `/art?tab=showcase&tag=${encodeURIComponent(weapon.name.toLowerCase())}`, lfg: `${LFG_PAGE}?weapon=${weapon.id}`, }; diff --git a/app/components/layout/index.module.css b/app/components/layout/index.module.css index 958d244c4..c0c76c65a 100644 --- a/app/components/layout/index.module.css +++ b/app/components/layout/index.module.css @@ -3,6 +3,17 @@ min-width: 0; } +.streamXpSubtitle { + display: flex; + align-items: center; + gap: 2px; +} + +.streamXpIcon { + width: 14px; + height: 14px; +} + .header { display: flex; width: 100%; @@ -33,10 +44,10 @@ justify-content: center; width: 40px; height: 36px; - background-color: var(--color-accent); + background-color: var(--color-text-accent); border-radius: var(--radius-field); font-weight: var(--weight-bold); - color: var(--color-text); + color: var(--color-text-inverse); text-decoration: none; flex-shrink: 0; transition: background-color 0.2s; @@ -55,11 +66,6 @@ top: 5px; } -.siteLogo:hover { - background-color: var(--color-accent-high); - color: var(--color-text-inverse); -} - .siteLogo:focus-visible { outline: var(--focus-ring); outline-offset: 1px; @@ -117,9 +123,11 @@ } .sideNavEmpty { - font-size: var(--font-xs); + font-size: var(--font-2xs); color: var(--color-text-high); padding: var(--s-1) var(--s-2); + margin: 0 auto; + font-style: italic; } .viewAllLink { @@ -149,6 +157,18 @@ } } +.headerCollapsedBreadcrumbs { + display: none; + min-width: 0; +} + +@media screen and (min-width: 600px) { + .headerCollapsedBreadcrumbs { + display: flex; + min-width: 0; + } +} + .mobileLogo { display: none; } diff --git a/app/components/layout/index.tsx b/app/components/layout/index.tsx index 0b9dbaf27..340bd3d94 100644 --- a/app/components/layout/index.tsx +++ b/app/components/layout/index.tsx @@ -183,6 +183,7 @@ export function Layout({ const navOffset = useNavOffset(); const isMounted = useIsMounted(); + const user = useUser(); const sidebarData = data?.sidebar; const events = sidebarData?.events ?? []; const matchStatus = sidebarData?.matchStatus; @@ -264,21 +265,34 @@ export function Layout({ } action={ - - {t("common:actions.viewAll")} - - + user ? ( + + {t("common:actions.viewAll")} + + + ) : null } > {t("front:sideNav.friends")} - {friends.map((friend) => ( - - ))} + {friends.length > 0 ? ( + friends.map((friend) => ) + ) : ( +
+ {user + ? t("front:sideNav.friends.noFriends") + : t("front:sideNav.friends.notLoggedIn")} +
+ )} }> {t("front:sideNav.streams")} + {streams.length === 0 ? ( +
+ {t("front:sideNav.noStreams")} +
+ ) : null} {streams.map((stream) => { const startsAtDate = databaseTimestampToDate(stream.startsAt); @@ -289,12 +303,25 @@ export function Layout({ imageUrl={stream.imageUrl} overlayIconUrl={stream.overlayIconUrl} subtitle={ - isMounted - ? formatDistanceToNow(startsAtDate, { - addSuffix: true, - language: i18n.language as LanguageCode, - }) - : "" + stream.peakXp ? ( + + + {stream.peakXp} + + ) : stream.subtitle ? ( + stream.subtitle + ) : isMounted ? ( + formatDistanceToNow(startsAtDate, { + addSuffix: true, + language: i18n.language as LanguageCode, + }) + ) : ( + "" + ) } badge={ isMounted && startsAtDate.getTime() < Date.now() @@ -316,6 +343,11 @@ export function Layout({ }} > + {sideNavCollapsed ? ( +
+ +
+ ) : null} setSideNavCollapsed(!sideNavCollapsed)} /> @@ -487,10 +519,17 @@ function SideNavUserPanel() { } return ( - - }> - {t("header.login.discord")} - - + <> + + }> + {t("header.login.discord")} + + +
+ + + +
+ ); } diff --git a/app/features/core/streams/streams.server.ts b/app/features/core/streams/streams.server.ts index 202693b27..7a3753118 100644 --- a/app/features/core/streams/streams.server.ts +++ b/app/features/core/streams/streams.server.ts @@ -1,17 +1,15 @@ -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 { cache } from "~/utils/cache.server"; import { dateToDatabaseTimestamp } from "~/utils/dates"; import { tournamentStreamsPage } from "~/utils/urls"; -const FIVE_MINUTES = 5 * 60 * 1000; -const MAX_STREAMS = 5; +export const COMBINED_STREAMS_KEY = "combined-streams"; -export function clearLiveStreamsCache() { - cache.delete("live-tournament-streams"); +export function clearCombinedStreamsCache() { + cache.delete(COMBINED_STREAMS_KEY); } export type SidebarStream = { @@ -23,34 +21,28 @@ export type SidebarStream = { subtitle: string; startsAt: number; tier: TournamentTierNumber | null; + peakXp?: number; + twitchUsername?: string; }; -export function getLiveTournamentStreams(): Promise { - return cachified({ - key: "live-tournament-streams", - cache, - ttl: ttl(FIVE_MINUTES), - async getFreshValue() { - const streams: SidebarStream[] = []; +export function getLiveTournamentStreams(): SidebarStream[] { + 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: dateToDatabaseTimestamp(tournament.ctx.startTime), - tier: tournament.ctx.tier, - }); - } + 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: dateToDatabaseTimestamp(tournament.ctx.startTime), + tier: tournament.ctx.tier, + }); + } - return streams.sort(sortByTierAscending).slice(0, MAX_STREAMS); - }, - }); + return 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) { @@ -76,10 +68,3 @@ function deriveCurrentRound(tournament: Tournament): string { 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/friends/FriendRepository.server.ts b/app/features/friends/FriendRepository.server.ts index dcf66efe4..baa501dfb 100644 --- a/app/features/friends/FriendRepository.server.ts +++ b/app/features/friends/FriendRepository.server.ts @@ -1,55 +1,97 @@ import { db } from "~/db/sql"; export async function findByUserIdWithActivity(userId: number) { - const rows = await db - .selectFrom("Friendship") - .innerJoin("User", (join) => - join.on((eb) => + const [friendRows, teamMemberRows] = await Promise.all([ + db + .selectFrom("Friendship") + .innerJoin("User", (join) => + join.on((eb) => + eb.or([ + eb.and([ + eb("Friendship.userOneId", "=", userId), + eb("User.id", "=", eb.ref("Friendship.userTwoId")), + ]), + eb.and([ + eb("Friendship.userTwoId", "=", userId), + eb("User.id", "=", eb.ref("Friendship.userOneId")), + ]), + ]), + ), + ) + .leftJoin("TournamentSub", "TournamentSub.userId", "User.id") + .leftJoin( + "CalendarEvent", + "CalendarEvent.tournamentId", + "TournamentSub.tournamentId", + ) + .leftJoin( + "CalendarEventDate", + "CalendarEventDate.eventId", + "CalendarEvent.id", + ) + .select([ + "Friendship.id as friendshipId", + "User.id", + "User.username", + "User.discordId", + "User.discordAvatar", + "User.customUrl", + "CalendarEvent.name as tournamentName", + "TournamentSub.tournamentId", + "CalendarEventDate.startTime as tournamentStartTime", + "Friendship.createdAt as friendshipCreatedAt", + ]) + .where((eb) => eb.or([ - eb.and([ - eb("Friendship.userOneId", "=", userId), - eb("User.id", "=", eb.ref("Friendship.userTwoId")), - ]), - eb.and([ - eb("Friendship.userTwoId", "=", userId), - eb("User.id", "=", eb.ref("Friendship.userOneId")), - ]), + eb("Friendship.userOneId", "=", userId), + eb("Friendship.userTwoId", "=", userId), ]), - ), - ) - .leftJoin("TournamentSub", "TournamentSub.userId", "User.id") - .leftJoin( - "CalendarEvent", - "CalendarEvent.tournamentId", - "TournamentSub.tournamentId", - ) - .leftJoin( - "CalendarEventDate", - "CalendarEventDate.eventId", - "CalendarEvent.id", - ) - .select([ - "Friendship.id as friendshipId", - "User.id", - "User.username", - "User.discordId", - "User.discordAvatar", - "User.customUrl", - "CalendarEvent.name as tournamentName", - "TournamentSub.tournamentId", - "CalendarEventDate.startTime as tournamentStartTime", - "Friendship.createdAt as friendshipCreatedAt", - ]) - .where((eb) => - eb.or([ - eb("Friendship.userOneId", "=", userId), - eb("Friendship.userTwoId", "=", userId), - ]), - ) - .orderBy("Friendship.createdAt", "desc") - .execute(); + ) + .orderBy("Friendship.createdAt", "desc") + .execute(), + db + .selectFrom("TeamMemberWithSecondary as myMembership") + .innerJoin("TeamMemberWithSecondary as otherMembership", (join) => + join + .onRef("otherMembership.teamId", "=", "myMembership.teamId") + .on("otherMembership.userId", "!=", userId), + ) + .innerJoin("User", "User.id", "otherMembership.userId") + .leftJoin("TournamentSub", "TournamentSub.userId", "User.id") + .leftJoin( + "CalendarEvent", + "CalendarEvent.tournamentId", + "TournamentSub.tournamentId", + ) + .leftJoin( + "CalendarEventDate", + "CalendarEventDate.eventId", + "CalendarEvent.id", + ) + .select([ + "User.id", + "User.username", + "User.discordId", + "User.discordAvatar", + "User.customUrl", + "CalendarEvent.name as tournamentName", + "TournamentSub.tournamentId", + "CalendarEventDate.startTime as tournamentStartTime", + ]) + .where("myMembership.userId", "=", userId) + .where("myMembership.leftAt", "is", null) + .where("otherMembership.leftAt", "is", null) + .execute(), + ]); - return rows; + return [ + ...friendRows, + ...teamMemberRows.map((row) => ({ + ...row, + friendshipId: null as number | null, + friendshipCreatedAt: null as number | null, + })), + ]; } export async function findPendingSentRequests(senderId: number) { diff --git a/app/features/friends/components/FriendMenu.tsx b/app/features/friends/components/FriendMenu.tsx index 4fb3182ae..20d58ff3c 100644 --- a/app/features/friends/components/FriendMenu.tsx +++ b/app/features/friends/components/FriendMenu.tsx @@ -11,7 +11,7 @@ import { } from "~/components/elements/Menu"; import { ListButton } from "~/components/SideNav"; import { databaseTimestampToDate } from "~/utils/dates"; -import { SENDOUQ_LOOKING_PAGE, tournamentPage } from "~/utils/urls"; +import { SENDOUQ_LOOKING_PAGE, tournamentSubsPage } from "~/utils/urls"; export function FriendMenu({ discordId, @@ -23,6 +23,7 @@ export function FriendMenu({ tournamentId, friendshipId, friendshipCreatedAt, + onNavigate, }: { discordId: string; discordAvatar: string | null; @@ -33,6 +34,7 @@ export function FriendMenu({ tournamentId: number | null; friendshipId?: number; friendshipCreatedAt?: number | null; + onNavigate?: () => void; }) { const { t } = useTranslation(["common", "friends"]); const fetcher = useFetcher(); @@ -60,11 +62,15 @@ export function FriendMenu({ } > - }> + } onAction={onNavigate}> {t("friends:friendsList.viewUserPage")} {activityHref ? ( - }> + } + onAction={onNavigate} + > {activityHref.isSendouQ ? t("friends:friendsList.joinSendouQ") : t("friends:friendsList.viewTournament")} @@ -127,7 +133,7 @@ function resolveActivityHref(friend: { } if (friend.tournamentId) { - return { url: tournamentPage(friend.tournamentId), isSendouQ: false }; + return { url: tournamentSubsPage(friend.tournamentId), isSendouQ: false }; } return null; diff --git a/app/features/friends/loaders/friends.server.ts b/app/features/friends/loaders/friends.server.ts index 4e7662531..041723a44 100644 --- a/app/features/friends/loaders/friends.server.ts +++ b/app/features/friends/loaders/friends.server.ts @@ -16,28 +16,29 @@ export const loader = async () => { FriendRepository.findPendingReceivedRequests(user.id), ]); - // xxx: why is this needed? shouldn't the query be doing this already? - const uniqueFriends = R.uniqueBy(friendsWithActivity, (f) => f.id); + const unique = R.uniqueBy(friendsWithActivity, (f) => f.id); - const friends = uniqueFriends.map((friend) => { - const activity = resolveFriendActivity(friend.id, friend.tournamentName); + const friends = unique + .filter((f) => f.friendshipId !== null) + .map((friend) => { + const activity = resolveFriendActivity(friend.id, friend.tournamentName); - return { - id: friend.id, - friendshipId: friend.friendshipId, - username: friend.username, - discordId: friend.discordId, - discordAvatar: friend.discordAvatar, - url: userPage({ + return { + id: friend.id, + friendshipId: friend.friendshipId as number, + username: friend.username, discordId: friend.discordId, - customUrl: friend.customUrl, - }), - subtitle: activity.subtitle, - badge: activity.badge, - tournamentId: friend.tournamentId, - friendshipCreatedAt: friend.friendshipCreatedAt, - }; - }); + discordAvatar: friend.discordAvatar, + url: userPage({ + discordId: friend.discordId, + customUrl: friend.customUrl, + }), + subtitle: activity.subtitle, + badge: activity.badge, + tournamentId: friend.tournamentId, + friendshipCreatedAt: friend.friendshipCreatedAt, + }; + }); friends.sort((a, b) => { const aActive = a.subtitle ? 1 : 0; @@ -47,8 +48,35 @@ export const loader = async () => { return (b.friendshipCreatedAt ?? 0) - (a.friendshipCreatedAt ?? 0); }); + const teamMembers = unique + .filter((f) => f.friendshipId === null) + .map((tm) => { + const activity = resolveFriendActivity(tm.id, tm.tournamentName); + + return { + id: tm.id, + username: tm.username, + discordId: tm.discordId, + discordAvatar: tm.discordAvatar, + url: userPage({ + discordId: tm.discordId, + customUrl: tm.customUrl, + }), + subtitle: activity.subtitle, + badge: activity.badge, + tournamentId: tm.tournamentId, + }; + }); + + teamMembers.sort((a, b) => { + const aActive = a.subtitle ? 1 : 0; + const bActive = b.subtitle ? 1 : 0; + return bActive - aActive; + }); + return { friends, + teamMembers, incomingRequests: incomingRequests.map((req) => ({ id: req.id, sender: { diff --git a/app/features/friends/routes/friends.module.css b/app/features/friends/routes/friends.module.css index 92fc366ab..499528af3 100644 --- a/app/features/friends/routes/friends.module.css +++ b/app/features/friends/routes/friends.module.css @@ -12,3 +12,25 @@ gap: var(--s-2); font-weight: var(--weight-semi); } + +.friendsListHeader { + display: flex; + align-items: center; + justify-content: space-between; + flex-wrap: wrap; + gap: var(--s-2); +} + +.filterRadio { + padding: var(--s-1) var(--s-2); + border-radius: var(--radius-field); + cursor: pointer; + color: var(--color-text-high); + font-weight: var(--weight-semi); + font-size: var(--font-3xs); +} + +.filterRadioSelected { + background-color: var(--color-bg-higher); + color: var(--color-text); +} diff --git a/app/features/friends/routes/friends.tsx b/app/features/friends/routes/friends.tsx index ba3b8f223..8f81c5b69 100644 --- a/app/features/friends/routes/friends.tsx +++ b/app/features/friends/routes/friends.tsx @@ -1,3 +1,6 @@ +import clsx from "clsx"; +import { useState } from "react"; +import { Radio, RadioGroup } from "react-aria-components"; import { useTranslation } from "react-i18next"; import { Form, useLoaderData } from "react-router"; import { Avatar } from "~/components/Avatar"; @@ -17,6 +20,8 @@ export const handle: SendouRouteHandle = { i18n: ["friends"], }; +type ViewFilter = "friends" | "team" | "all"; + export default function FriendsPage() { const data = useLoaderData(); @@ -136,19 +141,75 @@ function PendingRequestsSection() { function FriendsListSection() { const { t } = useTranslation(["common", "friends"]); const data = useLoaderData(); + const [filter, setFilter] = useState("friends"); + + const viewLabels: Record = { + friends: t("friends:view.friends"), + team: t("friends:view.teamMembers"), + all: t("friends:view.all"), + }; + + const shownItems = resolveShownItems(filter, data); + const emptyKey = + filter === "team" + ? "friends:teamMembers.empty" + : "friends:friendsList.empty"; return (
-

{t("friends:friendsList.title")}

- {data.friends.length === 0 ? ( -

{t("friends:friendsList.empty")}

+
+

{t("friends:friendsList.title")}

+ setFilter(v as ViewFilter)} + aria-label={t("friends:view.label")} + orientation="horizontal" + className="stack horizontal xs" + > + {(["friends", "team", "all"] as const).map((value) => ( + + {({ isSelected }) => ( + + {viewLabels[value]} + + )} + + ))} + +
+ {shownItems.length === 0 ? ( +

{t(emptyKey)}

) : (
- {data.friends.map((friend) => ( - + {shownItems.map((item) => ( + ))}
)}
); } + +function resolveShownItems( + filter: ViewFilter, + data: Awaited>, +) { + if (filter === "friends") return data.friends; + if (filter === "team") return data.teamMembers; + + const friendIds = new Set(data.friends.map((f) => f.id)); + const combined = [ + ...data.friends, + ...data.teamMembers.filter((tm) => !friendIds.has(tm.id)), + ]; + + return combined.sort((a, b) => { + const aActive = a.subtitle ? 1 : 0; + const bActive = b.subtitle ? 1 : 0; + return bActive - aActive; + }); +} diff --git a/app/features/lfg/components/LFGPost.module.css b/app/features/lfg/components/LFGPost.module.css index 10b3898e0..db327f868 100644 --- a/app/features/lfg/components/LFGPost.module.css +++ b/app/features/lfg/components/LFGPost.module.css @@ -86,7 +86,7 @@ font-size: var(--font-2xs); font-weight: var(--weight-semi); border-radius: var(--radius-selector); - background-color: var(--color-bg-higher); + background-color: var(--color-bg-high); padding: 0 var(--s-2); height: var(--selector-size-sm); display: flex; diff --git a/app/features/lfg/routes/lfg.tsx b/app/features/lfg/routes/lfg.tsx index bbcb5b0aa..f625ba30d 100644 --- a/app/features/lfg/routes/lfg.tsx +++ b/app/features/lfg/routes/lfg.tsx @@ -96,6 +96,7 @@ export default function LFGPage() { const activeFilterCount = countActiveFilters(filters); + // xxx: undo changes that introduce the panel here return (
[], @@ -12,3 +14,24 @@ export function replaceAll( } }); } + +export function findXRankStreams() { + return db + .selectFrom("LiveStream") + .innerJoin("User", "User.twitch", "LiveStream.twitch") + .innerJoin("SplatoonPlayer", "SplatoonPlayer.userId", "User.id") + .where( + "SplatoonPlayer.peakXp", + ">=", + StreamRanking.minXpForStreamToBeShown(), + ) + .where("LiveStream.twitch", "is not", null) + .select([ + ...COMMON_USER_FIELDS, + "SplatoonPlayer.peakXp", + "LiveStream.viewerCount", + "LiveStream.thumbnailUrl", + "LiveStream.twitch as twitchUsername", + ]) + .execute(); +} diff --git a/app/features/scrims/ScrimPostRepository.server.ts b/app/features/scrims/ScrimPostRepository.server.ts index 0c504ea6e..03c70b359 100644 --- a/app/features/scrims/ScrimPostRepository.server.ts +++ b/app/features/scrims/ScrimPostRepository.server.ts @@ -417,39 +417,59 @@ export async function findUserScrims(userId: number): Promise { const rows = await baseFindQuery .where("ScrimPost.canceledAt", "is", null) .where("ScrimPost.at", ">=", now) + .where((eb) => + eb.or([ + eb.exists( + eb + .selectFrom("ScrimPostUser") + .select("ScrimPostUser.scrimPostId") + .whereRef("ScrimPostUser.scrimPostId", "=", "ScrimPost.id") + .where("ScrimPostUser.userId", "=", userId), + ), + eb.exists( + eb + .selectFrom("ScrimPostRequest") + .innerJoin( + "ScrimPostRequestUser", + "ScrimPostRequestUser.scrimPostRequestId", + "ScrimPostRequest.id", + ) + .select("ScrimPostRequest.scrimPostId") + .whereRef("ScrimPostRequest.scrimPostId", "=", "ScrimPost.id") + .where("ScrimPostRequestUser.userId", "=", userId), + ), + ]), + ) .orderBy("ScrimPost.at", "asc") .execute(); - return rows - .map(mapDBRowToScrimPost) - .filter((post) => Scrim.isParticipating(post, userId)) - .map((post) => { - const isAccepted = Scrim.isAccepted(post); - - if (!isAccepted) { - return { - id: post.id, - at: post.at, - opponentName: null, - opponentAvatarUrl: null, - isAccepted: false, - }; - } - - const userIsInPost = post.users.some((u) => u.id === userId); - const opponent = userIsInPost - ? post.requests[0] - : { team: post.team, users: post.users }; - const opponentTeam = opponent?.team; - const opponentOwner = opponent?.users.find((u) => u.isOwner); + return rows.map(mapDBRowToScrimPost).map((post) => { + const isAccepted = Scrim.isAccepted(post); + if (!isAccepted) { return { id: post.id, at: post.at, - opponentName: opponentTeam?.name ?? null, - opponentAvatarUrl: - opponentTeam?.avatarUrl ?? opponentOwner?.discordAvatar ?? null, - isAccepted: true, + opponentName: null, + opponentAvatarUrl: null, + isAccepted: false, }; - }); + } + + const userIsInPost = post.users.some((u) => u.id === userId); + const opponent = userIsInPost + ? post.requests[0] + : { team: post.team, users: post.users }; + const opponentTeam = opponent?.team; + const opponentOwner = opponent?.users.find((u) => u.isOwner); + + return { + id: post.id, + at: post.at, + opponentName: opponentTeam?.name ?? null, + opponentAvatarUrl: + opponentTeam?.avatarUrl ?? opponentOwner?.discordAvatar ?? null, + isAccepted: true, + }; + }); } diff --git a/app/features/sendouq-streams/core/streams.server.ts b/app/features/sendouq-streams/core/streams.server.ts index 60c48ce84..6511f0e94 100644 --- a/app/features/sendouq-streams/core/streams.server.ts +++ b/app/features/sendouq-streams/core/streams.server.ts @@ -1,6 +1,9 @@ import cachified from "@epic-web/cachified"; import * as R from "remeda"; -import type { SidebarStream } from "~/features/core/streams/streams.server"; +import { + COMBINED_STREAMS_KEY, + type SidebarStream, +} from "~/features/core/streams/streams.server"; import { cachedFullUserLeaderboard, type UserLeaderboardWithAdditionsItem, @@ -60,6 +63,7 @@ export function cachedStreams() { export function refreshStreamsCache() { cache.delete(SENDOUQ_STREAMS_KEY); + cache.delete(COMBINED_STREAMS_KEY); void cachedStreams().catch((err) => logger.error(`Failed to refresh cache: ${err}`), ); @@ -101,12 +105,20 @@ function streamedMatches({ }); } -export async function getSendouQSidebarStreams(): Promise { +export type SendouQSidebarEntry = { + sidebarStream: SidebarStream; + tier: { name: TierName; isPlus: boolean } | null; + twitchUsernames: string[]; +}; + +export async function getSendouQSidebarStreams(): Promise< + SendouQSidebarEntry[] +> { const streams = await cachedStreams(); const matchIdToStream = R.groupBy(streams, (s) => s.match.id); - const sidebarStreams: SidebarStream[] = []; + const entries: SendouQSidebarEntry[] = []; for (const [matchIdStr, matchStreams] of Object.entries(matchIdToStream)) { const matchId = Number(matchIdStr); @@ -115,25 +127,35 @@ export async function getSendouQSidebarStreams(): Promise { 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, + const twitchUsernames = matchStreams + .map((s) => s.stream.twitchUserName) + .filter((t): t is string => t !== null); + + entries.push({ + sidebarStream: { + 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, + }, + tier: averageTier, + twitchUsernames, }); } - return sidebarStreams.sort((a, b) => { - const aTierIndex = getTierIndexFromSubtitle(a.subtitle); - const bTierIndex = getTierIndexFromSubtitle(b.subtitle); + return entries.sort((a, b) => { + const aTierIndex = getTierIndexFromSubtitle(a.sidebarStream.subtitle); + const bTierIndex = getTierIndexFromSubtitle(b.sidebarStream.subtitle); return aTierIndex - bTierIndex; }); } diff --git a/app/features/settings/routes/settings.tsx b/app/features/settings/routes/settings.tsx index 3a13f7db5..896dc0bf3 100644 --- a/app/features/settings/routes/settings.tsx +++ b/app/features/settings/routes/settings.tsx @@ -53,7 +53,21 @@ export default function SettingsPage() { return (
-

{t("common:pages.settings")}

+
+

{t("common:pages.settings")}

+ {user ? ( +
+ } + type="submit" + > + {t("common:header.logout")} + +
+ ) : null} +
{t("common:settings.locales")} @@ -69,11 +83,6 @@ export default function SettingsPage() { {({ FormField }) => } ) : null} - - {t("common:settings.theme")} - - - {user ? ( <> @@ -111,18 +120,13 @@ export default function SettingsPage() { {({ FormField }) => }
-
- } - type="submit" - > - {t("common:header.logout")} - -
) : null} + + {t("common:settings.theme")} + + +
); diff --git a/app/features/sidebar/core/StreamRanking.test.ts b/app/features/sidebar/core/StreamRanking.test.ts new file mode 100644 index 000000000..63b85c761 --- /dev/null +++ b/app/features/sidebar/core/StreamRanking.test.ts @@ -0,0 +1,55 @@ +import { describe, expect, it } from "vitest"; +import * as StreamRanking from "./StreamRanking"; + +describe("StreamRanking.sendouQTierToScore", () => { + it("LEVIATHAN+ scores 1", () => { + expect( + StreamRanking.sendouQTierToScore({ name: "LEVIATHAN", isPlus: true }), + ).toBe(1); + }); + + it("PLATINUM+ scores 5", () => { + expect( + StreamRanking.sendouQTierToScore({ name: "PLATINUM", isPlus: true }), + ).toBe(5); + }); + + it("IRON & SILVER+ scores 9 (capped)", () => { + expect( + StreamRanking.sendouQTierToScore({ name: "SILVER", isPlus: true }), + ).toBe(9); + + expect( + StreamRanking.sendouQTierToScore({ name: "IRON", isPlus: false }), + ).toBe(9); + }); +}); + +describe("StreamRanking.xpToScore", () => { + it("returns null for XP below 3000", () => { + expect(StreamRanking.xpToScore(2999)).toBeNull(); + expect(StreamRanking.xpToScore(0)).toBeNull(); + }); + + it("3000 XP scores 9", () => { + expect(StreamRanking.xpToScore(3000)).toBe(9); + }); + + it("3200 XP scores 8", () => { + expect(StreamRanking.xpToScore(3200)).toBe(8); + }); + + it("3400 XP scores 7", () => { + expect(StreamRanking.xpToScore(3400)).toBe(7); + }); + + it("3800 XP scores 5 (X rank minimum)", () => { + expect(StreamRanking.xpToScore(3800)).toBe(5); + }); + + it("XP above 3800 is capped at score 5", () => { + expect(StreamRanking.xpToScore(4200)).toBe(5); + expect(StreamRanking.xpToScore(4600)).toBe(5); + expect(StreamRanking.xpToScore(9999)).toBe(5); + }); +}); diff --git a/app/features/sidebar/core/StreamRanking.ts b/app/features/sidebar/core/StreamRanking.ts new file mode 100644 index 000000000..3ed338a7d --- /dev/null +++ b/app/features/sidebar/core/StreamRanking.ts @@ -0,0 +1,47 @@ +import type { SidebarStream } from "~/features/core/streams/streams.server"; +import { TIERS, type TierName } from "~/features/mmr/mmr-constants"; +import type { TournamentTierNumber } from "~/features/tournament/core/tiering"; + +type RankedStream = { stream: SidebarStream; score: number }; + +export function rank( + streams: RankedStream[], + maxStreams: number, +): SidebarStream[] { + return streams + .sort((a, b) => a.score - b.score || a.stream.startsAt - b.stream.startsAt) + .slice(0, maxStreams) + .map((rs) => rs.stream); +} + +export function tournamentTierToScore( + tier: TournamentTierNumber | null, +): number { + return tier ?? 9; +} + +export function sendouQTierToScore(tier: { + name: TierName; + isPlus: boolean; +}): number { + const baseIndex = TIERS.findIndex((t) => t.name === tier.name); + if (baseIndex === -1) return 9; + return Math.min(9, baseIndex * 2 + (tier.isPlus ? 1 : 2)); +} + +const X_RANK_SCORES = [ + [3800, 5], + [3600, 6], + [3400, 7], + [3200, 8], + [3000, 9], +] as const; + +export function minXpForStreamToBeShown(): number { + return X_RANK_SCORES.at(-1)?.[0] ?? 3_000; +} + +export function xpToScore(peakXp: number): number | null { + const entry = X_RANK_SCORES.find(([minXp]) => peakXp >= minXp); + return entry ? entry[1] : null; +} diff --git a/app/features/sidebar/core/sidebar.server.ts b/app/features/sidebar/core/sidebar.server.ts index dbb214ea2..dcba14dfe 100644 --- a/app/features/sidebar/core/sidebar.server.ts +++ b/app/features/sidebar/core/sidebar.server.ts @@ -1,22 +1,32 @@ +import { cachified } from "@epic-web/cachified"; import { href } from "react-router"; +import * as R from "remeda"; +import type { ShowcaseCalendarEvent } from "~/features/calendar/calendar-types"; import { + COMBINED_STREAMS_KEY, getLiveTournamentStreams, type SidebarStream, } from "~/features/core/streams/streams.server"; import * as FriendRepository from "~/features/friends/FriendRepository.server"; import { resolveFriendActivity } from "~/features/friends/friends-utils.server"; import * as ShowcaseTournaments from "~/features/front-page/core/ShowcaseTournaments.server"; +import * as LiveStreamRepository from "~/features/live-streams/LiveStreamRepository.server"; import * as ScrimPostRepository from "~/features/scrims/ScrimPostRepository.server"; import { SendouQ } from "~/features/sendouq/core/SendouQ.server"; import { getSendouQSidebarStreams } from "~/features/sendouq-streams/core/streams.server"; import { RunningTournaments } from "~/features/tournament-bracket/core/RunningTournaments.server"; +import { cache, ttl } from "~/utils/cache.server"; import { + BLANK_IMAGE_URL, + discordAvatarUrl, navIconUrl, sendouQMatchPage, tournamentBracketsPage, tournamentMatchPage, + twitchUrl, userPage, } from "~/utils/urls"; +import * as StreamRanking from "./StreamRanking"; export type SidebarEvent = { id: number; @@ -39,15 +49,18 @@ export type SidebarFriend = { tournamentId: number | null; }; -const MAX_EVENTS_VISIBLE = 8; +const MAX_EVENTS_VISIBLE = 5; const MAX_FRIENDS_VISIBLE = 4; +const MAX_STREAMS_VISIBLE = 5; const SENDOUQ_QUOTA = 2; const TOURNAMENT_SUB_QUOTA = 2; export async function resolveSidebarData(userId: number | null) { if (!userId) { + const tournamentsData = + await ShowcaseTournaments.frontPageTournamentsByUserId(null); return { - events: [] as SidebarEvent[], + events: showcaseEventsToSidebarEvents(tournamentsData.showcase), matchStatus: null as { matchId: number; url: string } | null, tournamentMatchStatus: null as { url: string; @@ -56,7 +69,7 @@ export async function resolveSidebarData(userId: number | null) { logoUrl: string | null; } | null, friends: [] as SidebarFriend[], - streams: await combinedStreams(), + streams: await combinedStreamsCached(), }; } @@ -101,9 +114,14 @@ export async function resolveSidebarData(userId: number | null) { scrimStatus: s.isAccepted ? ("booked" as const) : ("looking" as const), })); - const events = [...tournamentEvents, ...scrimEvents] - .sort((a, b) => a.startTime - b.startTime) - .slice(0, MAX_EVENTS_VISIBLE); + const personalEvents = [...tournamentEvents, ...scrimEvents].sort( + (a, b) => a.startTime - b.startTime, + ); + const events = ( + personalEvents.length > 0 + ? personalEvents + : showcaseEventsToSidebarEvents(tournamentsData.showcase) + ).slice(0, MAX_EVENTS_VISIBLE); const friends = resolveFriends(friendsWithActivity); @@ -114,17 +132,91 @@ export async function resolveSidebarData(userId: number | null) { : null, tournamentMatchStatus, friends, - streams: await combinedStreams(), + streams: await combinedStreamsCached(), }; } +function combinedStreamsCached(): Promise { + return cachified({ + key: COMBINED_STREAMS_KEY, + cache, + ttl: ttl(10 * 60 * 1000), + async getFreshValue() { + return combinedStreams(); + }, + }); +} + async function combinedStreams(): Promise { - const [tournamentStreams, sendouQStreams] = await Promise.all([ - getLiveTournamentStreams(), + const tournamentStreams = getLiveTournamentStreams(); + const [sendouQEntries, xRankRows] = await Promise.all([ getSendouQSidebarStreams(), + LiveStreamRepository.findXRankStreams(), ]); - return [...tournamentStreams, ...sendouQStreams]; + const seenUsernames = new Set( + sendouQEntries.flatMap((e) => + e.twitchUsernames.map((t) => t.toLowerCase()), + ), + ); + + const ranked: { stream: SidebarStream; score: number }[] = []; + + for (const stream of tournamentStreams) { + ranked.push({ + stream, + score: StreamRanking.tournamentTierToScore(stream.tier), + }); + } + + for (const { sidebarStream, tier } of sendouQEntries) { + const score = tier ? StreamRanking.sendouQTierToScore(tier) : 9; + ranked.push({ stream: sidebarStream, score }); + } + + const xRankByUser = new Map(); + for (const row of xRankRows) { + const existing = xRankByUser.get(row.id); + if (!existing || (row.peakXp ?? 0) > (existing.peakXp ?? 0)) { + xRankByUser.set(row.id, row); + } + } + + for (const row of xRankByUser.values()) { + if ( + row.twitchUsername && + seenUsernames.has(row.twitchUsername.toLowerCase()) + ) { + continue; + } + + const score = StreamRanking.xpToScore(row.peakXp ?? 0); + if (score === null) continue; + + ranked.push({ + stream: { + id: row.id, + name: row.username, + imageUrl: row.discordAvatar + ? discordAvatarUrl({ + discordId: row.discordId, + discordAvatar: row.discordAvatar, + size: "sm", + }) + : BLANK_IMAGE_URL, + url: row.twitchUsername + ? twitchUrl(row.twitchUsername) + : userPage({ discordId: row.discordId, customUrl: row.customUrl }), + subtitle: "", + startsAt: Math.floor(Date.now() / 1000), + tier: null, + peakXp: row.peakXp ?? undefined, + }, + score, + }); + } + + return StreamRanking.rank(ranked, MAX_STREAMS_VISIBLE); } function resolveTournamentMatchStatus(userId: number) { @@ -180,29 +272,27 @@ type FriendWithActivity = Awaited< >[number]; function resolveFriends(friendsWithActivity: FriendWithActivity[]) { + const unique = R.uniqueBy(friendsWithActivity, (f) => f.id); + const friendRows = unique.filter((f) => f.friendshipId !== null); + const teamMemberRows = unique.filter((f) => f.friendshipId === null); + const sendouqFriends: SidebarFriend[] = []; const tournamentSubFriends: SidebarFriend[] = []; + const inactiveFriends: FriendWithActivity[] = []; - for (const friend of friendsWithActivity) { + for (const friend of friendRows) { const activity = resolveFriendActivity(friend.id, friend.tournamentName); - if (!activity.subtitle) continue; + if (!activity.subtitle) { + inactiveFriends.push(friend); + continue; + } - const url = userPage({ - discordId: friend.discordId, - customUrl: friend.customUrl, - }); - - const sidebarFriend: SidebarFriend = { - id: friend.id, - name: friend.username, - discordId: friend.discordId, - discordAvatar: friend.discordAvatar, - url, - subtitle: activity.subtitle, - badge: activity.badge ?? "", - tournamentId: friend.tournamentId, - }; + const sidebarFriend = rowToSidebarFriend( + friend, + activity.subtitle, + activity.badge ?? "", + ); if (activity.subtitle === "SendouQ") { sendouqFriends.push(sidebarFriend); @@ -226,5 +316,70 @@ function resolveFriends(friendsWithActivity: FriendWithActivity[]) { result.push(...[...extraSendouq, ...extraTournament].slice(0, remaining)); } + if (result.length < MAX_FRIENDS_VISIBLE) { + const shownIds = new Set(result.map((f) => f.id)); + const inactiveTeamMembers: FriendWithActivity[] = []; + + for (const tm of teamMemberRows) { + if (result.length >= MAX_FRIENDS_VISIBLE) break; + if (shownIds.has(tm.id)) continue; + + const activity = resolveFriendActivity(tm.id, tm.tournamentName); + if (!activity.subtitle) { + inactiveTeamMembers.push(tm); + continue; + } + + result.push( + rowToSidebarFriend(tm, activity.subtitle, activity.badge ?? ""), + ); + shownIds.add(tm.id); + } + + for (const friend of inactiveFriends) { + if (result.length >= MAX_FRIENDS_VISIBLE) break; + if (shownIds.has(friend.id)) continue; + + result.push(rowToSidebarFriend(friend, "", "")); + shownIds.add(friend.id); + } + + for (const tm of inactiveTeamMembers) { + if (result.length >= MAX_FRIENDS_VISIBLE) break; + + result.push(rowToSidebarFriend(tm, "", "")); + } + } + return result; } + +function showcaseEventsToSidebarEvents( + events: ShowcaseCalendarEvent[], +): SidebarEvent[] { + return events.map((e) => ({ + id: e.id, + name: e.name, + url: e.url, + logoUrl: e.logoUrl, + startTime: e.startTime, + type: "tournament" as const, + })); +} + +function rowToSidebarFriend( + row: FriendWithActivity, + subtitle: string, + badge: string, +): SidebarFriend { + return { + id: row.id, + name: row.username, + discordId: row.discordId, + discordAvatar: row.discordAvatar, + url: userPage({ discordId: row.discordId, customUrl: row.customUrl }), + subtitle, + badge, + tournamentId: row.tournamentId, + }; +} diff --git a/app/features/tournament-bracket/core/Tournament.server.ts b/app/features/tournament-bracket/core/Tournament.server.ts index 8c899a709..30c1fc6c7 100644 --- a/app/features/tournament-bracket/core/Tournament.server.ts +++ b/app/features/tournament-bracket/core/Tournament.server.ts @@ -1,4 +1,4 @@ -import { clearLiveStreamsCache } from "~/features/core/streams/streams.server"; +import { clearCombinedStreamsCache } 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"; @@ -102,10 +102,7 @@ export async function tournamentFromDBCached(args: { }) { const data = notFoundIfFalsy(await tournamentDataCached(args)); - const tournament = new Tournament({ ...data, simulateBrackets: false }); - syncTournamentToRegistry(tournament); - - return tournament; + return new Tournament({ ...data, simulateBrackets: false }); } // caching promise ensures that if many requests are made for the same tournament @@ -145,11 +142,11 @@ function syncTournamentToRegistry(tournament: Tournament) { if (isRunning) { RunningTournaments.add(tournament); if (!wasInRegistry) { - clearLiveStreamsCache(); + clearCombinedStreamsCache(); } } else { if (wasInRegistry) { - clearLiveStreamsCache(); + clearCombinedStreamsCache(); } RunningTournaments.remove(tournament.ctx.id); } diff --git a/app/features/tournament/routes/to.$id.register.tsx b/app/features/tournament/routes/to.$id.register.tsx index aed93a050..8b2f3717a 100644 --- a/app/features/tournament/routes/to.$id.register.tsx +++ b/app/features/tournament/routes/to.$id.register.tsx @@ -355,7 +355,9 @@ function RegistrationForms() { {ownTeam ? ( <> - {tournament.teamsPrePickMaps ? : null} + {tournament.teamsPrePickMaps ? ( + + ) : null} ) : null} diff --git a/app/root.tsx b/app/root.tsx index 9d6e5ed98..1aa098646 100644 --- a/app/root.tsx +++ b/app/root.tsx @@ -24,6 +24,7 @@ import { useMatches, useNavigate, useNavigation, + useRevalidator, useSearchParams, } from "react-router"; import { useDebounce } from "react-use"; @@ -155,6 +156,7 @@ function Document({ usePreloadTranslation(); useLoadingIndicator(); useTriggerToasts(); + useSidebarRevalidation(); return ( { + const TEN_MINUTES = 10 * 60 * 1000; + + const revalidate = () => { + if (revalidator.state === "idle") { + revalidator.revalidate(); + } + }; + + const handleVisibilityChange = () => { + if (document.visibilityState === "visible") { + revalidate(); + } + }; + + document.addEventListener("visibilitychange", handleVisibilityChange); + const interval = setInterval(revalidate, TEN_MINUTES); + + return () => { + document.removeEventListener("visibilitychange", handleVisibilityChange); + clearInterval(interval); + }; + }, [revalidator]); +} + function usePreloadTranslation() { React.useEffect(() => { void generalI18next.loadNamespaces(allI18nNamespaces()); diff --git a/app/styles/vars.css b/app/styles/vars.css index b35b04b16..5280bcad6 100644 --- a/app/styles/vars.css +++ b/app/styles/vars.css @@ -8,31 +8,31 @@ The hue and chroma values should not be edited manually, instead use oklch-gamut.ts to generate valid color palettes! */ html { - --_base-h: 260; - --_base-c-0: 0.00012; - --_base-c-1: 0.00588; - --_base-c-2: 0.00744; - --_base-c-3: 0.0168; - --_base-c-4: 0.01548; - --_base-c-5: 0.01632; - --_base-c-6: 0.01548; - --_base-c-7: 0.00804; + --_base-h: 268; + --_base-c-0: 0; + --_base-c-1: 0.02418640252723488; + --_base-c-2: 0.047119999999999995; + --_base-c-3: 0.1064; + --_base-c-4: 0.09804; + --_base-c-5: 0.10336000000000001; + --_base-c-6: 0.09804; + --_base-c-7: 0.05092; - --_acc-h: 270; + --_acc-h: 360; --_acc-c-0: 0.0912; - --_acc-c-1: 0.2664; + --_acc-c-1: 0.21186558872491915; --_acc-c-2: 0.0816; --_acc-c-3: 0.06; - --_acc-c-4: 0.2616; - --_acc-c-5: 0.1344; + --_acc-c-4: 0.2145290094090161; + --_acc-c-5: 0.1288211858120959; - --_second-h: 90; - --_second-c-0: 0.05295305624247283; - --_second-c-1: 0.10680119733498437; + --_second-h: 180; + --_second-c-0: 0.047012120400352186; + --_second-c-1: 0.09481890384235897; --_second-c-2: 0.0816; --_second-c-3: 0.06; - --_second-c-4: 0.10814382461004245; - --_second-c-5: 0.06493861022758024; + --_second-c-4: 0.0960108984048409; + --_second-c-5: 0.05765298510196014; --_radius-box: 3; --_radius-field: 2; diff --git a/locales/da/friends.json b/locales/da/friends.json index 3b1f7a023..8a17841ab 100644 --- a/locales/da/friends.json +++ b/locales/da/friends.json @@ -10,5 +10,10 @@ "friendsList.viewTournament": "", "friendsList.joinSendouQ": "", "friendsList.deleteFriend": "", - "friendsList.deleteConfirm": "" + "friendsList.deleteConfirm": "", + "view.label": "", + "view.friends": "", + "view.teamMembers": "", + "view.all": "", + "teamMembers.empty": "" } diff --git a/locales/da/front.json b/locales/da/front.json index 092064e7e..823ccad12 100644 --- a/locales/da/front.json +++ b/locales/da/front.json @@ -22,7 +22,10 @@ "updates.viewPast": "", "sideNav.myCalendar": "", "sideNav.noEvents": "", + "sideNav.noStreams": "", "sideNav.friends": "", + "sideNav.friends.notLoggedIn": "", + "sideNav.friends.noFriends": "", "sideNav.streams": "", "sideNav.matchStarted": "", "sideNav.tournamentCheckin": "", diff --git a/locales/de/friends.json b/locales/de/friends.json index 3b1f7a023..8a17841ab 100644 --- a/locales/de/friends.json +++ b/locales/de/friends.json @@ -10,5 +10,10 @@ "friendsList.viewTournament": "", "friendsList.joinSendouQ": "", "friendsList.deleteFriend": "", - "friendsList.deleteConfirm": "" + "friendsList.deleteConfirm": "", + "view.label": "", + "view.friends": "", + "view.teamMembers": "", + "view.all": "", + "teamMembers.empty": "" } diff --git a/locales/de/front.json b/locales/de/front.json index 092064e7e..823ccad12 100644 --- a/locales/de/front.json +++ b/locales/de/front.json @@ -22,7 +22,10 @@ "updates.viewPast": "", "sideNav.myCalendar": "", "sideNav.noEvents": "", + "sideNav.noStreams": "", "sideNav.friends": "", + "sideNav.friends.notLoggedIn": "", + "sideNav.friends.noFriends": "", "sideNav.streams": "", "sideNav.matchStarted": "", "sideNav.tournamentCheckin": "", diff --git a/locales/en/common.json b/locales/en/common.json index 26dce6b74..dc52e3698 100644 --- a/locales/en/common.json +++ b/locales/en/common.json @@ -8,8 +8,8 @@ "pages.calendar": "Calendar", "pages.faq": "FAQ", "pages.builds": "Builds", - "pages.analyzer": "Build Analyzer", - "pages.comp-analyzer": "Comp Analyzer", + "pages.analyzer": "Analyzer", + "pages.comp-analyzer": "Comps", "pages.maps": "Map Lists", "pages.plans": "Planner", "pages.object-damage-calculator": "DMG Calc", @@ -22,7 +22,7 @@ "pages.popularBuilds": "Popular Builds", "pages.abilityStats": "Ability Stats", "pages.xsearch": "Top Search", - "pages.leaderboards": "Leaderboard", + "pages.leaderboards": "Rankings", "pages.links": "Links", "pages.art": "Art", "pages.sendouq": "SendouQ", diff --git a/locales/en/friends.json b/locales/en/friends.json index 7fe6a9e5c..4d7f4aaad 100644 --- a/locales/en/friends.json +++ b/locales/en/friends.json @@ -10,5 +10,10 @@ "friendsList.viewTournament": "View tournament", "friendsList.joinSendouQ": "Join SendouQ", "friendsList.deleteFriend": "Delete friend", - "friendsList.deleteConfirm": "Delete {{name}} as a friend?" + "friendsList.deleteConfirm": "Delete {{name}} as a friend?", + "view.label": "View filter", + "view.friends": "Friends", + "view.teamMembers": "Team members", + "view.all": "All", + "teamMembers.empty": "No team members yet" } diff --git a/locales/en/front.json b/locales/en/front.json index 3c11df4f1..38fc66dee 100644 --- a/locales/en/front.json +++ b/locales/en/front.json @@ -22,7 +22,10 @@ "updates.viewPast": "View past updates", "sideNav.myCalendar": "Events", "sideNav.noEvents": "No upcoming events", + "sideNav.noStreams": "No streams currently", "sideNav.friends": "Friends", + "sideNav.friends.notLoggedIn": "Log in to follow your friends' activity", + "sideNav.friends.noFriends": "Add friends to see their activity here", "sideNav.streams": "Streams", "sideNav.matchStarted": "Match #{{matchId}} started!", "sideNav.tournamentCheckin": "Check-in now", diff --git a/locales/es-ES/friends.json b/locales/es-ES/friends.json index 3b1f7a023..8a17841ab 100644 --- a/locales/es-ES/friends.json +++ b/locales/es-ES/friends.json @@ -10,5 +10,10 @@ "friendsList.viewTournament": "", "friendsList.joinSendouQ": "", "friendsList.deleteFriend": "", - "friendsList.deleteConfirm": "" + "friendsList.deleteConfirm": "", + "view.label": "", + "view.friends": "", + "view.teamMembers": "", + "view.all": "", + "teamMembers.empty": "" } diff --git a/locales/es-ES/front.json b/locales/es-ES/front.json index 092064e7e..823ccad12 100644 --- a/locales/es-ES/front.json +++ b/locales/es-ES/front.json @@ -22,7 +22,10 @@ "updates.viewPast": "", "sideNav.myCalendar": "", "sideNav.noEvents": "", + "sideNav.noStreams": "", "sideNav.friends": "", + "sideNav.friends.notLoggedIn": "", + "sideNav.friends.noFriends": "", "sideNav.streams": "", "sideNav.matchStarted": "", "sideNav.tournamentCheckin": "", diff --git a/locales/es-US/friends.json b/locales/es-US/friends.json index 3b1f7a023..8a17841ab 100644 --- a/locales/es-US/friends.json +++ b/locales/es-US/friends.json @@ -10,5 +10,10 @@ "friendsList.viewTournament": "", "friendsList.joinSendouQ": "", "friendsList.deleteFriend": "", - "friendsList.deleteConfirm": "" + "friendsList.deleteConfirm": "", + "view.label": "", + "view.friends": "", + "view.teamMembers": "", + "view.all": "", + "teamMembers.empty": "" } diff --git a/locales/es-US/front.json b/locales/es-US/front.json index 092064e7e..823ccad12 100644 --- a/locales/es-US/front.json +++ b/locales/es-US/front.json @@ -22,7 +22,10 @@ "updates.viewPast": "", "sideNav.myCalendar": "", "sideNav.noEvents": "", + "sideNav.noStreams": "", "sideNav.friends": "", + "sideNav.friends.notLoggedIn": "", + "sideNav.friends.noFriends": "", "sideNav.streams": "", "sideNav.matchStarted": "", "sideNav.tournamentCheckin": "", diff --git a/locales/fr-CA/friends.json b/locales/fr-CA/friends.json index 3b1f7a023..8a17841ab 100644 --- a/locales/fr-CA/friends.json +++ b/locales/fr-CA/friends.json @@ -10,5 +10,10 @@ "friendsList.viewTournament": "", "friendsList.joinSendouQ": "", "friendsList.deleteFriend": "", - "friendsList.deleteConfirm": "" + "friendsList.deleteConfirm": "", + "view.label": "", + "view.friends": "", + "view.teamMembers": "", + "view.all": "", + "teamMembers.empty": "" } diff --git a/locales/fr-CA/front.json b/locales/fr-CA/front.json index 092064e7e..823ccad12 100644 --- a/locales/fr-CA/front.json +++ b/locales/fr-CA/front.json @@ -22,7 +22,10 @@ "updates.viewPast": "", "sideNav.myCalendar": "", "sideNav.noEvents": "", + "sideNav.noStreams": "", "sideNav.friends": "", + "sideNav.friends.notLoggedIn": "", + "sideNav.friends.noFriends": "", "sideNav.streams": "", "sideNav.matchStarted": "", "sideNav.tournamentCheckin": "", diff --git a/locales/fr-EU/friends.json b/locales/fr-EU/friends.json index 3b1f7a023..8a17841ab 100644 --- a/locales/fr-EU/friends.json +++ b/locales/fr-EU/friends.json @@ -10,5 +10,10 @@ "friendsList.viewTournament": "", "friendsList.joinSendouQ": "", "friendsList.deleteFriend": "", - "friendsList.deleteConfirm": "" + "friendsList.deleteConfirm": "", + "view.label": "", + "view.friends": "", + "view.teamMembers": "", + "view.all": "", + "teamMembers.empty": "" } diff --git a/locales/fr-EU/front.json b/locales/fr-EU/front.json index f4faedb0a..9b15ff757 100644 --- a/locales/fr-EU/front.json +++ b/locales/fr-EU/front.json @@ -22,7 +22,10 @@ "updates.viewPast": "Ancienne mise à jour", "sideNav.myCalendar": "", "sideNav.noEvents": "", + "sideNav.noStreams": "", "sideNav.friends": "", + "sideNav.friends.notLoggedIn": "", + "sideNav.friends.noFriends": "", "sideNav.streams": "", "sideNav.matchStarted": "", "sideNav.tournamentCheckin": "", diff --git a/locales/he/friends.json b/locales/he/friends.json index 3b1f7a023..8a17841ab 100644 --- a/locales/he/friends.json +++ b/locales/he/friends.json @@ -10,5 +10,10 @@ "friendsList.viewTournament": "", "friendsList.joinSendouQ": "", "friendsList.deleteFriend": "", - "friendsList.deleteConfirm": "" + "friendsList.deleteConfirm": "", + "view.label": "", + "view.friends": "", + "view.teamMembers": "", + "view.all": "", + "teamMembers.empty": "" } diff --git a/locales/he/front.json b/locales/he/front.json index 092064e7e..823ccad12 100644 --- a/locales/he/front.json +++ b/locales/he/front.json @@ -22,7 +22,10 @@ "updates.viewPast": "", "sideNav.myCalendar": "", "sideNav.noEvents": "", + "sideNav.noStreams": "", "sideNav.friends": "", + "sideNav.friends.notLoggedIn": "", + "sideNav.friends.noFriends": "", "sideNav.streams": "", "sideNav.matchStarted": "", "sideNav.tournamentCheckin": "", diff --git a/locales/it/friends.json b/locales/it/friends.json index 3b1f7a023..8a17841ab 100644 --- a/locales/it/friends.json +++ b/locales/it/friends.json @@ -10,5 +10,10 @@ "friendsList.viewTournament": "", "friendsList.joinSendouQ": "", "friendsList.deleteFriend": "", - "friendsList.deleteConfirm": "" + "friendsList.deleteConfirm": "", + "view.label": "", + "view.friends": "", + "view.teamMembers": "", + "view.all": "", + "teamMembers.empty": "" } diff --git a/locales/it/front.json b/locales/it/front.json index c405e8744..f93e2ff6e 100644 --- a/locales/it/front.json +++ b/locales/it/front.json @@ -22,7 +22,10 @@ "updates.viewPast": "Visualizza aggiornamenti passati", "sideNav.myCalendar": "", "sideNav.noEvents": "", + "sideNav.noStreams": "", "sideNav.friends": "", + "sideNav.friends.notLoggedIn": "", + "sideNav.friends.noFriends": "", "sideNav.streams": "", "sideNav.matchStarted": "", "sideNav.tournamentCheckin": "", diff --git a/locales/ja/friends.json b/locales/ja/friends.json index 3b1f7a023..8a17841ab 100644 --- a/locales/ja/friends.json +++ b/locales/ja/friends.json @@ -10,5 +10,10 @@ "friendsList.viewTournament": "", "friendsList.joinSendouQ": "", "friendsList.deleteFriend": "", - "friendsList.deleteConfirm": "" + "friendsList.deleteConfirm": "", + "view.label": "", + "view.friends": "", + "view.teamMembers": "", + "view.all": "", + "teamMembers.empty": "" } diff --git a/locales/ja/front.json b/locales/ja/front.json index 092064e7e..823ccad12 100644 --- a/locales/ja/front.json +++ b/locales/ja/front.json @@ -22,7 +22,10 @@ "updates.viewPast": "", "sideNav.myCalendar": "", "sideNav.noEvents": "", + "sideNav.noStreams": "", "sideNav.friends": "", + "sideNav.friends.notLoggedIn": "", + "sideNav.friends.noFriends": "", "sideNav.streams": "", "sideNav.matchStarted": "", "sideNav.tournamentCheckin": "", diff --git a/locales/ko/friends.json b/locales/ko/friends.json index 3b1f7a023..8a17841ab 100644 --- a/locales/ko/friends.json +++ b/locales/ko/friends.json @@ -10,5 +10,10 @@ "friendsList.viewTournament": "", "friendsList.joinSendouQ": "", "friendsList.deleteFriend": "", - "friendsList.deleteConfirm": "" + "friendsList.deleteConfirm": "", + "view.label": "", + "view.friends": "", + "view.teamMembers": "", + "view.all": "", + "teamMembers.empty": "" } diff --git a/locales/ko/front.json b/locales/ko/front.json index 092064e7e..823ccad12 100644 --- a/locales/ko/front.json +++ b/locales/ko/front.json @@ -22,7 +22,10 @@ "updates.viewPast": "", "sideNav.myCalendar": "", "sideNav.noEvents": "", + "sideNav.noStreams": "", "sideNav.friends": "", + "sideNav.friends.notLoggedIn": "", + "sideNav.friends.noFriends": "", "sideNav.streams": "", "sideNav.matchStarted": "", "sideNav.tournamentCheckin": "", diff --git a/locales/nl/friends.json b/locales/nl/friends.json index 3b1f7a023..8a17841ab 100644 --- a/locales/nl/friends.json +++ b/locales/nl/friends.json @@ -10,5 +10,10 @@ "friendsList.viewTournament": "", "friendsList.joinSendouQ": "", "friendsList.deleteFriend": "", - "friendsList.deleteConfirm": "" + "friendsList.deleteConfirm": "", + "view.label": "", + "view.friends": "", + "view.teamMembers": "", + "view.all": "", + "teamMembers.empty": "" } diff --git a/locales/nl/front.json b/locales/nl/front.json index 092064e7e..823ccad12 100644 --- a/locales/nl/front.json +++ b/locales/nl/front.json @@ -22,7 +22,10 @@ "updates.viewPast": "", "sideNav.myCalendar": "", "sideNav.noEvents": "", + "sideNav.noStreams": "", "sideNav.friends": "", + "sideNav.friends.notLoggedIn": "", + "sideNav.friends.noFriends": "", "sideNav.streams": "", "sideNav.matchStarted": "", "sideNav.tournamentCheckin": "", diff --git a/locales/pl/friends.json b/locales/pl/friends.json index 3b1f7a023..8a17841ab 100644 --- a/locales/pl/friends.json +++ b/locales/pl/friends.json @@ -10,5 +10,10 @@ "friendsList.viewTournament": "", "friendsList.joinSendouQ": "", "friendsList.deleteFriend": "", - "friendsList.deleteConfirm": "" + "friendsList.deleteConfirm": "", + "view.label": "", + "view.friends": "", + "view.teamMembers": "", + "view.all": "", + "teamMembers.empty": "" } diff --git a/locales/pl/front.json b/locales/pl/front.json index 092064e7e..823ccad12 100644 --- a/locales/pl/front.json +++ b/locales/pl/front.json @@ -22,7 +22,10 @@ "updates.viewPast": "", "sideNav.myCalendar": "", "sideNav.noEvents": "", + "sideNav.noStreams": "", "sideNav.friends": "", + "sideNav.friends.notLoggedIn": "", + "sideNav.friends.noFriends": "", "sideNav.streams": "", "sideNav.matchStarted": "", "sideNav.tournamentCheckin": "", diff --git a/locales/pt-BR/friends.json b/locales/pt-BR/friends.json index 3b1f7a023..8a17841ab 100644 --- a/locales/pt-BR/friends.json +++ b/locales/pt-BR/friends.json @@ -10,5 +10,10 @@ "friendsList.viewTournament": "", "friendsList.joinSendouQ": "", "friendsList.deleteFriend": "", - "friendsList.deleteConfirm": "" + "friendsList.deleteConfirm": "", + "view.label": "", + "view.friends": "", + "view.teamMembers": "", + "view.all": "", + "teamMembers.empty": "" } diff --git a/locales/pt-BR/front.json b/locales/pt-BR/front.json index 092064e7e..823ccad12 100644 --- a/locales/pt-BR/front.json +++ b/locales/pt-BR/front.json @@ -22,7 +22,10 @@ "updates.viewPast": "", "sideNav.myCalendar": "", "sideNav.noEvents": "", + "sideNav.noStreams": "", "sideNav.friends": "", + "sideNav.friends.notLoggedIn": "", + "sideNav.friends.noFriends": "", "sideNav.streams": "", "sideNav.matchStarted": "", "sideNav.tournamentCheckin": "", diff --git a/locales/ru/friends.json b/locales/ru/friends.json index 3b1f7a023..8a17841ab 100644 --- a/locales/ru/friends.json +++ b/locales/ru/friends.json @@ -10,5 +10,10 @@ "friendsList.viewTournament": "", "friendsList.joinSendouQ": "", "friendsList.deleteFriend": "", - "friendsList.deleteConfirm": "" + "friendsList.deleteConfirm": "", + "view.label": "", + "view.friends": "", + "view.teamMembers": "", + "view.all": "", + "teamMembers.empty": "" } diff --git a/locales/ru/front.json b/locales/ru/front.json index 4422f41f0..ca2ed46cd 100644 --- a/locales/ru/front.json +++ b/locales/ru/front.json @@ -22,7 +22,10 @@ "updates.viewPast": "Посмотреть прошлые новости", "sideNav.myCalendar": "", "sideNav.noEvents": "", + "sideNav.noStreams": "", "sideNav.friends": "", + "sideNav.friends.notLoggedIn": "", + "sideNav.friends.noFriends": "", "sideNav.streams": "", "sideNav.matchStarted": "", "sideNav.tournamentCheckin": "", diff --git a/locales/zh/friends.json b/locales/zh/friends.json index 3b1f7a023..8a17841ab 100644 --- a/locales/zh/friends.json +++ b/locales/zh/friends.json @@ -10,5 +10,10 @@ "friendsList.viewTournament": "", "friendsList.joinSendouQ": "", "friendsList.deleteFriend": "", - "friendsList.deleteConfirm": "" + "friendsList.deleteConfirm": "", + "view.label": "", + "view.friends": "", + "view.teamMembers": "", + "view.all": "", + "teamMembers.empty": "" } diff --git a/locales/zh/front.json b/locales/zh/front.json index 092064e7e..823ccad12 100644 --- a/locales/zh/front.json +++ b/locales/zh/front.json @@ -22,7 +22,10 @@ "updates.viewPast": "", "sideNav.myCalendar": "", "sideNav.noEvents": "", + "sideNav.noStreams": "", "sideNav.friends": "", + "sideNav.friends.notLoggedIn": "", + "sideNav.friends.noFriends": "", "sideNav.streams": "", "sideNav.matchStarted": "", "sideNav.tournamentCheckin": "", diff --git a/migrations/120-friends-system.js b/migrations/120-friends-system.js index 5b35da31c..7b771cfed 100644 --- a/migrations/120-friends-system.js +++ b/migrations/120-friends-system.js @@ -37,6 +37,10 @@ export function up(db) { `create index friend_request_receiver_id on "FriendRequest"("receiverId")`, ).run(); + db.prepare( + `create index all_team_member_user_id on "AllTeamMember"("userId")`, + ).run(); + db.prepare( /* sql */ ` INSERT INTO "Friendship" ("userOneId", "userTwoId")