diff --git a/app/components/Avatar.tsx b/app/components/Avatar.tsx index 1e9365d74..dec646b98 100644 --- a/app/components/Avatar.tsx +++ b/app/components/Avatar.tsx @@ -122,6 +122,12 @@ export function Avatar({ const [isErrored, setIsErrored] = React.useState(false); const isClient = useHydrated(); + // an can finish loading (and fail) before React hydrates and attaches onError, so that + // error is missed — re-check on mount and fall back manually so SSR'd avatars still heal + const checkAlreadyErrored = (img: HTMLImageElement | null) => { + if (img?.complete && img.naturalWidth === 0) setIsErrored(true); + }; + const identiconSource = identiconInput ?? user?.discordId ?? "unknown"; const src = url @@ -141,6 +147,7 @@ export function Avatar({ return (
{alt} svg { + stroke-width: 3; + } +} + +.badgeMd { + width: 1.25rem; + height: 1.25rem; + + & > svg { + width: 0.75rem; + height: 0.75rem; + } +} + +.badgeSm { + width: 0.9rem; + height: 0.9rem; + border-width: 1.5px; + + & > svg { + width: 0.6rem; + height: 0.6rem; + } +} + +.badgeXs { + width: 0.65rem; + height: 0.65rem; + border-width: 1.5px; + + & > svg { + width: 0.45rem; + height: 0.45rem; + } +} + +.positive { + background-color: var(--color-success); +} + +.negative { + background-color: var(--color-error); +} + +.neutral { + background-color: var(--color-text-high); +} diff --git a/app/components/NoteAvatar.tsx b/app/components/NoteAvatar.tsx new file mode 100644 index 000000000..01a099cbf --- /dev/null +++ b/app/components/NoteAvatar.tsx @@ -0,0 +1,61 @@ +import clsx from "clsx"; +import { Check, Minus, X } from "lucide-react"; +import type * as React from "react"; +import type { Tables } from "~/db/tables"; +import styles from "./NoteAvatar.module.css"; + +type Sentiment = Tables["PrivateUserNote"]["sentiment"]; + +const BADGE_CLASS: Record = { + POSITIVE: styles.positive, + NEUTRAL: styles.neutral, + NEGATIVE: styles.negative, +}; + +const BADGE_ICON: Record = { + POSITIVE: , + NEUTRAL: , + NEGATIVE: , +}; + +const SIZE_CLASS = { + xs: styles.badgeXs, + sm: styles.badgeSm, + md: styles.badgeMd, +} as const; + +/** + * Wraps an avatar (or any node) and overlays a sentiment badge on the bottom-left corner when + * `sentiment` is set: POSITIVE → green check, NEGATIVE → red cross, NEUTRAL → grey dash. Renders the + * children without a badge when `sentiment` is `null`/`undefined`. `size` scales the badge to match + * the wrapped avatar (`xs` for tiny avatars, `sm` for small avatars, `md` for large ones). + */ +export function NoteAvatar({ + sentiment, + size = "md", + className, + children, +}: { + sentiment?: Sentiment | null; + size?: keyof typeof SIZE_CLASS; + className?: string; + children: React.ReactNode; +}) { + return ( +
+ {children} + {sentiment ? ( + + {BADGE_ICON[sentiment]} + + ) : null} +
+ ); +} diff --git a/app/components/match-page/MatchRosterTab.module.css b/app/components/match-page/MatchRosterTab.module.css index 495b6c0d3..b2643955f 100644 --- a/app/components/match-page/MatchRosterTab.module.css +++ b/app/components/match-page/MatchRosterTab.module.css @@ -122,24 +122,16 @@ gap: var(--s-2); } -.memberMenuTrigger { - background: none; - border: 0; - padding: 0; - color: inherit; - font: inherit; - text-align: inherit; - cursor: pointer; -} - -.friendCodeHeader { - text-align: center; -} - -.memberMenuHeader { +.memberNameStack { display: flex; flex-direction: column; - gap: var(--s-0-5); + line-height: 1.2; +} + +.memberInGameName { + font-size: var(--font-2xs); + color: var(--color-text-high); + font-weight: var(--weight-semi); } .memberTier { @@ -156,18 +148,6 @@ font-size: var(--font-2xs); } -.plusTier { - display: flex; - align-items: center; - gap: var(--s-0-5); - background-color: var(--color-bg-higher); - border-radius: var(--radius-full); - padding: var(--s-0-5) var(--s-1-5); - padding-inline-start: var(--s-1); - font-weight: var(--weight-semi); - color: var(--color-text); -} - .subbedOutTrigger { display: flex; align-items: center; diff --git a/app/components/match-page/MatchRosterTab.tsx b/app/components/match-page/MatchRosterTab.tsx index 1dd5eacc7..51ae546a5 100644 --- a/app/components/match-page/MatchRosterTab.tsx +++ b/app/components/match-page/MatchRosterTab.tsx @@ -1,28 +1,22 @@ import clsx from "clsx"; -import { Armchair, Edit, User } from "lucide-react"; +import { Armchair, Edit } from "lucide-react"; import { useState } from "react"; -import { Button as ReactAriaButton } from "react-aria-components"; import { useTranslation } from "react-i18next"; import { Link } from "react-router"; import { Avatar } from "~/components/Avatar"; import { SendouButton } from "~/components/elements/Button"; -import { - SendouMenu, - SendouMenuItem, - SendouMenuSection, -} from "~/components/elements/Menu"; import { SendouPopover } from "~/components/elements/Popover"; import { Image, TierImage } from "~/components/Image"; +import { NoteAvatar } from "~/components/NoteAvatar"; import type { TierName } from "~/features/mmr/mmr-constants"; +import { + UserCard, + useUserCardData, +} from "~/features/user-card/components/UserCard"; import type { MainWeaponId } from "~/modules/in-game-lists/types"; import invariant from "~/utils/invariant"; import type { CommonUser } from "~/utils/kysely.server"; -import { - navIconUrl, - preferenceEmojiUrl, - tierImageUrl, - userPage, -} from "~/utils/urls"; +import { tierImageUrl } from "~/utils/urls"; import { SendouTabPanel } from "../elements/Tabs"; import styles from "./MatchRosterTab.module.css"; import { TAB_KEYS } from "./MatchTabs"; @@ -30,10 +24,7 @@ import { WeaponPool } from "./WeaponPool"; type RosterTabMember = CommonUser & { tier?: { name: TierName; isPlus: boolean } | "CALCULATING"; - plusTier?: number | null; weaponPool?: Array; - friendCode?: string | null; - privateNote?: { sentiment: "POSITIVE" | "NEUTRAL" | "NEGATIVE" } | null; inGameName?: string | null; }; @@ -166,10 +157,7 @@ function TeamRoster({
- +
))} @@ -403,27 +391,14 @@ function MemberTierPopoverContent({ ); } -function MemberMeta({ - plusTier, - weaponPool, -}: { - plusTier?: number | null; - weaponPool?: Array; -}) { - const hasPlusTier = typeof plusTier === "number"; +function MemberMeta({ weaponPool }: { weaponPool?: Array }) { const hasWeapons = weaponPool && weaponPool.length > 0; - if (!hasPlusTier && !hasWeapons) return null; + if (!hasWeapons) return null; return (
- {hasPlusTier ? ( -
- - {plusTier} -
- ) : null} - {hasWeapons ? : null} +
); } @@ -465,72 +440,21 @@ function RosterMemberLink({ member: RosterTabMember; className?: string; }) { - const { t } = useTranslation(["friends", "q"]); - - const showNoteItem = member.privateNote !== undefined; - const useMenu = !!member.friendCode || showNoteItem; - - const nameContent = {member.inGameName ?? member.username}; - - if (!useMenu) { - return ( - - - {nameContent} - - ); - } - - const headerContent = member.friendCode ? ( -
- {`SW-${member.friendCode}`} -
- ) : undefined; + const cardData = useUserCardData(member.id); return ( - + + + - {nameContent} - - } - > - - }> - {t("friends:friendsList.viewUserPage")} - - {showNoteItem ? ( - - ) : ( - - ) - } - > - {member.privateNote - ? t("q:looking.groups.editNote") - : t("q:looking.groups.addNote")} - - ) : null} - - + +
+ {member.username} + {member.inGameName ? ( + {member.inGameName} + ) : null} +
+ + ); } diff --git a/app/db/seed/index.ts b/app/db/seed/index.ts index 724e2e581..f47149aa5 100644 --- a/app/db/seed/index.ts +++ b/app/db/seed/index.ts @@ -27,9 +27,11 @@ import { } from "~/features/plus-voting/core"; import * as PlusVotingRepository from "~/features/plus-voting/PlusVotingRepository.server"; import * as ScrimPostRepository from "~/features/scrims/ScrimPostRepository.server"; +import { LUTI_DIVS } from "~/features/scrims/scrims-constants"; import * as SQGroupRepository from "~/features/sendouq/SQGroupRepository.server"; import * as ReportedWeaponRepository from "~/features/sendouq-match/ReportedWeaponRepository.server"; import * as SQMatchRepository from "~/features/sendouq-match/SQMatchRepository.server"; +import { PRESET_COLORS } from "~/features/tier-list-maker/tier-list-maker-constants"; import { clearAllTournamentDataCache } from "~/features/tournament-bracket/core/Tournament.server"; import * as TournamentLFGRepository from "~/features/tournament-lfg/TournamentLFGRepository.server"; import * as TournamentOrganizationRepository from "~/features/tournament-organization/TournamentOrganizationRepository.server"; @@ -179,6 +181,7 @@ const basicSeeds = (variation?: SeedVariation | null) => [ adminUserWeaponPool, adminUserWidgets, userProfiles, + userCardData, variation === "TEAM_MAP_PREFS" ? undefined : userMapModePreferences, userMatchProfileWeaponPool, seedingSkills, @@ -900,6 +903,66 @@ async function userProfiles() { } } +/** + * SendouQ groups draw their members from the lowest user ids, so users at or below this id are + * guaranteed full user card data (the rest get a realistic mix of set/unset fields). + */ +const USER_CARD_SEEDED_USER_ID_CEILING = 100; + +async function userCardData() { + for (let id = 2; id < 500; id++) { + if (id === ADMIN_ID || id === NZAP_TEST_ID) continue; + + const guaranteed = id <= USER_CARD_SEEDED_USER_ID_CEILING; + + sql + .prepare( + /* sql */ ` + update "User" + set + "shortBio" = @shortBio, + "div" = @div, + "bannerPresetImg" = @bannerPresetImg, + "unverifiedPeakXP" = @unverifiedPeakXP + where "id" = @id`, + ) + .run({ + id, + shortBio: + guaranteed || faker.number.float(1) > 0.4 + ? faker.lorem.sentence() + : null, + div: + guaranteed || faker.number.float(1) > 0.5 + ? faker.helpers.arrayElement(LUTI_DIVS) + : null, + bannerPresetImg: randomBannerPresetImg(), + unverifiedPeakXP: + guaranteed || faker.number.float(1) > 0.6 ? randomPeakXp() : null, + }); + } +} + +/** Mix of the three banner sources: null (color derived from user id), a stage banner, an explicit color. */ +function randomBannerPresetImg() { + const roll = faker.number.float(1); + if (roll < 0.34) return null; + if (roll < 0.67) return String(faker.helpers.arrayElement(stageIds)); + return faker.helpers.arrayElement(PRESET_COLORS); +} + +/** Self-reported peak XP with exactly one division defined (the other null), as the column expects. */ +function randomPeakXp() { + const points = faker.number.int({ min: 2000, max: 3500 }); + const isTentatek = faker.datatype.boolean(); + + return JSON.stringify({ + overall: points, + tentatek: isTentatek ? points : null, + takoroka: isTentatek ? null : points, + }); +} + const randomPreferences = (): UserMapModePreferences => { const modes: UserMapModePreferences["modes"] = modesShort.flatMap((mode) => { if (faker.number.float(1) > 0.5 && mode !== "SZ") return []; @@ -3497,16 +3560,7 @@ const SENDOU_FRIEND_IDS_IN_TOURNAMENT_LFG = [100, 101]; const SENDOU_FRIEND_IDS_OTHER = [102, 103]; async function friendships(variation?: SeedVariation | null) { - const allFriendIds = [ - ...SENDOU_FRIEND_IDS_IN_LOOKING_GROUPS, - ...SENDOU_FRIEND_IDS_IN_TOURNAMENT_LFG, - ...SENDOU_FRIEND_IDS_OTHER, - ]; - - for (const friendId of allFriendIds) { - const userOneId = Math.min(ADMIN_ID, friendId); - const userTwoId = Math.max(ADMIN_ID, friendId); - + const insertFriendship = (idA: number, idB: number) => sql .prepare( /* sql */ ` @@ -3514,9 +3568,24 @@ async function friendships(variation?: SeedVariation | null) { values (@userOneId, @userTwoId) `, ) - .run({ userOneId, userTwoId }); + .run({ userOneId: Math.min(idA, idB), userTwoId: Math.max(idA, idB) }); + + const allFriendIds = [ + ...SENDOU_FRIEND_IDS_IN_LOOKING_GROUPS, + ...SENDOU_FRIEND_IDS_IN_TOURNAMENT_LFG, + ...SENDOU_FRIEND_IDS_OTHER, + ]; + + for (const friendId of allFriendIds) { + insertFriendship(ADMIN_ID, friendId); } + // friendships between some looking-group owners so their user cards show mutual friends with the + // admin, while others (e.g. 153 and the additional members) intentionally have none + insertFriendship(150, 151); + insertFriendship(150, 152); + insertFriendship(151, 152); + if (variation === "NO_SQ_GROUPS" || variation === "TEAM_MAP_PREFS") return; for (const friendId of SENDOU_FRIEND_IDS_IN_LOOKING_GROUPS) { diff --git a/app/db/tables.ts b/app/db/tables.ts index 805efb357..ca82b5ddb 100644 --- a/app/db/tables.ts +++ b/app/db/tables.ts @@ -288,7 +288,6 @@ export type ParsedMemento = { users: Record< number, { - plusTier?: PlusTier["tier"]; skill?: TieredSkill | "CALCULATING"; skillDifference?: UserSkillDifference; } @@ -500,12 +499,21 @@ export interface SeedingSkill { type: "RANKED" | "UNRANKED"; } +export interface PeakXP { + /** Peak XP across all divisions */ + overall: number; + /** Peak XP (Takoroka division) */ + takoroka: number | null; + /** Peak XP (Tentatek division) */ + tentatek: number | null; +} + export interface SplatoonPlayer { id: GeneratedAlways; splId: string; userId: number | null; /** Players best XP across both divisions. Denormalized for performance. */ - peakXp: number | null; + peakXp: JSONColumnTypeNullable; } export interface TaggedArt { @@ -1087,11 +1095,17 @@ export type Pronouns = { object: (typeof OBJECT_PRONOUNS)[number]; }; +/** Card stat types that can be hidden from a user's card (kept here so `User.hiddenCardStats` does not import a feature module; keep in sync with the feature's `UserCardStat["type"]`). */ +export type HideableUserCardStat = "XP" | "DIV"; + export interface User { /** 1 = permabanned, timestamp = ban active till then */ banned: Generated; bannedReason: string | null; + /** Shown on old user profile and Plus Voting */ bio: string | null; + /** Shown on user card */ + shortBio: string | null; commissionsOpen: Generated; commissionsOpenedAt: number | null; commissionText: string | null; @@ -1136,8 +1150,16 @@ export interface User { /** User creation date. Can be null because we did not always save this. */ createdAt: number | null; joinOrder: number | null; - /** Last message used when creating a tournament sub post */ - lastSubMessage: string | null; + /** User card banner default selection, hex code or stage id. Note: supporters can also upload banner (stored in UserSubmittedImage, referenced by `bannerImgId` which takes precedence) */ + bannerPresetImg: JSONColumnTypeNullable; + /** Supporter-uploaded user card banner (UserSubmittedImage id). Takes precedence over `bannerPresetImg`. */ + bannerImgId: number | null; + /** Card stat types the user has chosen to hide from their card. */ + hiddenCardStats: JSONColumnTypeNullable>; + /** Div in the latest finished LUTI (e.g. "2" or "X"). Must have been in a team that did not drop and the user played at least one match (got result as well) */ + div: string | null; + /** Peak XP as indicated by the user. Should have either `takoroka` or `tentatek` key defined but not both. */ + unverifiedPeakXP: JSONColumnTypeNullable; } /** Represents User joined with PlusTier table */ @@ -1290,6 +1312,9 @@ export interface VideoMatchPlayer { weaponSplId: number; } +/** `WEST` = Tentatek division, `JPN` = Takoroka division. */ +export type XRankPlacementRegion = "WEST" | "JPN"; + export interface XRankPlacement { badges: string; bannerSplId: number; @@ -1301,7 +1326,7 @@ export interface XRankPlacement { playerId: number; power: number; rank: number; - region: "WEST" | "JPN"; + region: XRankPlacementRegion; title: string; weaponSplId: MainWeaponId; year: number; diff --git a/app/features/api-public/routes/user.$identifier.ts b/app/features/api-public/routes/user.$identifier.ts index a42d4678e..e57b6e3e4 100644 --- a/app/features/api-public/routes/user.$identifier.ts +++ b/app/features/api-public/routes/user.$identifier.ts @@ -4,6 +4,7 @@ import { z } from "zod"; import { db } from "~/db/sql"; import * as Seasons from "~/features/mmr/core/Seasons"; import { userSkills as _userSkills } from "~/features/mmr/tiered.server"; +import { peakXpOverallSql } from "~/features/top-search/XRankPlacementRepository.server"; import * as UserRepository from "~/features/user-page/UserRepository.server"; import { i18next } from "~/modules/i18n/i18next.server"; import { safeNumberParse } from "~/utils/number"; @@ -44,7 +45,7 @@ export const loader = async ({ params }: LoaderFunctionArgs) => { .whereRef("UserWeapon.userId", "=", "User.id") .orderBy("UserWeapon.order", "asc"), ).as("weapons"), - "SplatoonPlayer.peakXp", + peakXpOverallSql().as("peakXp"), jsonArrayFrom( eb .selectFrom("TeamMemberWithSecondary") diff --git a/app/features/badges/BadgeRepository.server.test.ts b/app/features/badges/BadgeRepository.server.test.ts index 0129447a8..16a7e73a9 100644 --- a/app/features/badges/BadgeRepository.server.test.ts +++ b/app/features/badges/BadgeRepository.server.test.ts @@ -65,7 +65,21 @@ async function insertSplatoonPlayer(args: { userId: number | null; peakXp: number | null; }) { - await db.insertInto("SplatoonPlayer").values(args).execute(); + await db + .insertInto("SplatoonPlayer") + .values({ + splId: args.splId, + userId: args.userId, + peakXp: + args.peakXp === null + ? null + : JSON.stringify({ + overall: args.peakXp, + tentatek: args.peakXp, + takoroka: null, + }), + }) + .execute(); } async function findBadgeByCode(code: string) { diff --git a/app/features/badges/BadgeRepository.server.ts b/app/features/badges/BadgeRepository.server.ts index c64f2accd..9cef45b55 100644 --- a/app/features/badges/BadgeRepository.server.ts +++ b/app/features/badges/BadgeRepository.server.ts @@ -2,6 +2,7 @@ import type { ExpressionBuilder, NotNull } from "kysely"; import { jsonArrayFrom, jsonObjectFrom } from "kysely/helpers/sqlite"; import { db } from "~/db/sql"; import type { DB } from "~/db/tables"; +import { peakXpOverallSql } from "~/features/top-search/XRankPlacementRepository.server"; import { sortBadgesByFavorites } from "~/features/user-page/core/badge-sorting.server"; import invariant from "~/utils/invariant"; import { commonUserSelect } from "~/utils/kysely.server"; @@ -232,7 +233,7 @@ export async function syncXPBadges() { const userTopXPowers = await trx .selectFrom("SplatoonPlayer") - .select(["userId", "peakXp"]) + .select(["userId", peakXpOverallSql().as("peakXp")]) .where("userId", "is not", null) .where("peakXp", "is not", null) .$narrowType<{ userId: NotNull; peakXp: NotNull }>() diff --git a/app/features/components-showcase/routes/components.tsx b/app/features/components-showcase/routes/components.tsx index 3391b29d3..b30e6a8f4 100644 --- a/app/features/components-showcase/routes/components.tsx +++ b/app/features/components-showcase/routes/components.tsx @@ -49,12 +49,19 @@ import { SubmitButton } from "~/components/SubmitButton"; import { SubNav, SubNavLink } from "~/components/SubNav"; import { Table } from "~/components/Table"; import { WeaponSelect } from "~/components/WeaponSelect"; +import { UserCard } from "~/features/user-card/components/UserCard"; +import type { UserCardData } from "~/features/user-card/user-card-types"; import type { CustomFieldRenderProps } from "~/form/FormField"; import { SendouForm } from "~/form/SendouForm"; import type { MainWeaponId, StageId } from "~/modules/in-game-lists/types"; +import type { SendouRouteHandle } from "~/utils/remix.server"; import styles from "../components-showcase.module.css"; import { formFieldsShowcaseSchema } from "../form-examples-schema"; +export const handle: SendouRouteHandle = { + i18n: ["user", "q"], +}; + export const SECTIONS = [ { title: "Buttons", id: "buttons", component: ButtonsSection }, { title: "Alerts", id: "alerts", component: AlertsSection }, @@ -78,6 +85,7 @@ export const SECTIONS = [ { title: "Table", id: "table", component: TableSection }, { title: "Pagination", id: "pagination", component: PaginationSection }, { title: "Avatar", id: "avatar", component: AvatarSection }, + { title: "User Card", id: "user-card", component: UserCardSection }, { title: "Form Messages", id: "form-messages", @@ -1565,6 +1573,58 @@ function AvatarSection({ id }: { id: string }) { ); } +const USER_CARD_DATA = { + id: 1, + username: "Sendou", + discordId: "79237403620945920", + discordAvatar: null, + customUrl: "sendou", + customAvatarUrl: null, + banner: { type: "STAGE", stageId: 5 }, + shortBio: "Very show bio goes here maybe max two lines that gets clamped.", + customTheme: null, + friendCode: "1234-1234-1234", + freeAgentPostId: 1, + privateNote: { + text: "Played with them, very friendly", + sentiment: "POSITIVE", + updatedAt: 1704067200, + }, + stats: [ + { + type: "XP", + values: [ + { isVerified: false, region: "JPN", points: 3123 }, + { isVerified: true, region: "JPN", points: 2901 }, + ], + }, + { + type: "SEASON", + top: 59, + value: { + isPlus: true, + name: "LEVIATHAN", + }, + }, + { type: "DIV", value: "1" }, + { type: "PLUS", value: 1 }, + ], +} satisfies UserCardData; + +function UserCardSection({ id }: { id: string }) { + return ( +
+ User Card + +
+ + Sendou + +
+
+ ); +} + function FormMessageSection({ id }: { id: string }) { return (
diff --git a/app/features/lfg/components/LFGPost.module.css b/app/features/lfg/components/LFGPost.module.css index 8f3c0f957..638741b4c 100644 --- a/app/features/lfg/components/LFGPost.module.css +++ b/app/features/lfg/components/LFGPost.module.css @@ -117,21 +117,3 @@ height: 14px; } } - -.tier { - position: absolute; - top: -3px; - right: 4px; -} - -.tierPill { - width: 60px; -} - -.tierPillStart { - border-radius: 0 var(--radius-box) var(--radius-box) 0; -} - -.tierPillEnd { - border-radius: var(--radius-box) 0 0 var(--radius-box); -} diff --git a/app/features/lfg/components/LFGPost.tsx b/app/features/lfg/components/LFGPost.tsx index 407053d03..20bf8e814 100644 --- a/app/features/lfg/components/LFGPost.tsx +++ b/app/features/lfg/components/LFGPost.tsx @@ -8,41 +8,36 @@ import { Divider } from "~/components/Divider"; import { SendouButton } from "~/components/elements/Button"; import { Flag } from "~/components/Flag"; import { FormWithConfirm } from "~/components/FormWithConfirm"; -import { Image, TierImage, WeaponImage } from "~/components/Image"; +import { WeaponImage } from "~/components/Image"; import { LocaleTime } from "~/components/LocaleTime"; +import { NoteAvatar } from "~/components/NoteAvatar"; import { useUser } from "~/features/auth/core/user"; -import * as Seasons from "~/features/mmr/core/Seasons"; -import type { TieredSkill } from "~/features/mmr/tiered.server"; +import { + UserCard, + useUserCardData, +} from "~/features/user-card/components/UserCard"; import { useFormatDistanceToNow } from "~/hooks/intl/useFormatDistanceToNow"; import { useHydrated } from "~/hooks/useHydrated"; import { useHasRole } from "~/modules/permissions/hooks"; import { databaseTimestampToDate } from "~/utils/dates"; -import { lfgNewPostPage, navIconUrl, userPage } from "~/utils/urls"; +import { lfgNewPostPage } from "~/utils/urls"; import { hourDifferenceBetweenTimezones } from "../core/timezone"; -import type { LFGLoaderData, TiersMap } from "../routes/lfg"; +import type { LFGLoaderData } from "../routes/lfg"; import styles from "./LFGPost.module.css"; type Post = LFGLoaderData["posts"][number]; -export function LFGPost({ - post, - tiersMap, -}: { - post: Post; - tiersMap: TiersMap; -}) { +export function LFGPost({ post }: { post: Post }) { if (post.team) { - return ( - - ); + return ; } - return ; + return ; } const USER_POST_EXPANDABLE_CRITERIA = 300; -function UserLFGPost({ post, tiersMap }: { post: Post; tiersMap: TiersMap }) { +function UserLFGPost({ post }: { post: Post }) { const user = useUser(); const isAdmin = useHasRole("ADMIN"); const [isExpanded, setIsExpanded] = React.useState(false); @@ -57,13 +52,7 @@ function UserLFGPost({ post, tiersMap }: { post: Post; tiersMap: TiersMap }) { @@ -88,10 +77,8 @@ function UserLFGPost({ post, tiersMap }: { post: Post; tiersMap: TiersMap }) { function TeamLFGPost({ post, - tiersMap, }: { post: Post & { team: NonNullable }; - tiersMap: TiersMap; }) { const isHydrated = useHydrated(); const user = useUser(); @@ -120,13 +107,9 @@ function TeamLFGPost({ {isExpanded ? ( - + ) : ( - + )}
@@ -155,42 +138,21 @@ function PostTeamLogoHeader({ team }: { team: NonNullable }) { ); } -function PostTeamMembersPeek({ - team, - tiersMap, -}: { - team: NonNullable; - tiersMap: TiersMap; -}) { +function PostTeamMembersPeek({ team }: { team: NonNullable }) { return (
{team.members.map((member) => ( - + ))}
); } -function PostTeamMembersFull({ - team, - tiersMap, - postId, -}: { - team: NonNullable; - tiersMap: TiersMap; - postId: number; -}) { +function PostTeamMembersFull({ team }: { team: NonNullable }) { return (
{team.members.map((member) => ( -
- - -
+ ))}
); @@ -198,23 +160,21 @@ function PostTeamMembersFull({ function PostTeamMember({ member, - tiersMap, }: { member: NonNullable["members"][number]; - tiersMap: TiersMap; }) { - const tiers = tiersMap.get(member.id); - const tier = tiers?.latest ?? tiers?.previous; + const cardData = useUserCardData(member.id); return (
-
- - - {member.username} - - {tier ? : null} -
+ + + + + + {member.username} + +
); } @@ -226,17 +186,24 @@ function PostUserHeader({ author: Post["author"]; includeWeapons: boolean; }) { + const cardData = useUserCardData(author.id); + return (
- -
-
- - {author.username} - {" "} - {author.country ? : null} -
+
+ + + + + + {author.username} + + {" "} + {author.country ? : null}
{includeWeapons ? ( @@ -291,16 +258,12 @@ function PostTime({ function PostPills({ timezone, - plusTier, languages, - tiers, canEdit, postId, }: { timezone?: string | null; - plusTier?: number | null; languages?: string | null; - tiers?: NonNullable>; canEdit?: boolean; postId: number; }) { @@ -316,10 +279,6 @@ function PostPills({ )} {!isHydrated && } - {typeof plusTier === "number" && ( - - )} - {tiers && } {typeof languages === "string" && ( )} @@ -332,66 +291,6 @@ function PostTimezonePillPlaceholder() { return
; } -const currentSeasonNth = Seasons.currentOrPrevious()!.nth; - -function PostSkillPills({ - tiers, -}: { - tiers: NonNullable>; -}) { - const hasBoth = tiers.latest && tiers.previous; - - return ( -
- {tiers.latest ? ( - - ) : null} - {tiers.previous ? ( - - ) : null} -
- ); -} - -function PostSkillPill({ - seasonNth, - tier, - cut, -}: { - seasonNth: number; - tier: TieredSkill["tier"]; - cut?: "START" | "END"; -}) { - return ( -
- S{seasonNth} - -
- ); -} - -function PostPlusServerPill({ plusTier }: { plusTier: number }) { - return ( -
- - {plusTier} -
- ); -} - function PostTimezonePill({ timezone }: { timezone: string }) { const userTimezone = Intl.DateTimeFormat().resolvedOptions().timeZone; const diff = hourDifferenceBetweenTimezones(userTimezone, timezone); diff --git a/app/features/lfg/loaders/lfg.server.ts b/app/features/lfg/loaders/lfg.server.ts index 614fcff17..83bba8572 100644 --- a/app/features/lfg/loaders/lfg.server.ts +++ b/app/features/lfg/loaders/lfg.server.ts @@ -1,7 +1,9 @@ +import * as R from "remeda"; import { getUser } from "~/features/auth/core/user.server"; import * as Seasons from "~/features/mmr/core/Seasons"; import type { TieredSkill } from "~/features/mmr/tiered.server"; import { userSkills } from "~/features/mmr/tiered.server"; +import * as UserCardRepository from "~/features/user-card/UserCardRepository.server"; import type { Unpacked } from "~/utils/types"; import * as LFGRepository from "../LFGRepository.server"; @@ -9,9 +11,19 @@ export const loader = async () => { const user = getUser(); const posts = await LFGRepository.posts(user); + const cardUserIds = R.unique( + posts.flatMap((post) => [ + post.author.id, + ...(post.team?.members ?? []).map((member) => member.id), + ]), + ); + return { posts, tiersMap: postsUsersTiersMap(posts), + ...(await UserCardRepository.userCards({ + userIds: cardUserIds, + })), }; }; diff --git a/app/features/lfg/routes/lfg.tsx b/app/features/lfg/routes/lfg.tsx index eec5e49b3..722f81858 100644 --- a/app/features/lfg/routes/lfg.tsx +++ b/app/features/lfg/routes/lfg.tsx @@ -31,7 +31,7 @@ import styles from "./lfg.module.css"; export { action, loader }; export const handle: SendouRouteHandle = { - i18n: ["lfg"], + i18n: ["lfg", "user", "q"], breadcrumb: () => ({ imgPath: navIconUrl("lfg"), href: LFG_PAGE, @@ -131,7 +131,7 @@ export default function LFGPage() { className={clsx("stack sm", styles.post)} > {showExpiryAlert(post) ? : null} - +
))} {filteredPosts.length === 0 ? ( diff --git a/app/features/live-streams/LiveStreamRepository.server.ts b/app/features/live-streams/LiveStreamRepository.server.ts index bf52a5e82..c51bb9c6c 100644 --- a/app/features/live-streams/LiveStreamRepository.server.ts +++ b/app/features/live-streams/LiveStreamRepository.server.ts @@ -1,5 +1,6 @@ import { db } from "~/db/sql"; import type { Tables, TablesInsertable } from "~/db/tables"; +import { peakXpOverallSql } from "~/features/top-search/XRankPlacementRepository.server"; import { commonUserSelect } from "~/utils/kysely.server"; import * as StreamRanking from "../sidebar/core/StreamRanking"; @@ -34,15 +35,11 @@ export function findXRankStreams() { .selectFrom("LiveStream") .innerJoin("User", "User.twitch", "LiveStream.twitch") .innerJoin("SplatoonPlayer", "SplatoonPlayer.userId", "User.id") - .where( - "SplatoonPlayer.peakXp", - ">=", - StreamRanking.minXpForStreamToBeShown(), - ) + .where(peakXpOverallSql(), ">=", StreamRanking.minXpForStreamToBeShown()) .where("LiveStream.twitch", "is not", null) .select((eb) => [ ...commonUserSelect(eb), - "SplatoonPlayer.peakXp", + peakXpOverallSql().as("peakXp"), "LiveStream.viewerCount", "LiveStream.thumbnailUrl", "LiveStream.twitch as twitchUsername", diff --git a/app/features/match-page-test/routes/match-page-test.tsx b/app/features/match-page-test/routes/match-page-test.tsx index 7bfc98707..dd4f52350 100644 --- a/app/features/match-page-test/routes/match-page-test.tsx +++ b/app/features/match-page-test/routes/match-page-test.tsx @@ -196,7 +196,6 @@ export default function MatchPageTestRoute() { discordAvatar: null, customUrl: "sendou", tier: { name: "LEVIATHAN", isPlus: true }, - plusTier: 1, weaponPool: [0, 2000, 4000], customAvatarUrl: null, }, @@ -207,7 +206,6 @@ export default function MatchPageTestRoute() { discordAvatar: null, customUrl: null, tier: { name: "DIAMOND", isPlus: false }, - plusTier: 2, weaponPool: [20, 1100], customAvatarUrl: null, }, @@ -250,7 +248,6 @@ export default function MatchPageTestRoute() { discordAvatar: null, customUrl: null, tier: { name: "PLATINUM", isPlus: false }, - plusTier: 3, weaponPool: [40, 3000], customAvatarUrl: null, }, diff --git a/app/features/scrims/components/ScrimCard.tsx b/app/features/scrims/components/ScrimCard.tsx index b47778bd3..efd3ed885 100644 --- a/app/features/scrims/components/ScrimCard.tsx +++ b/app/features/scrims/components/ScrimCard.tsx @@ -20,12 +20,17 @@ import { SendouPopover } from "~/components/elements/Popover"; import { FormWithConfirm } from "~/components/FormWithConfirm"; import { ModeImage } from "~/components/Image"; import { LocaleTime } from "~/components/LocaleTime"; +import { NoteAvatar } from "~/components/NoteAvatar"; import TimePopover from "~/components/TimePopover"; import { useUser } from "~/features/auth/core/user"; +import { + UserCard, + useUserCardData, +} from "~/features/user-card/components/UserCard"; import { useDateTimeFormat } from "~/hooks/intl/useDateTimeFormat"; import type { ModeShort } from "~/modules/in-game-lists/types"; import { databaseTimestampToDate } from "~/utils/dates"; -import { scrimPage, tournamentRegisterPage, userPage } from "~/utils/urls"; +import { scrimPage, tournamentRegisterPage } from "~/utils/urls"; import type { ScrimPost, ScrimPostRequest } from "../scrims-types"; import { formatFlexTimeDisplay } from "../scrims-utils"; import styles from "./ScrimCard.module.css"; @@ -142,11 +147,19 @@ function ScrimTeamAvatar({ teamName: string; owner: ScrimPost["users"][number]; }) { + const cardData = useUserCardData(owner.id); + if (teamAvatarUrl) { return ; } - return ; + return ( + + + + + + ); } function ScrimVisibilityPopover() { @@ -178,20 +191,28 @@ function ScrimTeamMembersPopover({ users }: { users: ScrimPost["users"] }) { >
{users.map((user) => ( - - - {user.username} - + ))}
); } +function ScrimTeamMemberRow({ user }: { user: ScrimPost["users"][number] }) { + const cardData = useUserCardData(user.id); + + return ( + + + + + + {user.username} + + + ); +} + function ScrimTournamentPopover({ tournament, }: { diff --git a/app/features/scrims/loaders/scrims.$id.server.ts b/app/features/scrims/loaders/scrims.$id.server.ts index 653f369cf..71357bbeb 100644 --- a/app/features/scrims/loaders/scrims.$id.server.ts +++ b/app/features/scrims/loaders/scrims.$id.server.ts @@ -1,5 +1,6 @@ import type { LoaderFunctionArgs } from "react-router"; import { chatAccessible } from "~/features/chat/chat-utils"; +import * as UserCardRepository from "~/features/user-card/UserCardRepository.server"; import * as UserRepository from "~/features/user-page/UserRepository.server"; import { databaseTimestampToDate } from "~/utils/dates"; import { notFoundIfFalsy } from "../../../utils/remix.server"; @@ -36,6 +37,10 @@ export const loader = async ({ params }: LoaderFunctionArgs) => { const mapByMap = await resolveMapByMap({ post, user }); return { + ...(await UserCardRepository.userCards({ + userIds: participantIds, + include: { friendCode: true }, + })), post, chatCode: (user.roles.includes("STAFF") || participantIds.includes(user.id)) && diff --git a/app/features/scrims/loaders/scrims.server.ts b/app/features/scrims/loaders/scrims.server.ts index ffe1e6bcc..5f2b18616 100644 --- a/app/features/scrims/loaders/scrims.server.ts +++ b/app/features/scrims/loaders/scrims.server.ts @@ -1,7 +1,9 @@ import type { LoaderFunctionArgs } from "react-router"; +import * as R from "remeda"; import * as AssociationsRepository from "~/features/associations/AssociationRepository.server"; import * as Association from "~/features/associations/core/Association"; import { getUser } from "~/features/auth/core/user.server"; +import * as UserCardRepository from "~/features/user-card/UserCardRepository.server"; import { parseSearchParams } from "~/utils/remix.server"; import * as TeamRepository from "../../team/TeamRepository.server"; import * as Scrim from "../core/Scrim"; @@ -43,7 +45,19 @@ export const loader = async ({ request }: LoaderFunctionArgs) => { }), })); + const cardUserIds = R.unique( + posts.flatMap((post) => [ + ...post.users.map((user) => user.id), + ...post.requests.flatMap((request) => + request.users.map((user) => user.id), + ), + ]), + ); + return { + ...(await UserCardRepository.userCards({ + userIds: cardUserIds, + })), posts: dividePosts(posts, user?.id), teams: user ? await TeamRepository.teamsByMemberUserId(user.id) : [], filters: filters ?? Scrim.defaultFilters(), diff --git a/app/features/scrims/routes/scrims.$id.tsx b/app/features/scrims/routes/scrims.$id.tsx index d962ee2a7..a92e8d5cd 100644 --- a/app/features/scrims/routes/scrims.$id.tsx +++ b/app/features/scrims/routes/scrims.$id.tsx @@ -11,7 +11,7 @@ import { loader } from "../loaders/scrims.$id.server"; export { action, loader }; export const handle: SendouRouteHandle = { - i18n: ["scrims", "q"], + i18n: ["scrims", "q", "user"], breadcrumb: () => ({ imgPath: navIconUrl("scrims"), href: scrimsPage(), diff --git a/app/features/scrims/routes/scrims.tsx b/app/features/scrims/routes/scrims.tsx index 2e490944b..c506a0378 100644 --- a/app/features/scrims/routes/scrims.tsx +++ b/app/features/scrims/routes/scrims.tsx @@ -38,7 +38,7 @@ import styles from "./scrims.module.css"; export type NewRequestFormFields = z.infer; export const handle: SendouRouteHandle = { - i18n: ["calendar", "scrims"], + i18n: ["calendar", "scrims", "user", "q"], breadcrumb: () => ({ imgPath: navIconUrl("scrims"), href: scrimsPage(), diff --git a/app/features/scrims/scrims-utils.test.ts b/app/features/scrims/scrims-utils.test.ts index 605c23233..eebc32475 100644 --- a/app/features/scrims/scrims-utils.test.ts +++ b/app/features/scrims/scrims-utils.test.ts @@ -3,9 +3,32 @@ import { MapPool } from "~/features/map-list-generator/core/map-pool"; import { formatFlexTimeDisplay, generateTimeOptions, + parseLutiDivFromName, parseMapPoolInput, } from "./scrims-utils"; +describe("parseLutiDivFromName", () => { + it("parses a numeric division", () => { + expect(parseLutiDivFromName("LUTI: Season 15 - Division 2")).toBe("2"); + }); + + it("parses division X", () => { + expect(parseLutiDivFromName("LUTI Season 15 Division X")).toBe("X"); + }); + + it("parses a two-digit division without matching a single digit", () => { + expect(parseLutiDivFromName("LUTI Season 15 Div 10")).toBe("10"); + }); + + it("returns null when no division token is present", () => { + expect(parseLutiDivFromName("Leagues Under The Ink Season 15")).toBeNull(); + }); + + it("returns null for an out-of-range division", () => { + expect(parseLutiDivFromName("LUTI Division 12")).toBeNull(); + }); +}); + describe("generateTimeOptions", () => { it("includes both start and end times", () => { const start = new Date("2025-01-15T14:15:00"); diff --git a/app/features/scrims/scrims-utils.ts b/app/features/scrims/scrims-utils.ts index 53ad090b9..6ad99fa7b 100644 --- a/app/features/scrims/scrims-utils.ts +++ b/app/features/scrims/scrims-utils.ts @@ -3,6 +3,7 @@ import * as R from "remeda"; import { MapPool } from "~/features/map-list-generator/core/map-pool"; import { databaseTimestampToDate } from "~/utils/dates"; import * as Scrim from "./core/Scrim"; +import { LUTI_DIVS } from "./scrims-constants"; import type { LutiDiv, ScrimPost } from "./scrims-types"; export const getPostRequestCensor = @@ -53,6 +54,20 @@ export const parseLutiDiv = (div: number): LutiDiv => { return String(div) as LutiDiv; }; +/** + * Extracts the LUTI division (e.g. `"X"`, `"2"`) from a tournament name such as + * "LUTI: Season 15 - Division 2". Returns `null` if no valid division token is found. + */ +export const parseLutiDivFromName = (name: string): LutiDiv | null => { + const match = name.match(/\bdiv(?:ision)?\.?\s*(X|11|10|[1-9])\b/i); + if (!match) return null; + + const token = match[1].toUpperCase(); + return (LUTI_DIVS as readonly string[]).includes(token) + ? (token as LutiDiv) + : null; +}; + export const serializeLutiDiv = (div: LutiDiv): number => { if (div === "X") return 0; diff --git a/app/features/sendouq-match/SQMatchRepository.server.ts b/app/features/sendouq-match/SQMatchRepository.server.ts index 3429c08e0..14784b490 100644 --- a/app/features/sendouq-match/SQMatchRepository.server.ts +++ b/app/features/sendouq-match/SQMatchRepository.server.ts @@ -123,7 +123,6 @@ function groupWithTeamAndMembers( eb .selectFrom("GroupMember") .innerJoin("User", "User.id", "GroupMember.userId") - .leftJoin("PlusTier", "User.id", "PlusTier.userId") .leftJoin("GroupMatchContinueVote", (join) => join .onRef( @@ -148,7 +147,6 @@ function groupWithTeamAndMembers( "User.noScreen", matchProfileWeapons(arrayEb).as("weapons"), "User.mapModePreferences", - "PlusTier.tier as plusTier", "GroupMatchContinueVote.isContinuing", arrayEb .selectFrom("UserFriendCode") diff --git a/app/features/sendouq-match/actions/q.match.$id.server.ts b/app/features/sendouq-match/actions/q.match.$id.server.ts index 9cb254123..177359dfa 100644 --- a/app/features/sendouq-match/actions/q.match.$id.server.ts +++ b/app/features/sendouq-match/actions/q.match.$id.server.ts @@ -1,5 +1,4 @@ import type { ActionFunctionArgs } from "react-router"; -import { redirect } from "react-router"; import { db } from "~/db/sql"; import { requireUser } from "~/features/auth/core/user.server"; import * as ChatSystemMessage from "~/features/chat/ChatSystemMessage.server"; @@ -9,7 +8,6 @@ import { refreshSendouQInstance, SendouQ, } from "~/features/sendouq/core/SendouQ.server"; -import * as PrivateUserNoteRepository from "~/features/sendouq/PrivateUserNoteRepository.server"; import { SENDOUQ_LOOKING_ROOM } from "~/features/sendouq/q-constants"; import { SendouQError } from "~/features/sendouq/q-utils.server"; import * as SQGroupRepository from "~/features/sendouq/SQGroupRepository.server"; @@ -26,7 +24,6 @@ import { parseRequestPayload, } from "~/utils/remix.server"; import { assertUnreachable } from "~/utils/types"; -import { sendouQMatchPage } from "~/utils/urls"; import * as RejoinVote from "../core/RejoinVote"; import * as SendouQMatch from "../core/SendouQMatch"; import { matchSchema, qMatchPageParamsSchema } from "../q-match-schemas"; @@ -263,15 +260,6 @@ export const action = async ({ request, params }: ActionFunctionArgs) => { break; } - case "ADD_PRIVATE_USER_NOTE": { - await PrivateUserNoteRepository.upsertOwnNote({ - sentiment: data.sentiment, - targetId: data.targetId, - text: data.comment, - }); - - throw redirect(sendouQMatchPage(matchId)); - } case "UNDO_MATCH_REPORT": { const result = await SQMatchRepository.undoMatchReport({ matchId, diff --git a/app/features/sendouq-match/components/SendouQMatchTabs.tsx b/app/features/sendouq-match/components/SendouQMatchTabs.tsx index b0a9a6ad6..b451bf0b4 100644 --- a/app/features/sendouq-match/components/SendouQMatchTabs.tsx +++ b/app/features/sendouq-match/components/SendouQMatchTabs.tsx @@ -1,5 +1,4 @@ import { useTranslation } from "react-i18next"; -import { useNavigate, useSearchParams } from "react-router"; import { MatchResultTab } from "~/components/match-page/MatchResultTab"; import { MatchRosterTab } from "~/components/match-page/MatchRosterTab"; import { MatchTabs } from "~/components/match-page/MatchTabs"; @@ -7,8 +6,7 @@ import { useUser } from "~/features/auth/core/user"; import { ACTION_TAB_AFTER_LOCKED_SECONDS } from "~/features/sendouq/q-constants"; import { useHasRole } from "~/modules/permissions/hooks"; import { databaseTimestampNow } from "~/utils/dates"; -import { safeNumberParse } from "~/utils/number"; -import { sendouQMatchPage, teamPage } from "~/utils/urls"; +import { teamPage } from "~/utils/urls"; import { resolveTimelineMaps, resolveTimelineSpChanges, @@ -16,14 +14,11 @@ import { } from "../core/match-timeline"; import * as SendouQMatch from "../core/SendouQMatch"; import type { SendouQMatchLoaderData } from "../loaders/q.match.$id.server"; -import { AddPrivateNoteDialog } from "./AddPrivateNoteDialog"; import { SendouQMatchActionTab } from "./SendouQMatchActionTab"; export function SendouQMatchTabs({ data }: { data: SendouQMatchLoaderData }) { const user = useUser(); const isStaff = useHasRole("STAFF"); - const [searchParams] = useSearchParams(); - const navigate = useNavigate(); const { t } = useTranslation(["q"]); const currentMap = data.match.currentMap; @@ -91,78 +86,56 @@ export function SendouQMatchTabs({ data }: { data: SendouQMatchLoaderData }) { ? ["action"] : undefined; - const ownGroup = - userSide === "ALPHA" - ? data.match.groupAlpha - : userSide === "BRAVO" - ? data.match.groupBravo - : null; - const addingNoteFor = ownGroup?.members.find( - (m) => m.id === safeNumberParse(searchParams.get("note")), - ); - return ( - <> - navigate(sendouQMatchPage(data.match.id))} + + {isLocked || hasReportedMaps ? ( + + {data.match.cancelRequestedByUserId ? ( +

+ {t("q:match.canceled.detail", { + requester: resolveCancelRequesterUsername(data.match), + accepter: resolveCancelAccepterUsername(data.match), + })} +

+ ) : null} +
+ ) : null} + - - {isLocked || hasReportedMaps ? ( - - {data.match.cancelRequestedByUserId ? ( -

- {t("q:match.canceled.detail", { - requester: resolveCancelRequesterUsername(data.match), - accepter: resolveCancelAccepterUsername(data.match), - })} -

- ) : null} -
- ) : null} - m.winnerGroupId !== null).length + } + viewerSide={userSide} /> - {showActionTab ? ( - m.winnerGroupId !== null).length - } - viewerSide={userSide} - /> - ) : null} -
- + ) : null} +
); } @@ -184,23 +157,14 @@ function resolveCancelAccepterUsername(match: MatchData) { ); } -function mapRosterMembers( - members: MatchData["groupAlpha"]["members"], - { viewerId, isOwnTeam }: { viewerId?: number; isOwnTeam: boolean }, -) { +function mapRosterMembers(members: MatchData["groupAlpha"]["members"]) { return members.map((member) => ({ ...member, tier: member.skill === "CALCULATING" ? ("CALCULATING" as const) : member.skill?.tier, - plusTier: member.plusTier ?? undefined, weaponPool: member.weapons?.map((w) => w.weaponSplId), - friendCode: member.friendCode, - privateNote: - viewerId !== undefined && isOwnTeam && member.id !== viewerId - ? (member.privateNote ?? null) - : undefined, })); } diff --git a/app/features/sendouq-match/core/match.server.ts b/app/features/sendouq-match/core/match.server.ts index e2e167e66..44d55b913 100644 --- a/app/features/sendouq-match/core/match.server.ts +++ b/app/features/sendouq-match/core/match.server.ts @@ -383,7 +383,6 @@ export function createMatchMemento( return [ member.id, { - plusTier: member.plusTier ?? undefined, skill: !skill || skill.approximate ? ("CALCULATING" as const) : skill, }, diff --git a/app/features/sendouq-match/loaders/q.match.$id.server.ts b/app/features/sendouq-match/loaders/q.match.$id.server.ts index 787a80e9e..f4b379df7 100644 --- a/app/features/sendouq-match/loaders/q.match.$id.server.ts +++ b/app/features/sendouq-match/loaders/q.match.$id.server.ts @@ -3,9 +3,9 @@ import { getUser } from "~/features/auth/core/user.server"; import { chatAccessible } from "~/features/chat/chat-utils"; import * as Seasons from "~/features/mmr/core/Seasons"; import { SendouQ } from "~/features/sendouq/core/SendouQ.server"; -import * as PrivateUserNoteRepository from "~/features/sendouq/PrivateUserNoteRepository.server"; import * as ReportedWeaponRepository from "~/features/sendouq-match/ReportedWeaponRepository.server"; import * as SQMatchRepository from "~/features/sendouq-match/SQMatchRepository.server"; +import * as UserCardRepository from "~/features/user-card/UserCardRepository.server"; import { databaseTimestampToDate } from "~/utils/dates"; import type { SerializeFrom } from "~/utils/remix"; import { notFoundIfFalsy, parseParams } from "~/utils/remix.server"; @@ -30,14 +30,15 @@ export const loader = async ({ params }: LoaderFunctionArgs) => { const isStaff = user?.roles.includes("STAFF") ?? false; const isParticipant = Boolean(user && matchUsers.includes(user.id)); - const [privateNotes, reportedWeapons] = await Promise.all([ - user ? PrivateUserNoteRepository.ownNotes(matchUsers) : undefined, - ReportedWeaponRepository.findByMatchId(matchId), - ]); + const reportedWeapons = await ReportedWeaponRepository.findByMatchId(matchId); - const match = SendouQ.mapMatch(matchUnmapped, user, privateNotes); + const match = SendouQ.mapMatch(matchUnmapped, user); return { + ...(await UserCardRepository.userCards({ + userIds: matchUsers, + include: { friendCode: isStaff || isParticipant }, + })), match, reportedWeapons, isOffSeason: Seasons.current() === null, diff --git a/app/features/sendouq-match/q-match-schemas.ts b/app/features/sendouq-match/q-match-schemas.ts index 3a341ef60..12b6fc206 100644 --- a/app/features/sendouq-match/q-match-schemas.ts +++ b/app/features/sendouq-match/q-match-schemas.ts @@ -1,6 +1,5 @@ import { z } from "zod"; -import { SENDOUQ } from "~/features/sendouq/q-constants"; -import { _action, falsyToNull, id, weaponSplId } from "~/utils/zod"; +import { _action, id, weaponSplId } from "~/utils/zod"; export const matchSchema = z.union([ z.object({ @@ -21,15 +20,6 @@ export const matchSchema = z.union([ weaponSplId, mapIndex: z.coerce.number().int().nonnegative(), }), - z.object({ - _action: _action("ADD_PRIVATE_USER_NOTE"), - comment: z.preprocess( - falsyToNull, - z.string().max(SENDOUQ.PRIVATE_USER_NOTE_MAX_LENGTH).nullable(), - ), - sentiment: z.enum(["POSITIVE", "NEUTRAL", "NEGATIVE"]), - targetId: id, - }), z.object({ _action: _action("UNDO_MATCH_REPORT"), }), diff --git a/app/features/sendouq-match/routes/q.match.$id.tsx b/app/features/sendouq-match/routes/q.match.$id.tsx index e88740b7a..b31180e9b 100644 --- a/app/features/sendouq-match/routes/q.match.$id.tsx +++ b/app/features/sendouq-match/routes/q.match.$id.tsx @@ -30,7 +30,7 @@ export const meta: MetaFunction = (args) => { }; export const handle: SendouRouteHandle = { - i18n: ["q"], + i18n: ["q", "user"], breadcrumb: () => ({ imgPath: navIconUrl("sendouq"), href: SENDOUQ_PAGE, diff --git a/app/features/sendouq/SQGroupRepository.server.ts b/app/features/sendouq/SQGroupRepository.server.ts index 9145397b3..73728b5e4 100644 --- a/app/features/sendouq/SQGroupRepository.server.ts +++ b/app/features/sendouq/SQGroupRepository.server.ts @@ -72,14 +72,12 @@ export async function findCurrentGroups() { isTenStar: number; })[] | null; - plusTier: Tables["PlusTier"]["tier"] | null; }; return db .selectFrom("Group") .innerJoin("GroupMember", "GroupMember.groupId", "Group.id") .innerJoin("User", "User.id", "GroupMember.userId") - .leftJoin("PlusTier", "PlusTier.userId", "User.id") .leftJoin("GroupMatch", (join) => join.on((eb) => eb.or([ @@ -112,7 +110,6 @@ export async function findCurrentGroups() { note: eb.ref("GroupMember.note"), weapons: matchProfileWeapons(eb), languages: eb.ref("User.languages"), - plusTier: eb.ref("PlusTier.tier"), vc: eb.ref("User.vc"), }), ]) diff --git a/app/features/sendouq/actions/q.looking.server.ts b/app/features/sendouq/actions/q.looking.server.ts index 228f99525..9faeee598 100644 --- a/app/features/sendouq/actions/q.looking.server.ts +++ b/app/features/sendouq/actions/q.looking.server.ts @@ -15,7 +15,6 @@ import { assertUnreachable } from "~/utils/types"; import { navIconUrl, SENDOUQ_PAGE, sendouQMatchPage } from "~/utils/urls"; import { groupAfterMorph } from "../core/groups"; import { refreshSendouQInstance, SendouQ } from "../core/SendouQ.server"; -import * as PrivateUserNoteRepository from "../PrivateUserNoteRepository.server"; import { SENDOUQ_LOOKING_ROOM, sqGroupWebsocketRoom } from "../q-constants"; import { lookingSchema } from "../q-schemas.server"; import { resolveFutureMatchModes } from "../q-utils"; @@ -331,11 +330,6 @@ export const action: ActionFunction = async ({ request }) => { break; } - case "DELETE_PRIVATE_USER_NOTE": { - await PrivateUserNoteRepository.deleteOwnNoteById(data.targetId); - - break; - } default: { assertUnreachable(data); } diff --git a/app/features/sendouq/components/GroupCard.browser.test.tsx b/app/features/sendouq/components/GroupCard.browser.test.tsx index 2aeb1b915..c614aa77a 100644 --- a/app/features/sendouq/components/GroupCard.browser.test.tsx +++ b/app/features/sendouq/components/GroupCard.browser.test.tsx @@ -29,11 +29,9 @@ function createMember(overrides: Partial = {}): SQGroupMember { languages: [], skill: "CALCULATING", weapons: [], - plusTier: null, friendCode: null, inGameName: null, note: null, - privateNote: null, pronouns: null, skillDifference: undefined, noScreen: undefined, @@ -83,11 +81,9 @@ function createOwnGroupMember( languages: [], skill: "CALCULATING", weapons: [], - plusTier: null, friendCode: null, inGameName: null, note: null, - privateNote: null, pronouns: null, skillDifference: undefined, noScreen: undefined, diff --git a/app/features/sendouq/components/GroupCard.module.css b/app/features/sendouq/components/GroupCard.module.css index b99b81df4..dbf09540d 100644 --- a/app/features/sendouq/components/GroupCard.module.css +++ b/app/features/sendouq/components/GroupCard.module.css @@ -40,22 +40,6 @@ color: var(--color-text); } -.avatar { - min-width: 36px; -} - -.avatarPositive { - outline: 2px solid var(--color-success-low); -} - -.avatarNeutral { - outline: 2px solid var(--color-warning-low); -} - -.avatarNegative { - outline: 2px solid var(--color-error-low); -} - .tier { margin-inline-start: auto; } @@ -97,23 +81,6 @@ height: 24px; } -.addNoteButton { - border: none; - padding: 0 var(--s-1-5); - color: var(--color-text-accent); - font-size: var(--font-2xs); - font-weight: var(--weight-semi); - background-color: var(--color-bg); - white-space: nowrap; - height: 100%; - border-radius: var(--radius-selector); - - & > svg { - width: 18px; - height: 18px; - } -} - .noteTextarea { height: 4rem !important; } diff --git a/app/features/sendouq/components/GroupCard.tsx b/app/features/sendouq/components/GroupCard.tsx index 0a6c58fee..88e65c99b 100644 --- a/app/features/sendouq/components/GroupCard.tsx +++ b/app/features/sendouq/components/GroupCard.tsx @@ -1,32 +1,33 @@ import clsx from "clsx"; import type { SqlBool } from "kysely"; -import { Mic, PenSquare, Star, Trash, Volume2, VolumeX } from "lucide-react"; +import { Mic, Star, Volume2, VolumeX } from "lucide-react"; import * as React from "react"; import { Flipped } from "react-flip-toolkit"; import { useTranslation } from "react-i18next"; import { Link, useFetcher } from "react-router"; import { Avatar } from "~/components/Avatar"; -import { LinkButton, SendouButton } from "~/components/elements/Button"; +import { SendouButton } from "~/components/elements/Button"; import { SendouPopover } from "~/components/elements/Popover"; -import { FormWithConfirm } from "~/components/FormWithConfirm"; import { Image, ModeImage, TierImage, WeaponImage } from "~/components/Image"; -import { LocaleTime } from "~/components/LocaleTime"; +import { NoteAvatar } from "~/components/NoteAvatar"; import { SubmitButton } from "~/components/SubmitButton"; import type { ParsedMemento } from "~/db/tables"; import { useUser } from "~/features/auth/core/user"; import { MATCHES_COUNT_NEEDED_FOR_LEADERBOARD } from "~/features/leaderboards/leaderboards-constants"; import { ordinalToRoundedSp } from "~/features/mmr/mmr-utils"; import type { TieredSkill } from "~/features/mmr/tiered.server"; +import { + UserCard, + useUserCardData, +} from "~/features/user-card/components/UserCard"; import { languagesUnified } from "~/modules/i18n/config"; import { SPLATTERCOLOR_SCREEN_ID } from "~/modules/in-game-lists/weapon-ids"; import { inGameNameWithoutDiscriminator } from "~/utils/strings"; import { - navIconUrl, SENDOUQ_LOOKING_PAGE, specialWeaponImageUrl, TIERS_PAGE, tierImageUrl, - userPage, } from "~/utils/urls"; import type { SQGroup, @@ -37,12 +38,6 @@ import { FULL_GROUP_SIZE, SENDOUQ } from "../q-constants"; import { resolveFutureMatchModes } from "../q-utils"; import styles from "./GroupCard.module.css"; -const SENTIMENT_STYLES = { - POSITIVE: styles.avatarPositive, - NEUTRAL: styles.avatarNeutral, - NEGATIVE: styles.avatarNegative, -} as const; - export function GroupCard({ group, action, @@ -50,8 +45,6 @@ export function GroupCard({ hideVc = false, hideWeapons = false, hideNote: _hidenote = false, - showAddNote, - showNote = false, ownGroup, layout = "desktop", }: { @@ -61,13 +54,10 @@ export function GroupCard({ hideVc?: SqlBool; hideWeapons?: SqlBool; hideNote?: boolean; - showAddNote?: SqlBool; - showNote?: boolean; ownGroup?: SQOwnGroup; layout?: "mobile" | "desktop"; }) { const { t } = useTranslation(["q"]); - const user = useUser(); const fetcher = useFetcher(); const hideNote = @@ -104,8 +94,6 @@ export function GroupCard({ hideWeapons={hideWeapons} hideNote={hideNote} enableKicking={enableKicking} - showNote={showNote} - showAddNote={showAddNote && member.id !== user?.id} /> ); })} @@ -263,8 +251,6 @@ function GroupMember({ hideWeapons, hideNote, enableKicking, - showAddNote, - showNote, }: { member: SQGroupMember; showActions: boolean; @@ -273,70 +259,37 @@ function GroupMember({ hideWeapons?: SqlBool; hideNote?: boolean; enableKicking?: boolean; - showAddNote?: SqlBool; - showNote?: boolean; }) { const { t } = useTranslation(["q", "user"]); const user = useUser(); + const cardData = useUserCardData(member.id); return (
- {showNote && member.privateNote ? ( - - - - } - > - {member.privateNote.text} -
+ + - - -
-
- ) : ( - - )} - - {member.inGameName ? ( - <> - - {t("user:ign.short")}: - {" "} - {inGameNameWithoutDiscriminator(member.inGameName)} - - ) : ( - member.username - )} - + + + + {member.inGameName ? ( + <> + + {t("user:ign.short")}: + {" "} + {inGameNameWithoutDiscriminator(member.inGameName)} + + ) : ( + member.username + )} + + + {member.pronouns ? ( {member.pronouns.subject}/{member.pronouns.object} @@ -361,12 +314,6 @@ function GroupMember({
) : null} - {member.plusTier ? ( -
- - {member.plusTier} -
- ) : null} {member.friendCode ? ( ) : null} - {showAddNote ? ( - } - className={clsx(styles.addNoteButton, { - [styles.addNoteButtonEdit]: member.privateNote, - })} - > - {member.privateNote - ? t("q:looking.groups.editNote") - : t("q:looking.groups.addNote")} - - ) : null}
{member.weapons && member.weapons.length > 0 && !hideWeapons ? (
@@ -524,30 +458,6 @@ function AddPrivateNoteForm({ ); } -function DeletePrivateNoteForm({ - targetId, - name, -}: { - targetId: number; - name: string; -}) { - const { t } = useTranslation(["q"]); - - return ( - - - - - - ); -} - function GroupSkillDifference({ skillDifference, }: { diff --git a/app/features/sendouq/core/SendouQ.server.test.ts b/app/features/sendouq/core/SendouQ.server.test.ts index 40224a8be..8762a9c43 100644 --- a/app/features/sendouq/core/SendouQ.server.test.ts +++ b/app/features/sendouq/core/SendouQ.server.test.ts @@ -1,10 +1,8 @@ import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; import { db } from "~/db/sql"; import { refreshUserSkills } from "~/features/mmr/tiered.server"; -import * as PrivateUserNoteRepository from "~/features/sendouq/PrivateUserNoteRepository.server"; -import * as UserRepository from "~/features/user-page/UserRepository.server"; import { databaseTimestampNow } from "~/utils/dates"; -import { dbInsertUsers, dbReset, withUser } from "~/utils/Test"; +import { dbInsertUsers, dbReset } from "~/utils/Test"; import * as SQGroupRepository from "../SQGroupRepository.server"; import { refreshSendouQInstance, SendouQ } from "./SendouQ.server"; @@ -69,29 +67,6 @@ const createMatch = async ( .execute(); }; -const ownNotesOf = async (authorId: number, targetUserIds?: number[]) => { - const user = await UserRepository.findLeanById(authorId); - return withUser(user!, () => - PrivateUserNoteRepository.ownNotes(targetUserIds), - ); -}; - -const createPrivateNote = async ( - authorId: number, - targetId: number, - sentiment: "POSITIVE" | "NEUTRAL" | "NEGATIVE", - text = "test note", -) => { - const user = await UserRepository.findLeanById(authorId); - await withUser(user!, () => - PrivateUserNoteRepository.upsertOwnNote({ - targetId, - sentiment, - text, - }), - ); -}; - const insertSkill = async (userId: number, ordinal: number, season = 1) => { await db .insertInto("Skill") @@ -273,8 +248,7 @@ describe("SendouQ", () => { test("returns empty array when no groups exist", async () => { await refreshSendouQInstance(); - const notes = await ownNotesOf(1); - const groups = SendouQ.previewGroups(1, notes); + const groups = SendouQ.previewGroups(1); expect(groups).toEqual([]); }); @@ -283,8 +257,7 @@ describe("SendouQ", () => { await createGroup([1, 2, 3, 4]); await refreshSendouQInstance(); - const notes = await ownNotesOf(1); - const groups = SendouQ.previewGroups(1, notes); + const groups = SendouQ.previewGroups(1); expect(groups).toHaveLength(1); expect(groups[0].members).toBeUndefined(); @@ -294,35 +267,19 @@ describe("SendouQ", () => { await createGroup([1, 2]); await refreshSendouQInstance(); - const notes = await ownNotesOf(1); - const groups = SendouQ.previewGroups(1, notes); + const groups = SendouQ.previewGroups(1); expect(groups).toHaveLength(1); expect(groups[0].members).toBeDefined(); expect(groups[0].members).toHaveLength(2); }); - test("attaches private notes to members", async () => { - await createGroup([1, 2]); - await createPrivateNote(3, 2, "POSITIVE", "Great player"); - await refreshSendouQInstance(); - - const notes = await ownNotesOf(3); - const groups = SendouQ.previewGroups(3, notes); - - expect(groups).toHaveLength(1); - const member = groups[0].members?.find((m) => m.id === 2); - expect(member?.privateNote).toBeDefined(); - expect(member?.privateNote?.sentiment).toBe("POSITIVE"); - }); - test("removes inviteCode and chatCode from all groups", async () => { await createGroup([1, 2], { inviteCode: "CODE1" }); await createGroup([3, 4, 5, 6], { inviteCode: "CODE2" }); await refreshSendouQInstance(); - const notes = await ownNotesOf(1); - const groups = SendouQ.previewGroups(1, notes); + const groups = SendouQ.previewGroups(1); expect(groups).toHaveLength(2); for (const group of groups) { @@ -337,8 +294,7 @@ describe("SendouQ", () => { await createGroup([7, 8, 9]); await refreshSendouQInstance(); - const notes = await ownNotesOf(1); - const groups = SendouQ.previewGroups(1, notes); + const groups = SendouQ.previewGroups(1); expect(groups).toHaveLength(3); @@ -354,8 +310,7 @@ describe("SendouQ", () => { await createGroup([5, 6]); await refreshSendouQInstance(); - const notes = await ownNotesOf(1); - const groups = SendouQ.previewGroups(1, notes); + const groups = SendouQ.previewGroups(1); const fullGroup = groups.find((g) => g.members === undefined); const partialGroup = groups.find((g) => g.members !== undefined); @@ -399,8 +354,7 @@ describe("SendouQ", () => { .execute(); await refreshSendouQInstance(); - const notes = await ownNotesOf(1); - const groups = SendouQ.previewGroups(1, notes); + const groups = SendouQ.previewGroups(1); expect(groups).toHaveLength(2); expect(groups[0].id).toBe(group1Id); @@ -427,8 +381,7 @@ describe("SendouQ", () => { .execute(); await refreshSendouQInstance(); - const notes = await ownNotesOf(1); - const groups = SendouQ.previewGroups(1, notes); + const groups = SendouQ.previewGroups(1); expect(groups).toHaveLength(3); expect(groups[0].members![0].id).toBe(4); @@ -448,36 +401,13 @@ describe("SendouQ", () => { const partialGroupId = await createGroup([6]); await refreshSendouQInstance(); - const notes = await ownNotesOf(1); - const groups = SendouQ.previewGroups(1, notes); + const groups = SendouQ.previewGroups(1); expect(groups).toHaveLength(2); expect(groups[0].id).toBe(partialGroupId); expect(groups[1].id).toBe(fullGroupId); }); - test("sorts by sentiment first, then tier within same sentiment", async () => { - await insertSkill(1, 1000); - await insertSkill(2, 500); - await insertSkill(3, 2000); - await insertSkill(4, 1050); - - await createGroup([2]); - await createGroup([3]); - await createGroup([4]); - await createPrivateNote(1, 2, "POSITIVE"); - await createPrivateNote(1, 3, "NEGATIVE"); - await refreshSendouQInstance(); - - const notes = await ownNotesOf(1); - const groups = SendouQ.previewGroups(1, notes); - - expect(groups).toHaveLength(3); - expect(groups[0].members![0].id).toBe(2); - expect(groups[1].members![0].id).toBe(4); - expect(groups[2].members![0].id).toBe(3); - }); - test("handles viewer without skill gracefully", async () => { await insertSkill(2, 500); await insertSkill(3, 2000); @@ -486,8 +416,7 @@ describe("SendouQ", () => { await createGroup([3]); await refreshSendouQInstance(); - const notes = await ownNotesOf(1); - const groups = SendouQ.previewGroups(1, notes); + const groups = SendouQ.previewGroups(1); expect(groups).toHaveLength(2); }); @@ -508,8 +437,7 @@ describe("SendouQ", () => { await createGroup([1, 2, 3, 4]); await refreshSendouQInstance(); - const notes = await ownNotesOf(5); - const groups = SendouQ.lookingGroups(5, notes); + const groups = SendouQ.lookingGroups(5); expect(groups).toEqual([]); }); @@ -526,8 +454,7 @@ describe("SendouQ", () => { await createGroup([4], { status: "ACTIVE" }); await refreshSendouQInstance(); - const notes = await ownNotesOf(1); - const groups = SendouQ.lookingGroups(1, notes); + const groups = SendouQ.lookingGroups(1); expect(groups).toHaveLength(1); expect(groups[0].members![0].id).toBe(4); @@ -542,8 +469,7 @@ describe("SendouQ", () => { await refreshSendouQInstance(); - const notes = await ownNotesOf(1); - const groups = SendouQ.lookingGroups(1, notes); + const groups = SendouQ.lookingGroups(1); expect(groups).toHaveLength(1); expect(groups[0].members![0].id).toBe(3); @@ -554,8 +480,7 @@ describe("SendouQ", () => { await createGroup([3, 4]); await refreshSendouQInstance(); - const notes = await ownNotesOf(1); - const groups = SendouQ.lookingGroups(1, notes); + const groups = SendouQ.lookingGroups(1); expect(groups).toHaveLength(1); expect(groups[0].members?.some((m) => m.id === 1)).toBe(false); @@ -569,8 +494,7 @@ describe("SendouQ", () => { await createGroup([11, 12, 13, 14]); await refreshSendouQInstance(); - const notes = await ownNotesOf(1); - const groups = SendouQ.lookingGroups(1, notes); + const groups = SendouQ.lookingGroups(1); expect(groups).toHaveLength(1); expect(groups[0].members).toBeUndefined(); @@ -584,8 +508,7 @@ describe("SendouQ", () => { await createGroup([10, 11, 12, 13]); await refreshSendouQInstance(); - const notes = await ownNotesOf(1); - const groups = SendouQ.lookingGroups(1, notes); + const groups = SendouQ.lookingGroups(1); expect(groups).toHaveLength(1); expect(groups[0].members).toHaveLength(1); @@ -600,8 +523,7 @@ describe("SendouQ", () => { await createGroup([9, 10, 11, 12]); await refreshSendouQInstance(); - const notes = await ownNotesOf(1); - const groups = SendouQ.lookingGroups(1, notes); + const groups = SendouQ.lookingGroups(1); expect(groups).toHaveLength(2); const groupSizes = groups.map((g) => g.members!.length); @@ -617,8 +539,7 @@ describe("SendouQ", () => { await createGroup([8, 9, 10, 11]); await refreshSendouQInstance(); - const notes = await ownNotesOf(1); - const groups = SendouQ.lookingGroups(1, notes); + const groups = SendouQ.lookingGroups(1); expect(groups).toHaveLength(3); const groupSizes = groups.map((g) => g.members!.length); @@ -655,8 +576,7 @@ describe("SendouQ", () => { await refreshSendouQInstance(); - const notes = await ownNotesOf(1); - const groups = SendouQ.lookingGroups(1, notes); + const groups = SendouQ.lookingGroups(1); const fullGroups = groups.filter((g) => g.members === undefined); expect(fullGroups.some((g) => g.isReplay)).toBe(true); @@ -679,8 +599,7 @@ describe("SendouQ", () => { await refreshSendouQInstance(); - const notes = await ownNotesOf(1); - const groups = SendouQ.lookingGroups(1, notes); + const groups = SendouQ.lookingGroups(1); for (const group of groups) { expect(group.isReplay).toBe(false); @@ -693,8 +612,7 @@ describe("SendouQ", () => { await createGroup([3]); await refreshSendouQInstance(); - const notes = await ownNotesOf(1); - const groups = SendouQ.lookingGroups(1, notes); + const groups = SendouQ.lookingGroups(1); for (const group of groups) { expect(group.isReplay).toBe(false); @@ -718,8 +636,7 @@ describe("SendouQ", () => { await refreshSendouQInstance(); - const notes = await ownNotesOf(1); - const groups = SendouQ.lookingGroups(1, notes); + const groups = SendouQ.lookingGroups(1); const partialGroup = groups.find((g) => g.members?.some((m) => m.id === 5), @@ -742,8 +659,7 @@ describe("SendouQ", () => { await createGroup([5, 6, 7, 8]); await refreshSendouQInstance(); - const notes = await ownNotesOf(1); - const groups = SendouQ.lookingGroups(1, notes); + const groups = SendouQ.lookingGroups(1); const fullGroup = groups.find((g) => g.members === undefined); expect(fullGroup).toBeDefined(); @@ -754,8 +670,7 @@ describe("SendouQ", () => { await createGroup([2, 3]); await refreshSendouQInstance(); - const notes = await ownNotesOf(1); - const groups = SendouQ.lookingGroups(1, notes); + const groups = SendouQ.lookingGroups(1); const partialGroup = groups.find((g) => g.members?.length === 2); expect(partialGroup).toBeDefined(); @@ -768,8 +683,7 @@ describe("SendouQ", () => { await createGroup([3, 4, 5, 6]); await refreshSendouQInstance(); - const notes = await ownNotesOf(1); - const groups = SendouQ.lookingGroups(1, notes); + const groups = SendouQ.lookingGroups(1); for (const group of groups) { expect(group).not.toHaveProperty("inviteCode"); @@ -778,76 +692,6 @@ describe("SendouQ", () => { }); }); - describe("private note sorting", () => { - beforeEach(async () => { - await dbInsertUsers(8); - }); - - afterEach(() => { - dbReset(); - }); - - test("users with positive note sorted first", async () => { - await createGroup([1]); - await createGroup([2]); - await createGroup([3]); - await createGroup([4]); - await createGroup([5]); - await createGroup([6, 7]); - await createGroup([8]); - - await createPrivateNote(1, 5, "POSITIVE"); - - await refreshSendouQInstance(); - - const notes = await ownNotesOf(1); - const groups = SendouQ.lookingGroups(1, notes); - - expect(groups[0].members![0].id).toBe(5); - }); - - test("users with negative note sorted last", async () => { - await createGroup([1]); - await createGroup([2]); - await createGroup([3]); - await createGroup([4]); - await createGroup([5]); - await createGroup([6, 7]); - await createGroup([8]); - - await createPrivateNote(1, 5, "NEGATIVE"); - - await refreshSendouQInstance(); - - const notes = await ownNotesOf(1); - const groups = SendouQ.lookingGroups(1, notes); - - expect(groups[groups.length - 1].members![0].id).toBe(5); - }); - - test("group with both negative and positive sentiment sorted last", async () => { - await createGroup([1]); - await createGroup([2]); - await createGroup([3]); - await createGroup([4]); - await createGroup([5]); - await createGroup([6, 7]); - await createGroup([8]); - - await createPrivateNote(1, 6, "POSITIVE"); - await createPrivateNote(1, 7, "NEGATIVE"); - - await refreshSendouQInstance(); - - const notes = await ownNotesOf(1); - const groups = SendouQ.lookingGroups(1, notes); - - expect(groups[groups.length - 1].members?.some((m) => m.id === 6)).toBe( - true, - ); - }); - }); - describe("skill-based sorting", () => { beforeEach(async () => { refreshUserSkills(1); @@ -858,26 +702,7 @@ describe("SendouQ", () => { dbReset(); }); - test("sentiment still takes priority over skill", async () => { - await insertSkill(1, 1000); - await insertSkill(2, 500); - await insertSkill(4, 2000); - - await createGroup([1]); - await createGroup([2]); - await createGroup([4]); - - await createPrivateNote(1, 4, "POSITIVE"); - - await refreshSendouQInstance(); - - const notes = await ownNotesOf(1); - const groups = SendouQ.lookingGroups(1, notes); - - expect(groups[0].members![0].id).toBe(4); - }); - - test("groups with closer skill sorted first within same sentiment", async () => { + test("groups with closer skill sorted first", async () => { await insertSkill(1, 1000); await insertSkill(2, 1050); await insertSkill(3, 500); @@ -890,8 +715,7 @@ describe("SendouQ", () => { await refreshSendouQInstance(); - const notes = await ownNotesOf(1); - const groups = SendouQ.lookingGroups(1, notes); + const groups = SendouQ.lookingGroups(1); expect(groups[0].members![0].id).toBe(2); }); @@ -914,8 +738,7 @@ describe("SendouQ", () => { await refreshSendouQInstance(); - const notes = await ownNotesOf(1); - const groups = SendouQ.lookingGroups(1, notes); + const groups = SendouQ.lookingGroups(1); expect(groups.length).toBeGreaterThan(0); expect(groups[0].id).toBe(closerGroup); @@ -946,8 +769,7 @@ describe("SendouQ", () => { await createGroup([1]); await refreshSendouQInstance(); - const notes = await ownNotesOf(1); - const groups = SendouQ.lookingGroups(1, notes); + const groups = SendouQ.lookingGroups(1); expect(groups[0].members![0].id).toBe(3); expect(groups[1].members![0].id).toBe(2); diff --git a/app/features/sendouq/core/SendouQ.server.ts b/app/features/sendouq/core/SendouQ.server.ts index 1da60ac09..31d7c204b 100644 --- a/app/features/sendouq/core/SendouQ.server.ts +++ b/app/features/sendouq/core/SendouQ.server.ts @@ -5,7 +5,6 @@ import type { AuthenticatedUser } from "~/features/auth/core/user.server"; import * as Seasons from "~/features/mmr/core/Seasons"; import { defaultOrdinal } from "~/features/mmr/mmr-utils"; import { type TieredSkill, userSkills } from "~/features/mmr/tiered.server"; -import type * as PrivateUserNoteRepository from "~/features/sendouq/PrivateUserNoteRepository.server"; import * as SQGroupRepository from "~/features/sendouq/SQGroupRepository.server"; import * as SendouQMatch from "~/features/sendouq-match/core/SendouQMatch"; import type * as SQMatchRepository from "~/features/sendouq-match/SQMatchRepository.server"; @@ -22,9 +21,6 @@ import { tierDifferenceToRangeOrExact } from "./groups.server"; type DBGroupRow = Awaited< ReturnType >[number]; -type DBPrivateNoteRow = Awaited< - ReturnType ->[number]; type DBRecentlyFinishedMatchRow = Awaited< ReturnType >[number]; @@ -42,7 +38,6 @@ export type SQOwnGroup = SerializeFrom< NonNullable> >; export type SQMatch = SerializeFrom>; -export type SQMatchGroup = SQMatch["groupAlpha"] | SQMatch["groupBravo"]; export type SQGroupMember = NonNullable[number]; const FALLBACK_TIER = { isPlus: false, name: "IRON" } as const; @@ -91,7 +86,6 @@ class SendouQClass { return { ...member, - privateNote: null as DBPrivateNoteRow | null, languages: member.languages?.split(",") || [], skill: !skill || skill.approximate ? ("CALCULATING" as const) : skill, mapModePreferences: undefined, @@ -165,8 +159,6 @@ class SendouQClass { match: DBMatch, /** The authenticated user viewing the match (if any) */ user?: AuthenticatedUser, - /** Array of private user notes to include */ - notes: DBPrivateNoteRow[] = [], ) { const viewerSide = SendouQMatch.resolveGroupMemberOf({ groupAlpha: match.groupAlpha, @@ -199,7 +191,6 @@ class SendouQClass { return { ...member, skill: match.memento?.users[member.id]?.skill, - privateNote: null as DBPrivateNoteRow | null, skillDifference: match.memento?.users[member.id]?.skillDifference, noScreen: undefined, isContinuing: @@ -244,8 +235,8 @@ class SendouQClass { ...match, chatCode: isMatchInsider ? match.chatCode : undefined, currentMap, - groupAlpha: this.#getAddMemberPrivateNoteMapper(notes)(alphaCensored), - groupBravo: this.#getAddMemberPrivateNoteMapper(notes)(bravoCensored), + groupAlpha: alphaCensored, + groupBravo: bravoCensored, }; } @@ -256,14 +247,11 @@ class SendouQClass { previewGroups( /** The ID of the user viewing the preview */ userId: number, - /** Array of private user notes to include */ - notes: DBPrivateNoteRow[], ) { const usersTier = this.#getUserTier(userId); return this.groups .filter((group) => this.#isSuitableLookingGroup({ group })) - .map(this.#getAddMemberPrivateNoteMapper(notes)) - .sort(this.#getSkillAndNoteSortComparator(usersTier)) + .sort(this.#getSkillSortComparator(usersTier)) .map((group) => this.#addPreviewTierRange(group)) .map((group) => this.#censorGroup(group)); } @@ -271,14 +259,12 @@ class SendouQClass { /** * Returns groups that are available for matchmaking for a specific user based on their current group size. * Filters groups based on member count compatibility, activity status, and excludes stale groups. - * Results are sorted by sentiment (notes), tier difference, and activity. + * Results are sorted by tier difference and activity. * @returns Array of compatible groups sorted by relevance, or empty array if user has no group */ lookingGroups( /** The ID of the user looking for groups */ userId: number, - /** Array of private user notes to include */ - notes: DBPrivateNoteRow[] = [], ) { const ownGroup = this.findOwnGroup(userId); if (!ownGroup) return []; @@ -302,8 +288,7 @@ class SendouQClass { ) .map(this.#getGroupReplayMapper(userId)) .map(this.#getAddTierRangeMapper(ownGroup.tier)) - .map(this.#getAddMemberPrivateNoteMapper(notes)) - .sort(this.#getSkillAndNoteSortComparator(ownGroup.tier)) + .sort(this.#getSkillSortComparator(ownGroup.tier)) .map((group) => this.#censorGroup(group)); } @@ -416,27 +401,10 @@ class SendouQClass { return skill.tier; } - #getAddMemberPrivateNoteMapper(notes: DBPrivateNoteRow[]) { - return (group: T) => { - const membersWithNotes = group.members.map((member) => { - const note = notes.find((n) => n.targetUserId === member.id); - return { - ...member, - privateNote: note ?? null, - }; - }); - - return { - ...group, - members: membersWithNotes, - }; - }; - } - - #getSkillAndNoteSortComparator(ownTier?: TieredSkill["tier"] | null) { + #getSkillSortComparator(ownTier?: TieredSkill["tier"] | null) { return < T extends { - members: { privateNote: DBPrivateNoteRow | null }[]; + members: unknown[]; tierRange: TierRange | null; tier: TieredSkill["tier"] | null; latestActionAt: number; @@ -452,26 +420,6 @@ class SendouQClass { return aIsFull ? 1 : -1; } - const getGroupSentimentScore = (group: T) => { - const hasNegative = group.members.some( - (m) => m.privateNote?.sentiment === "NEGATIVE", - ); - const hasPositive = group.members.some( - (m) => m.privateNote?.sentiment === "POSITIVE", - ); - - if (hasNegative) return -1; - if (hasPositive) return 1; - return 0; - }; - - const scoreA = getGroupSentimentScore(a); - const scoreB = getGroupSentimentScore(b); - - if (scoreA !== scoreB) { - return scoreB - scoreA; - } - if (a.tierRange && b.tierRange) { if (a.tierRange.diff[1] !== b.tierRange.diff[1]) { return a.tierRange.diff[1] - b.tierRange.diff[1]; diff --git a/app/features/sendouq/loaders/q.looking.server.ts b/app/features/sendouq/loaders/q.looking.server.ts index 5df1fc2d5..5c01b7bb4 100644 --- a/app/features/sendouq/loaders/q.looking.server.ts +++ b/app/features/sendouq/loaders/q.looking.server.ts @@ -1,10 +1,11 @@ import type { LoaderFunctionArgs } from "react-router"; +import * as R from "remeda"; import { requireUser } from "~/features/auth/core/user.server"; import * as SQGroupRepository from "~/features/sendouq/SQGroupRepository.server"; import { cachedStreams } from "~/features/sendouq-streams/core/streams.server"; +import * as UserCardRepository from "~/features/user-card/UserCardRepository.server"; import { groupExpiryStatus } from "../core/groups"; import { SendouQ } from "../core/SendouQ.server"; -import * as PrivateUserNoteRepository from "../PrivateUserNoteRepository.server"; import { sqRedirectIfNeeded } from "../q-utils.server"; export const loader = async ({ url }: LoaderFunctionArgs) => { @@ -14,15 +15,11 @@ export const loader = async ({ url }: LoaderFunctionArgs) => { url.searchParams.get("preview") === "true" && user.roles.includes("SUPPORTER"); - const privateNotes = await PrivateUserNoteRepository.ownNotes( - SendouQ.usersInQueue, - ); - const ownGroup = SendouQ.findOwnGroup(user.id); const groups = isPreview && !ownGroup - ? SendouQ.previewGroups(user.id, privateNotes) - : SendouQ.lookingGroups(user.id, privateNotes); + ? SendouQ.previewGroups(user.id) + : SendouQ.lookingGroups(user.id); if (!isPreview) { sqRedirectIfNeeded({ @@ -31,11 +28,23 @@ export const loader = async ({ url }: LoaderFunctionArgs) => { }); } + const groupsToShow = + ownGroup && groupExpiryStatus(ownGroup.latestActionAt) === "EXPIRED" + ? [] + : groups; + + const cardUserIds = R.unique([ + ...(ownGroup?.members ?? []).map((member) => member.id), + ...groupsToShow.flatMap((group) => + (group.members ?? []).map((member) => member.id), + ), + ]); + return { - groups: - ownGroup && groupExpiryStatus(ownGroup.latestActionAt) === "EXPIRED" - ? [] - : groups, + ...(await UserCardRepository.userCards({ + userIds: cardUserIds, + })), + groups: groupsToShow, ownGroup, likes: ownGroup ? await SQGroupRepository.allLikesByGroupId(ownGroup.id) diff --git a/app/features/sendouq/q-schemas.server.ts b/app/features/sendouq/q-schemas.server.ts index 5070bddb6..7781b2963 100644 --- a/app/features/sendouq/q-schemas.server.ts +++ b/app/features/sendouq/q-schemas.server.ts @@ -80,10 +80,6 @@ export const lookingSchema = z.union([ z.string().max(SENDOUQ.OWN_PUBLIC_NOTE_MAX_LENGTH).nullable(), ), }), - z.object({ - _action: _action("DELETE_PRIVATE_USER_NOTE"), - targetId: id, - }), ]); export const weaponUsageSearchParamsSchema = z.object({ diff --git a/app/features/sendouq/routes/q.looking.tsx b/app/features/sendouq/routes/q.looking.tsx index fde36a96a..b590e2597 100644 --- a/app/features/sendouq/routes/q.looking.tsx +++ b/app/features/sendouq/routes/q.looking.tsx @@ -18,6 +18,7 @@ import { Placeholder } from "~/components/Placeholder"; import { SubmitButton } from "~/components/SubmitButton"; import { useUser } from "~/features/auth/core/user"; import { useWebsocketRevalidation } from "~/features/chat/chat-hooks"; +import type { UserCardData } from "~/features/user-card/user-card-types"; import { useDateTimeFormat } from "~/hooks/intl/useDateTimeFormat"; import { useHydrated } from "~/hooks/useHydrated"; import { useMainContentWidth } from "~/hooks/useMainContentWidth"; @@ -239,6 +240,8 @@ function Groups() { const isFullGroup = data.ownGroup && data.ownGroup.members.length === FULL_GROUP_SIZE; + const groups = sortGroupsByPrivateNoteSentiment(data.groups, data.userCards); + const invitedGroupsDesktop = (
@@ -248,7 +251,7 @@ function Groups() { : "q:looking.columns.invited", )} - {data.groups + {groups .filter((group) => data.likes.given.some((like) => like.groupId === group.id), ) @@ -258,7 +261,6 @@ function Groups() { key={group.id} group={group} action="UNLIKE" - showNote ownGroup={data.ownGroup} layout={layout} /> @@ -272,7 +274,7 @@ function Groups() { {t("q:looking.columns.myGroup")} - + {data.ownGroup.inviteCode ? ( ) : null; - const neutralGroups = data.groups.filter( + const neutralGroups = groups.filter( (group) => !data.likes.given.some((like) => like.groupId === group.id) && !data.likes.received.some((like) => like.groupId === group.id), ); - const groupsReceivedLikesFrom = data.groups.filter((group) => + const groupsReceivedLikesFrom = groups.filter((group) => data.likes.received.some((like) => like.groupId === group.id), ); @@ -341,7 +343,7 @@ function Groups() { {t("q:looking.columns.available")} {(isMobile - ? data.groups.filter( + ? groups.filter( (group) => !data.likes.received.some( (like) => like.groupId === group.id, @@ -360,7 +362,6 @@ function Groups() { ? "UNLIKE" : "LIKE" } - showNote ownGroup={data.ownGroup} layout={layout} /> @@ -388,7 +389,6 @@ function Groups() { key={group.id} group={group} action={action()} - showNote ownGroup={data.ownGroup} layout={layout} /> @@ -426,7 +426,6 @@ function Groups() { key={group.id} group={group} action={action()} - showNote ownGroup={data.ownGroup} layout={layout} /> @@ -439,6 +438,38 @@ function Groups() { ); } +/** + * Floats groups the viewer has a positive private note on up and groups with a + * negative note down, while preserving the server's tier/activity ordering + * within each sentiment bucket and keeping full (censored) groups last. The + * note sentiment is read from the already-loaded `userCards` data so the server + * does not need to attach notes to group members. + */ +function sortGroupsByPrivateNoteSentiment< + T extends { members?: { id: number }[] }, +>(groups: T[], userCards: Map): T[] { + const sentimentScore = (group: T) => { + if (!group.members) return 0; + + let score = 0; + for (const member of group.members) { + const sentiment = userCards.get(member.id)?.privateNote?.sentiment; + if (sentiment === "NEGATIVE") return -1; + if (sentiment === "POSITIVE") score = 1; + } + + return score; + }; + + return groups.toSorted((a, b) => { + const aIsFull = !a.members; + const bIsFull = !b.members; + if (aIsFull !== bIsFull) return aIsFull ? 1 : -1; + + return sentimentScore(b) - sentimentScore(a); + }); +} + function ColumnHeader({ isMobile, children, diff --git a/app/features/settings/components/MatchProfileTab.tsx b/app/features/settings/components/MatchProfileTab.tsx index 5fe9eaf4d..a5448c50d 100644 --- a/app/features/settings/components/MatchProfileTab.tsx +++ b/app/features/settings/components/MatchProfileTab.tsx @@ -1,5 +1,8 @@ +import { Pencil } from "lucide-react"; import * as React from "react"; -import { useLoaderData } from "react-router"; +import { useTranslation } from "react-i18next"; +import { useLoaderData, useLocation } from "react-router"; +import { LinkButton } from "~/components/elements/Button"; import { ModeImage } from "~/components/Image"; import type { Preference, UserMapModePreferences } from "~/db/tables"; import { BANNED_MAPS } from "~/features/match-profile/banned-maps"; @@ -7,52 +10,70 @@ import { AMOUNT_OF_MAPS_IN_POOL_PER_MODE } from "~/features/match-profile/match- import { SendouForm } from "~/form/SendouForm"; import { modesShort } from "~/modules/in-game-lists/modes"; import type { ModeShort, StageId } from "~/modules/in-game-lists/types"; +import { userCardEditPage } from "~/utils/urls"; import type { loader } from "../loaders/settings.server"; import { updateMatchProfileSchema } from "../match-profile-schemas"; import { ModeMapPoolPicker } from "./ModeMapPoolPicker"; import { PreferenceRadioGroup } from "./PreferenceRadioGroup"; export function MatchProfileTab() { + const { t } = useTranslation(["user"]); const data = useLoaderData(); + const location = useLocation(); const matchProfile = data.matchProfile; if (!matchProfile) return null; return ( - ({ - id: w.weaponSplId, - isFavorite: Boolean(w.isFavorite), - })), - vc: matchProfile.vc ?? "NO", - languages: matchProfile.languages ?? [], - noScreen: Boolean(matchProfile.noScreen), - }} - revalidateRoot - > - {({ FormField }) => ( - <> - - {(props: { - value: unknown; - onChange: (value: UserMapModePreferences) => void; - }) => ( - - )} - - - - - - - )} - +
+ } + className="self-start" + > + {t("user:card.edit.title")} + + ({ + id: w.weaponSplId, + isFavorite: Boolean(w.isFavorite), + })), + vc: matchProfile.vc ?? "NO", + languages: matchProfile.languages ?? [], + noScreen: Boolean(matchProfile.noScreen), + }} + revalidateRoot + > + {({ FormField }) => ( + <> + + {(props: { + value: unknown; + onChange: (value: UserMapModePreferences) => void; + }) => ( + + )} + + + + + + + )} + +
); } diff --git a/app/features/settings/routes/settings.tsx b/app/features/settings/routes/settings.tsx index 36a959a44..e5641592e 100644 --- a/app/features/settings/routes/settings.tsx +++ b/app/features/settings/routes/settings.tsx @@ -35,7 +35,7 @@ import "./settings.global.css"; export { action, loader }; export const handle: SendouRouteHandle = { - i18n: ["settings"], + i18n: ["settings", "user"], breadcrumb: () => ({ imgPath: navIconUrl("settings"), href: SETTINGS_PAGE, diff --git a/app/features/top-search/XRankPlacementRepository.server.test.ts b/app/features/top-search/XRankPlacementRepository.server.test.ts index 8e79380d9..0a600cb93 100644 --- a/app/features/top-search/XRankPlacementRepository.server.test.ts +++ b/app/features/top-search/XRankPlacementRepository.server.test.ts @@ -84,8 +84,37 @@ describe("refreshAllPeakXp", () => { .orderBy("id", "asc") .execute(); - expect(players[0].peakXp).toBe(2700); - expect(players[1].peakXp).toBe(3000); + expect(players[0].peakXp).toEqual({ + overall: 2700, + tentatek: 2700, + takoroka: null, + }); + expect(players[1].peakXp).toEqual({ + overall: 3000, + tentatek: 3000, + takoroka: null, + }); + }); + + test("splits peakXp by division (region)", async () => { + const playerId = await createSplatoonPlayer("player1"); + + await createXRankPlacement({ playerId, power: 2700, region: "WEST" }); + await createXRankPlacement({ playerId, power: 2900, region: "JPN" }); + + await XRankPlacementRepository.refreshAllPeakXp(); + + const player = await db + .selectFrom("SplatoonPlayer") + .select("peakXp") + .where("id", "=", playerId) + .executeTakeFirstOrThrow(); + + expect(player.peakXp).toEqual({ + overall: 2900, + tentatek: 2700, + takoroka: 2900, + }); }); test("sets peakXp to null for player with no placements", async () => { diff --git a/app/features/top-search/XRankPlacementRepository.server.ts b/app/features/top-search/XRankPlacementRepository.server.ts index c5d357a44..a38c8e2ec 100644 --- a/app/features/top-search/XRankPlacementRepository.server.ts +++ b/app/features/top-search/XRankPlacementRepository.server.ts @@ -13,6 +13,25 @@ export function unlinkPlayerByUserId(userId: number) { .execute(); } +/** + * SQLite expression extracting a Splatoon player's overall peak XP from the denormalized `peakXp` + * JSON column (see {@link refreshAllPeakXp}). `"SplatoonPlayer"` must be in scope at the call site. + */ +export function peakXpOverallSql() { + return sql`"SplatoonPlayer"."peakXp" ->> '$.overall'`; +} + +/** Whether the user has a linked Splatoon player (i.e. has claimed their X Rank results). */ +export async function isPlayerLinkedByUserId(userId: number): Promise { + const player = await db + .selectFrom("SplatoonPlayer") + .select("SplatoonPlayer.id") + .where("SplatoonPlayer.userId", "=", userId) + .executeTakeFirst(); + + return Boolean(player); +} + function xRankPlacementsQueryBase() { return db .selectFrom("XRankPlacement") @@ -132,12 +151,23 @@ export type FindPlacement = InferResult< export async function refreshAllPeakXp() { await db .updateTable("SplatoonPlayer") - .set((eb) => ({ - peakXp: eb - .selectFrom("XRankPlacement") - .select((eb) => eb.fn.max("XRankPlacement.power").as("peakXp")) - .whereRef("XRankPlacement.playerId", "=", "SplatoonPlayer.id"), - })) + .set({ + // denormalized PeakXP json: overall + per-division peaks + // (region WEST = Tentatek, otherwise Takoroka). null when no placements. + peakXp: sql`( + select iif( + max("XRankPlacement"."power") is null, + null, + json_object( + 'overall', max("XRankPlacement"."power"), + 'tentatek', max(iif("XRankPlacement"."region" = 'WEST', "XRankPlacement"."power", null)), + 'takoroka', max(iif("XRankPlacement"."region" != 'WEST', "XRankPlacement"."power", null)) + ) + ) + from "XRankPlacement" + where "XRankPlacement"."playerId" = "SplatoonPlayer"."id" + )`, + }) .execute(); } diff --git a/app/features/top-search/components/HowToLinkPopover.module.css b/app/features/top-search/components/HowToLinkPopover.module.css new file mode 100644 index 000000000..456e86c79 --- /dev/null +++ b/app/features/top-search/components/HowToLinkPopover.module.css @@ -0,0 +1,23 @@ +.content { + display: flex; + flex-direction: column; + gap: var(--s-3); + white-space: normal; + font-size: var(--font-xs); +} + +.list { + margin: var(--s-1) 0 0; + padding-inline-start: var(--s-4); + display: flex; + flex-direction: column; + gap: var(--s-1); +} + +.example { + display: block; + padding: var(--s-2); + border-radius: var(--radius-box); + background-color: var(--color-bg-higher); + word-break: break-word; +} diff --git a/app/features/top-search/components/HowToLinkPopover.tsx b/app/features/top-search/components/HowToLinkPopover.tsx new file mode 100644 index 000000000..5cc94dcfa --- /dev/null +++ b/app/features/top-search/components/HowToLinkPopover.tsx @@ -0,0 +1,51 @@ +import { Trans, useTranslation } from "react-i18next"; +import { SendouButton } from "~/components/elements/Button"; +import { SendouPopover } from "~/components/elements/Popover"; +import { SENDOU_INK_DISCORD_URL } from "~/utils/urls"; +import styles from "./HowToLinkPopover.module.css"; + +const EXAMPLE_PLAYER_URL = "https://sendou.ink/xsearch/player/0"; + +export function HowToLinkPopover() { + const { t } = useTranslation(["common"]); + + return ( + + {t("common:xsearch.link.trigger")} + + } + > +
+
+
+ {t("common:xsearch.link.about.title")} +
+
    +
  • {t("common:xsearch.link.about.endPower")}
  • +
  • {t("common:xsearch.link.about.notPeak")}
  • +
  • {t("common:xsearch.link.about.wait")}
  • +
+
+
+
+ + Post this message on our Discord ( + + discord.gg/sendou + + ), on the #helpdesk channel: + +
+ + {t("common:xsearch.link.exampleRequest")} {EXAMPLE_PLAYER_URL} + +
+
+ {t("common:xsearch.link.noScreenshots")} +
+
+
+ ); +} diff --git a/app/features/top-search/routes/xsearch.player.$id.tsx b/app/features/top-search/routes/xsearch.player.$id.tsx index a2b01210e..f0f90b72b 100644 --- a/app/features/top-search/routes/xsearch.player.$id.tsx +++ b/app/features/top-search/routes/xsearch.player.$id.tsx @@ -15,6 +15,7 @@ import { userPage, } from "~/utils/urls"; import { action } from "../actions/xsearch.player.$id.server"; +import { HowToLinkPopover } from "../components/HowToLinkPopover"; import { PlacementsTable } from "../components/Placements"; import { loader } from "../loaders/xsearch.player.$id.server"; @@ -77,19 +78,22 @@ export default function XSearchPlayerPage() { return (
-

- {hasUserLinked(placementUser) ? ( - {data.names.primary} - ) : ( - data.names.primary - )}{" "} - {t("common:xsearch.placements")} -

- {data.names.aliases.length > 0 ? ( -
- {t("common:xsearch.aliases")} {data.names.aliases.join(", ")} -
- ) : null} +
+

+ {hasUserLinked(placementUser) ? ( + {data.names.primary} + ) : ( + data.names.primary + )}{" "} + {t("common:xsearch.placements")} +

+ {data.names.aliases.length > 0 ? ( +
+ {t("common:xsearch.aliases")} {data.names.aliases.join(", ")} +
+ ) : null} +
+ {!hasUserLinked(placementUser) ? : null}
{isLinkedToCurrentUser ? : null} diff --git a/app/features/tournament-admin/loaders/to.$id.admin.seeds.server.ts b/app/features/tournament-admin/loaders/to.$id.admin.seeds.server.ts new file mode 100644 index 000000000..ce833c8c7 --- /dev/null +++ b/app/features/tournament-admin/loaders/to.$id.admin.seeds.server.ts @@ -0,0 +1,27 @@ +import type { LoaderFunctionArgs } from "react-router"; +import * as R from "remeda"; +import { tournamentFromDBCached } from "~/features/tournament-bracket/core/Tournament.server"; +import * as UserCardRepository from "~/features/user-card/UserCardRepository.server"; +import { parseParams } from "~/utils/remix.server"; +import { idObject } from "~/utils/zod"; + +export const loader = async ({ params }: LoaderFunctionArgs) => { + const { id: tournamentId } = parseParams({ params, schema: idObject }); + + const tournament = await tournamentFromDBCached({ + tournamentId, + user: undefined, + }); + + const userIds = R.unique( + tournament.ctx.teams.flatMap((team) => + team.members.map((member) => member.userId), + ), + ); + + return { + ...(await UserCardRepository.userCards({ + userIds, + })), + }; +}; diff --git a/app/features/tournament-admin/routes/to.$id.admin.seeds.module.css b/app/features/tournament-admin/routes/to.$id.admin.seeds.module.css index 18ccea0b0..414b7d321 100644 --- a/app/features/tournament-admin/routes/to.$id.admin.seeds.module.css +++ b/app/features/tournament-admin/routes/to.$id.admin.seeds.module.css @@ -176,15 +176,6 @@ margin-left: var(--s-1); } -.plusTier { - display: inline-flex; - align-items: center; - justify-content: center; - gap: 2px; - font-size: var(--font-2xs); - color: var(--color-text-high); -} - .playerRemoved { text-decoration: line-through; color: var(--color-text-high); diff --git a/app/features/tournament-admin/routes/to.$id.admin.seeds.tsx b/app/features/tournament-admin/routes/to.$id.admin.seeds.tsx index 6c771ad2e..506c24e4e 100644 --- a/app/features/tournament-admin/routes/to.$id.admin.seeds.tsx +++ b/app/features/tournament-admin/routes/to.$id.admin.seeds.tsx @@ -18,7 +18,7 @@ import { import { CSS } from "@dnd-kit/utilities"; import clsx from "clsx"; import * as React from "react"; -import { Link, useFetcher, useNavigation } from "react-router"; +import { useFetcher, useNavigation } from "react-router"; import { Alert } from "~/components/Alert"; import { Avatar } from "~/components/Avatar"; import { SendouButton } from "~/components/elements/Button"; @@ -27,7 +27,6 @@ import { SendouChipRadioGroup, } from "~/components/elements/ChipRadio"; import { SendouDialog } from "~/components/elements/Dialog"; -import { Image } from "~/components/Image"; import { InfoPopover } from "~/components/InfoPopover"; import { SubmitButton } from "~/components/SubmitButton"; import { Table } from "~/components/Table"; @@ -37,12 +36,18 @@ import { TOURNAMENT } from "~/features/tournament/tournament-constants"; import * as AbDivisions from "~/features/tournament-bracket/core/AbDivisions"; import type { Tournament } from "~/features/tournament-bracket/core/Tournament"; import type { TournamentDataTeam } from "~/features/tournament-bracket/core/Tournament.server"; +import { UserCard } from "~/features/user-card/components/UserCard"; import invariant from "~/utils/invariant"; -import { navIconUrl, userResultsPage } from "~/utils/urls"; +import type { SendouRouteHandle } from "~/utils/remix.server"; import { ordinalToRoundedSp } from "../../mmr/mmr-utils"; import styles from "./to.$id.admin.seeds.module.css"; export { action } from "../actions/to.$id.admin.seeds.server"; +export { loader } from "../loaders/to.$id.admin.seeds.server"; + +export const handle: SendouRouteHandle = { + i18n: ["user", "q"], +}; const AB_DIVISION_RADIO_OPTIONS = [ { value: "unassigned", label: "Unassigned" }, @@ -755,20 +760,9 @@ function RowContents({ [styles.playerNew]: isNew, })} > - - {member.username} - - {member.plusTier ? ( - - - {member.plusTier} - - ) : null} + + {member.username} + {isNew ? ( NEW ) : null} diff --git a/app/features/tournament-lfg/components/LFGGroupCard.tsx b/app/features/tournament-lfg/components/LFGGroupCard.tsx index 8d160b9a6..807933c8d 100644 --- a/app/features/tournament-lfg/components/LFGGroupCard.tsx +++ b/app/features/tournament-lfg/components/LFGGroupCard.tsx @@ -3,23 +3,28 @@ import { Mic, Star, Trash, Volume2, VolumeX } from "lucide-react"; import * as React from "react"; import { Flipped } from "react-flip-toolkit"; import { useTranslation } from "react-i18next"; -import { Link, useFetcher } from "react-router"; +import { useFetcher } from "react-router"; import { Avatar } from "~/components/Avatar"; import { Divider } from "~/components/Divider"; import { SendouButton } from "~/components/elements/Button"; import { SendouPopover } from "~/components/elements/Popover"; import { FormWithConfirm } from "~/components/FormWithConfirm"; import { Image, WeaponImage } from "~/components/Image"; +import { NoteAvatar } from "~/components/NoteAvatar"; import { SubmitButton } from "~/components/SubmitButton"; import type { Pronouns } from "~/db/tables"; import { useUser } from "~/features/auth/core/user"; import { IS_Q_LOOKING_MOBILE_BREAKPOINT } from "~/features/sendouq/q-constants"; import { useTournament } from "~/features/tournament/routes/to.$id"; +import { + UserCard, + useUserCardData, +} from "~/features/user-card/components/UserCard"; import { SendouForm } from "~/form/SendouForm"; import { useMainContentWidth } from "~/hooks/useMainContentWidth"; import { languagesUnified } from "~/modules/i18n/config"; import type { MainWeaponId } from "~/modules/in-game-lists/types"; -import { navIconUrl, userPage } from "~/utils/urls"; +import { navIconUrl } from "~/utils/urls"; import { updateGroupFormSchema } from "../tournament-lfg-schemas"; import styles from "./LFGGroupCard.module.css"; @@ -194,14 +199,23 @@ function LFGGroupMemberRow({ showActions: boolean; isOwnGroup: boolean; }) { + const cardData = useUserCardData(member.id); + return (
- - - {member.username} - + + + + + + {member.username} + + {member.pronouns ? ( {member.pronouns.subject}/{member.pronouns.object} diff --git a/app/features/tournament-lfg/loaders/to.$id.looking.server.ts b/app/features/tournament-lfg/loaders/to.$id.looking.server.ts index 091246234..641acd0ae 100644 --- a/app/features/tournament-lfg/loaders/to.$id.looking.server.ts +++ b/app/features/tournament-lfg/loaders/to.$id.looking.server.ts @@ -1,7 +1,9 @@ import type { LoaderFunctionArgs } from "react-router"; +import * as R from "remeda"; import type { Pronouns } from "~/db/tables"; import { getUser } from "~/features/auth/core/user.server"; import { tournamentFromDBCached } from "~/features/tournament-bracket/core/Tournament.server"; +import * as UserCardRepository from "~/features/user-card/UserCardRepository.server"; import type { MainWeaponId } from "~/modules/in-game-lists/types"; import type { SerializeFrom } from "~/utils/remix"; import { parseParams } from "~/utils/remix.server"; @@ -83,8 +85,16 @@ async function lookingMode({ ownGroup, }); + const cardUserIds = R.unique([ + ...groups.flatMap((group) => group.members.map((member) => member.id)), + ...(ownTeam?.members ?? []).map((member) => member.id), + ]); + return { mode: "looking" as const, + ...(await UserCardRepository.userCards({ + userIds: cardUserIds, + })), groups: otherGroups, ownGroup, ownTeam, @@ -130,6 +140,9 @@ async function subsMode({ return { mode: "subs" as const, + ...(await UserCardRepository.userCards({ + userIds: subs.map((sub) => sub.userId), + })), subs, hasOwnSubPost: subs.some((sub) => sub.userId === user?.id), tournamentId, diff --git a/app/features/tournament-lfg/routes/to.$id.looking.tsx b/app/features/tournament-lfg/routes/to.$id.looking.tsx index 8791a61a0..aea6d7d9f 100644 --- a/app/features/tournament-lfg/routes/to.$id.looking.tsx +++ b/app/features/tournament-lfg/routes/to.$id.looking.tsx @@ -3,7 +3,7 @@ import { Mic, Trash } from "lucide-react"; import * as React from "react"; import { Flipper } from "react-flip-toolkit"; import { useTranslation } from "react-i18next"; -import { Link, useFetcher, useLoaderData } from "react-router"; +import { useFetcher, useLoaderData } from "react-router"; import { Avatar } from "~/components/Avatar"; import { SendouButton } from "~/components/elements/Button"; import { SendouDialog } from "~/components/elements/Dialog"; @@ -16,16 +16,20 @@ import { } from "~/components/elements/Tabs"; import { FormWithConfirm } from "~/components/FormWithConfirm"; import { WeaponImage } from "~/components/Image"; +import { NoteAvatar } from "~/components/NoteAvatar"; import { Placeholder } from "~/components/Placeholder"; import { SubmitButton } from "~/components/SubmitButton"; import { useUser } from "~/features/auth/core/user"; import { IS_Q_LOOKING_MOBILE_BREAKPOINT } from "~/features/sendouq/q-constants"; import { useTournament } from "~/features/tournament/routes/to.$id"; +import { + UserCard, + useUserCardData, +} from "~/features/user-card/components/UserCard"; import { SendouForm } from "~/form/SendouForm"; import { useHydrated } from "~/hooks/useHydrated"; import { useMainContentWidth } from "~/hooks/useMainContentWidth"; import type { SendouRouteHandle } from "~/utils/remix.server"; -import { userPage } from "~/utils/urls"; import { LFGGroupCard } from "../components/LFGGroupCard"; import { type LookingLoaderData, @@ -43,7 +47,7 @@ export { loader }; import styles from "./to.$id.looking.module.css"; export const handle: SendouRouteHandle = { - i18n: ["q", "tournament", "forms", "common"], + i18n: ["q", "tournament", "forms", "common", "user"], }; export default function TournamentLFGShell() { @@ -285,6 +289,7 @@ function SubCard({ sub }: { sub: SubEntry }) { const { t } = useTranslation(["common", "tournament"]); const user = useUser(); const tournament = useTournament(); + const cardData = useUserCardData(sub.userId); const infos = [
@@ -322,10 +327,18 @@ function SubCard({ sub }: { sub: SubEntry }) { return (
- - - {sub.username} - +
+ + + + + +
+
+ + {sub.username} + +
{infos}
{sub.weapons ? (
diff --git a/app/features/tournament-match/loaders/to.$id.matches.$mid.server.ts b/app/features/tournament-match/loaders/to.$id.matches.$mid.server.ts index 8f11b732e..c94ae4db0 100644 --- a/app/features/tournament-match/loaders/to.$id.matches.$mid.server.ts +++ b/app/features/tournament-match/loaders/to.$id.matches.$mid.server.ts @@ -11,6 +11,7 @@ import * as PickBan from "~/features/tournament-bracket/core/PickBan"; import { tournamentFromDBCached } from "~/features/tournament-bracket/core/Tournament.server"; import { matchPageParamsSchema } from "~/features/tournament-bracket/tournament-bracket-schemas.server"; import { tournamentTeamToActiveRosterUserIds } from "~/features/tournament-bracket/tournament-bracket-utils"; +import * as UserCardRepository from "~/features/user-card/UserCardRepository.server"; import * as UserRepository from "~/features/user-page/UserRepository.server"; import { Status } from "~/modules/brackets-model"; import { cache, IN_MILLISECONDS, ttl } from "~/utils/cache.server"; @@ -218,6 +219,12 @@ export const loader = async ({ params }: LoaderFunctionArgs) => { !isLeagueRoundLocked(tournament, match.roundId); return { + ...(await UserCardRepository.userCards({ + userIds: match.players.map((p) => p.id), + include: { + friendCode: isParticipant || isSiteStaff || isTournamentStaff, + }, + })), match: hasPermsToSeeChat ? match : { ...match, chatCode: undefined }, results, reportedWeapons, diff --git a/app/features/tournament-match/routes/to.$id.matches.$mid.tsx b/app/features/tournament-match/routes/to.$id.matches.$mid.tsx index 92329cb78..b3ade903b 100644 --- a/app/features/tournament-match/routes/to.$id.matches.$mid.tsx +++ b/app/features/tournament-match/routes/to.$id.matches.$mid.tsx @@ -15,7 +15,7 @@ import { tournamentMatchWebsocketRoom } from "../tournament-match-utils"; export { action, loader }; export const handle: SendouRouteHandle = { - i18n: ["q"], + i18n: ["q", "user"], }; export default function TournamentMatchPage() { diff --git a/app/features/tournament/TournamentRepository.server.ts b/app/features/tournament/TournamentRepository.server.ts index de27605bf..4f01164f8 100644 --- a/app/features/tournament/TournamentRepository.server.ts +++ b/app/features/tournament/TournamentRepository.server.ts @@ -421,6 +421,39 @@ export async function findChildTournaments(parentTournamentId: number) { })); } +/** Child division tournaments of a league sign-up, with their name and finalized status. */ +export function findChildTournamentsForDivCalc(parentTournamentId: number) { + return db + .selectFrom("Tournament") + .innerJoin("CalendarEvent", "Tournament.id", "CalendarEvent.tournamentId") + .select([ + "Tournament.id as tournamentId", + "CalendarEvent.name", + "Tournament.isFinalized", + ]) + .where("Tournament.parentTournamentId", "=", parentTournamentId) + .execute(); +} + +/** + * User ids eligible for a LUTI division placement in the given tournament: they have a result, were + * on a team that did not drop out, and played at least one match. + */ +export function findLeagueDivParticipantUserIds(tournamentId: number) { + return db + .selectFrom("TournamentResult") + .innerJoin( + "TournamentTeam", + "TournamentTeam.id", + "TournamentResult.tournamentTeamId", + ) + .select("TournamentResult.userId") + .distinct() + .where("TournamentResult.tournamentId", "=", tournamentId) + .where("TournamentTeam.droppedOut", "=", 0) + .execute(); +} + export async function findTOSetMapPoolById(tournamentId: number) { return ( await db diff --git a/app/features/user-card/UserCardRepository.server.test.ts b/app/features/user-card/UserCardRepository.server.test.ts new file mode 100644 index 000000000..4e34aafea --- /dev/null +++ b/app/features/user-card/UserCardRepository.server.test.ts @@ -0,0 +1,161 @@ +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { db } from "~/db/sql"; +import { dbInsertUsers, dbReset, withNoUser, withUserId } from "~/utils/Test"; +import * as UserCardRepository from "./UserCardRepository.server"; + +describe("UserCardRepository.userCards", () => { + beforeEach(async () => { + await dbInsertUsers(2); + }); + + afterEach(() => { + dbReset(); + }); + + it("returns an empty map when given no user ids", async () => { + const { userCards } = await withNoUser(() => + UserCardRepository.userCards({ + userIds: [], + }), + ); + + expect(userCards.size).toBe(0); + }); + + it("keys cards by user id and builds the stats array from db fields", async () => { + await db + .updateTable("User") + .set({ + div: "1", + unverifiedPeakXP: JSON.stringify({ + overall: 3000, + takoroka: 3000, + tentatek: null, + }), + }) + .where("id", "=", 1) + .execute(); + await db.insertInto("PlusTier").values({ userId: 1, tier: 2 }).execute(); + + const { userCards } = await withNoUser(() => + UserCardRepository.userCards({ + userIds: [1, 2], + }), + ); + + expect(userCards.size).toBe(2); + + const card = userCards.get(1); + expect(card?.id).toBe(1); + expect(card?.freeAgentPostId).toBeNull(); + + const statTypes = card?.stats.map((stat) => stat.type) ?? []; + expect(statTypes).toContain("XP"); + expect(statTypes).toContain("DIV"); + expect(statTypes).toContain("PLUS"); + + expect(card?.stats.find((stat) => stat.type === "XP")).toMatchObject({ + type: "XP", + values: [{ isVerified: false, region: "JPN", points: 3000 }], + }); + expect(card?.stats.find((stat) => stat.type === "DIV")).toMatchObject({ + type: "DIV", + value: "1", + }); + expect(card?.stats.find((stat) => stat.type === "PLUS")).toMatchObject({ + type: "PLUS", + value: 2, + }); + + // user 2 has none of the optional fields -> no stats + expect(userCards.get(2)?.stats).toHaveLength(0); + }); + + it("persists edited card fields and surfaces hidden stats", async () => { + await db.insertInto("PlusTier").values({ userId: 1, tier: 2 }).execute(); + + await withUserId(1, () => + UserCardRepository.updateOwnCard({ + shortBio: "hello", + bannerPresetImg: "#ff4655", + bannerImgId: null, + unverifiedPeakXP: { overall: 2500, takoroka: null, tentatek: 2500 }, + hiddenCardStats: ["XP"], + }), + ); + + const { userCards } = await withUserId(1, () => + UserCardRepository.userCards({ + userIds: [1], + }), + ); + const card = userCards.get(1); + + expect(card?.shortBio).toBe("hello"); + expect(card?.banner).toMatchObject({ type: "COLOR", hexCode: "#ff4655" }); + // the hidden stat is filtered out of `stats` at query time + expect(card?.stats.find((stat) => stat.type === "XP")).toBeUndefined(); + expect(card?.stats.find((stat) => stat.type === "PLUS")).toMatchObject({ + type: "PLUS", + value: 2, + }); + + const extras = await UserCardRepository.cardEditExtras(1); + expect(extras.hiddenCardStats).toEqual(["XP"]); + }); + + it("keeps hidden stats in `stats` when includeHiddenStats is set", async () => { + await db.insertInto("PlusTier").values({ userId: 1, tier: 2 }).execute(); + + await withUserId(1, () => + UserCardRepository.updateOwnCard({ + shortBio: null, + bannerPresetImg: null, + bannerImgId: null, + unverifiedPeakXP: { overall: 2500, takoroka: null, tentatek: 2500 }, + hiddenCardStats: ["XP"], + }), + ); + + const { userCards } = await withUserId(1, () => + UserCardRepository.userCards({ + userIds: [1], + includeHiddenStats: true, + }), + ); + const card = userCards.get(1); + + expect(card?.stats.find((stat) => stat.type === "XP")).toMatchObject({ + type: "XP", + values: [{ isVerified: false, region: "WEST", points: 2500 }], + }); + }); + + it("produces a URL banner when an uploaded banner image is set", async () => { + const image = await db + .insertInto("UnvalidatedUserSubmittedImage") + .values({ url: "banner.webp", submitterUserId: 1, validatedAt: 1 }) + .returning("id") + .executeTakeFirstOrThrow(); + + await withUserId(1, () => + UserCardRepository.updateOwnCard({ + shortBio: null, + bannerPresetImg: null, + bannerImgId: image.id, + unverifiedPeakXP: null, + hiddenCardStats: [], + }), + ); + + const { userCards } = await withUserId(1, () => + UserCardRepository.userCards({ + userIds: [1], + }), + ); + + const banner = userCards.get(1)?.banner; + expect(banner?.type).toBe("URL"); + expect(banner).toHaveProperty("url"); + }); +}); diff --git a/app/features/user-card/UserCardRepository.server.ts b/app/features/user-card/UserCardRepository.server.ts new file mode 100644 index 000000000..d0b3dde0b --- /dev/null +++ b/app/features/user-card/UserCardRepository.server.ts @@ -0,0 +1,563 @@ +import { sub } from "date-fns"; +import type { Expression, ExpressionBuilder } from "kysely"; +import { sql } from "kysely"; +import { jsonBuildObject, jsonObjectFrom } from "kysely/helpers/sqlite"; +import { db } from "~/db/sql"; +import type { + CustomTheme, + HideableUserCardStat, + PeakXP, + Tables, + XRankPlacementRegion, +} from "~/db/tables"; +import { actorId, actorIdOrNull } from "~/features/auth/core/user.server"; +import { cachedFullUserLeaderboard } from "~/features/leaderboards/core/leaderboards.server"; +import { LFG } from "~/features/lfg/lfg-constants"; +import * as Seasons from "~/features/mmr/core/Seasons"; +import { TIERS } from "~/features/mmr/mmr-constants"; +import type { TieredSkill } from "~/features/mmr/tiered.server"; +import { userSkills } from "~/features/mmr/tiered.server"; +import type { StageId } from "~/modules/in-game-lists/types"; +import { dateToDatabaseTimestamp } from "~/utils/dates"; +import { + commonUserObjectFields, + concatUserSubmittedImagePrefix, +} from "~/utils/kysely.server"; +import { PRESET_COLORS } from "../tier-list-maker/tier-list-maker-constants"; +import type { + UserCardData, + UserCardStat, + UserCardStatXPValue, +} from "./user-card-types"; + +/** + * Loads `UserCardData` for many users at once, keyed by user id. The single batched DB query (see + * {@link userCardDataJsonObject}) is merged with the in-memory SEASON caches (tier from + * `userSkills`, leaderboard placement from `cachedFullUserLeaderboard`) in this app-layer enrich + * pass, producing the fully-formed `stats` array each card renders. The acting user viewing the + * cards (resolved from request context via `actorIdOrNull()`, or `null` when anonymous) scopes the + * per-viewer `privateNote`. + * + * Designed to be spread into a route loader (`{ ...(await userCards(...)) }`) so the `UserCard` + * component can resolve its own data from the route tree by id. + */ +export async function userCards({ + userIds, + include, + includeHiddenStats = false, +}: { + userIds: Array; + /** Opt-in fields skipped from the query by default; defaults to `false` each. */ + include?: { friendCode?: boolean }; + /** + * Keep stats the user has hidden in the resolved `stats` array. Off by default so hidden stat + * values never reach a viewer; the edit page opts in to render (and un-hide) its own toggles. + */ + includeHiddenStats?: boolean; +}): Promise<{ userCards: Map }> { + if (userIds.length === 0) return { userCards: new Map() }; + + const viewerId = actorIdOrNull(); + + // a user's card surfaces the better of their last two finished seasons (see bestSeasonResult) + const [rows, seasonResults] = await Promise.all([ + db + .selectFrom("User") + .select((eb) => + userCardDataJsonObject(eb, { viewerId, include }).as("cardData"), + ) + .where("User.id", "in", userIds) + .execute(), + Promise.all( + Seasons.allFinished() + .slice(0, 2) + .map((season) => seasonResult(season, userIds)), + ), + ]); + + const userCards = new Map(); + for (const { cardData } of rows) { + userCards.set( + cardData.id, + enrichUserCardData( + cardData, + bestSeasonResult(cardData.id, seasonResults), + includeHiddenStats, + ), + ); + } + + return { userCards }; +} + +/** + * Raw card fields the edit form needs that are not part of {@link UserCardData}: the uploaded banner + * image (id + preview url, for the image field's default value), the self-reported peak XP, and the + * hidden stat types (to pre-check the visibility toggles). + */ +export async function cardEditExtras(userId: number) { + const row = await db + .selectFrom("User") + .select((eb) => [ + "User.bannerImgId", + "User.unverifiedPeakXP", + "User.hiddenCardStats", + bannerImageUrl(eb).as("bannerImageUrl"), + ]) + .where("User.id", "=", userId) + .executeTakeFirst(); + + return { + bannerImgId: row?.bannerImgId ?? null, + bannerImageUrl: row?.bannerImageUrl ?? null, + unverifiedPeakXP: row?.unverifiedPeakXP ?? null, + hiddenCardStats: row?.hiddenCardStats ?? [], + }; +} + +/** Updates the editable user card fields of the acting user (their own card). */ +export function updateOwnCard(args: { + shortBio: string | null; + bannerPresetImg: string | null; + bannerImgId: number | null; + unverifiedPeakXP: PeakXP | null; + hiddenCardStats: Array; +}) { + const userId = actorId(); + return db.transaction().execute(async (trx) => { + // a removed or replaced uploaded banner is no longer referenced by anything, + // so its submitted image row is cleaned up (mirrors custom avatar handling) + const current = await trx + .selectFrom("User") + .select("User.bannerImgId") + .where("id", "=", userId) + .executeTakeFirst(); + if (current?.bannerImgId && current.bannerImgId !== args.bannerImgId) { + await trx + .deleteFrom("UnvalidatedUserSubmittedImage") + .where("id", "=", current.bannerImgId) + .where("UnvalidatedUserSubmittedImage.submitterUserId", "=", userId) + .execute(); + } + + await trx + .updateTable("User") + .set({ + shortBio: args.shortBio, + bannerPresetImg: args.bannerPresetImg, + bannerImgId: args.bannerImgId, + unverifiedPeakXP: args.unverifiedPeakXP + ? JSON.stringify(args.unverifiedPeakXP) + : null, + hiddenCardStats: + args.hiddenCardStats.length > 0 + ? JSON.stringify(args.hiddenCardStats) + : null, + }) + .where("id", "=", userId) + .execute(); + }); +} + +/** SQLite `case` expression mapping `User.id % PRESET_COLORS.length` to a preset banner color. */ +const BANNER_PRESET_COLOR_CASE = `case "User"."id" % ${PRESET_COLORS.length}\n${PRESET_COLORS.map( + (color, index) => `when ${index} then '${color}'`, +).join("\n")}\nend`; + +/** + * Kysely expression building the JSON object for all DB-resident `UserCard` fields of a single user. + * Designed to be composed both standalone (one user) and inside a batched list query (see + * {@link userCards}). `"User"` must be in scope at the call site. + * + * SEASON stats (tier + leaderboard placement) are NOT included here — they live in the in-memory + * `userSkills`/leaderboard caches and are merged in an app-layer enrich pass. `banner` is returned as + * loosely-typed fields (narrow to the discriminated union there). `friendCode` is opt-in via + * `include.friendCode` (defaults to off, resolving to `null`) so callers that never surface it skip + * the extra correlated subquery. + */ +function userCardDataJsonObject( + eb: ExpressionBuilder, + { + viewerId, + include, + }: { + viewerId: number | null; + include?: { friendCode?: boolean }; + }, +) { + return jsonBuildObject({ + ...commonUserObjectFields(eb), + shortBio: eb.ref("User.shortBio"), + div: eb.ref("User.div"), + customTheme: sql`IIF(COALESCE("User"."patronTier", 0) >= 2, "User"."customTheme", null)`, + hiddenCardStats: eb.ref("User.hiddenCardStats"), + banner: bannerJson(eb), + friendCode: include?.friendCode + ? friendCodeScalar(eb) + : sql`null`, + privateNote: privateNoteJson(eb, viewerId), + freeAgentPostId: freeAgentPostIdScalar(eb), + plusTier: plusTierScalar(eb), + xpVerified: xpVerifiedJson(eb), + xpUnverified: xpUnverifiedJson(), + }); +} + +type RawUserCardData = + ReturnType extends Expression + ? T + : never; + +/** + * Loosely-typed banner. A supporter-uploaded image (`User.bannerImgId`) takes precedence and yields + * a `URL` banner; otherwise it is pulled from the `User.bannerPresetImg` column ("hex code or stage + * id") where a numeric value is a stage id (`STAGE`) and anything else a `COLOR` hex code. When both + * are null (no explicit choice) a preset color is derived from the user id. Narrow to the + * `{ URL | COLOR | STAGE }` union in the enrich pass. + */ +function bannerJson(eb: ExpressionBuilder) { + return jsonBuildObject({ + type: sql<"URL" | "COLOR" | "STAGE">` + case + when "User"."bannerImgId" is not null then 'URL' + when "User"."bannerPresetImg" GLOB '[0-9]*' then 'STAGE' + else 'COLOR' + end`, + url: bannerImageUrl(eb), + hexCode: sql` + case + when "User"."bannerPresetImg" is null then (${sql.raw(BANNER_PRESET_COLOR_CASE)}) + when "User"."bannerPresetImg" GLOB '[0-9]*' then null + else "User"."bannerPresetImg" + end`, + stageId: sql< + number | null + >`iif("User"."bannerPresetImg" GLOB '[0-9]*', CAST("User"."bannerPresetImg" AS INTEGER), null)`, + }); +} + +/** Full URL of the supporter-uploaded banner image (resolved from `User.bannerImgId`), or null. */ +function bannerImageUrl(eb: ExpressionBuilder) { + return concatUserSubmittedImagePrefix( + eb + .selectFrom("UserSubmittedImage") + .select("UserSubmittedImage.url") + .whereRef("UserSubmittedImage.id", "=", "User.bannerImgId") + .$asScalar(), + ).$castTo(); +} + +function friendCodeScalar(eb: ExpressionBuilder) { + return eb + .selectFrom("UserFriendCode") + .select("UserFriendCode.friendCode") + .whereRef("UserFriendCode.userId", "=", "User.id") + .orderBy("UserFriendCode.createdAt", "desc") + .limit(1) + .$asScalar(); +} + +function privateNoteJson( + eb: ExpressionBuilder, + viewerId: number | null, +) { + if (viewerId === null) { + return sql | null>`null`; + } + + return jsonObjectFrom( + eb + .selectFrom("PrivateUserNote") + .select([ + "PrivateUserNote.text", + "PrivateUserNote.sentiment", + "PrivateUserNote.updatedAt", + ]) + .where("PrivateUserNote.authorId", "=", viewerId) + .whereRef("PrivateUserNote.targetId", "=", "User.id"), + ); +} + +/** + * Id of the user's most recent non-expired "looking for team" LFG post, which marks them as a free + * agent. `null` when they have no such post. Mirrors the LFG page's freshness cutoff so the id always + * points at a post that is still listed there. + */ +function freeAgentPostIdScalar(eb: ExpressionBuilder) { + return eb + .selectFrom("LFGPost") + .select("LFGPost.id") + .whereRef("LFGPost.authorId", "=", "User.id") + .where("LFGPost.type", "=", "PLAYER_FOR_TEAM") + .where( + "LFGPost.updatedAt", + ">", + dateToDatabaseTimestamp( + sub(new Date(), { days: LFG.POST_FRESHNESS_DAYS }), + ), + ) + .orderBy("LFGPost.updatedAt", "desc") + .limit(1) + .$asScalar(); +} + +function plusTierScalar(eb: ExpressionBuilder) { + return eb + .selectFrom("PlusTier") + .select("PlusTier.tier") + .whereRef("PlusTier.userId", "=", "User.id") + .$asScalar(); +} + +/** Single highest X Rank power placement (verified XP). */ +function xpVerifiedJson(eb: ExpressionBuilder) { + return jsonObjectFrom( + eb + .selectFrom("XRankPlacement") + .innerJoin( + "SplatoonPlayer", + "SplatoonPlayer.id", + "XRankPlacement.playerId", + ) + .whereRef("SplatoonPlayer.userId", "=", "User.id") + .select([ + sql`"XRankPlacement"."power"`.as("points"), + "XRankPlacement.region", + ]) + .orderBy("XRankPlacement.power", "desc") + .limit(1), + ); +} + +/** + * Self-reported peak XP from the `User.unverifiedPeakXP` column. Has exactly one of `tentatek` / + * `takoroka` defined, which decides the region (`tentatek` = `WEST`, `takoroka` = `JPN`); `points` + * is that region's value. + */ +function xpUnverifiedJson() { + return sql<{ points: number; region: XRankPlacementRegion } | null>` + iif( + "User"."unverifiedPeakXP" is null, + null, + json_object( + 'points', "User"."unverifiedPeakXP" ->> '$.overall', + 'region', iif("User"."unverifiedPeakXP" ->> '$.tentatek' is not null, 'WEST', 'JPN') + ) + ) + `; +} + +type SeasonResult = { + skills: Record; + placementsByUserId: Map; +}; + +/** + * Resolves one finished season's data for the requested users. `userSkills` is a synchronous + * in-memory cache, so we read tiers first and only fetch the (DB-backed) leaderboard when at least + * one requested user reached Leviathan+ that season—placements are surfaced for that rank only, so + * the common case of regular users never touches the leaderboard cache at all. + */ +async function seasonResult( + season: number, + userIds: Array, +): Promise { + const skills = userSkills(season).userSkills; + + const anyLeviathanPlus = userIds.some((id) => { + const skill = skills[id]; + return ( + skill !== undefined && !skill.approximate && isLeviathanPlus(skill.tier) + ); + }); + + const placementsByUserId = anyLeviathanPlus + ? await finishedSeasonPlacements(season) + : new Map(); + + return { skills, placementsByUserId }; +} + +const placementsBySeason = new Map>(); + +/** + * Leaderboard placements of a finished season, keyed by user id. Cached for the process lifetime: + * a finished season's leaderboard is immutable, matching how `userSkills` already holds finished + * seasons' tiers permanently. This keeps cards off `cachedFullUserLeaderboard`'s TTL, whose + * synchronous rebuild would otherwise stall the first Leviathan+ card render after a quiet period. + */ +async function finishedSeasonPlacements( + season: number, +): Promise> { + const cached = placementsBySeason.get(season); + if (cached) return cached; + + const placements = new Map( + (await cachedFullUserLeaderboard(season)).map((entry) => [ + entry.id, + entry.placementRank, + ]), + ); + placementsBySeason.set(season, placements); + + return placements; +} + +const isLeviathanPlus = (tier: TieredSkill["tier"]) => + tier.name === "LEVIATHAN" && tier.isPlus; + +/** + * Comparable strength of a tier (higher = better). Based on the tier's position in `TIERS` rather + * than the raw ordinal, because each season sets its own ordinal thresholds—the same ordinal can + * map to different tiers across seasons—so only the tier itself is comparable. `isPlus` (top half of + * a tier) breaks ties within the same tier name. + */ +const tierStrength = (tier: TieredSkill["tier"]) => { + const index = TIERS.findIndex((t) => t.name === tier.name); + return (TIERS.length - index) * 2 + (tier.isPlus ? 1 : 0); +}; + +/** + * Reduces a user's results across the last two finished seasons into the single result their card + * shows: the highest tier they reached (ignoring `approximate` tiers, which lack enough matches to + * count), and—only when that includes the very top Leviathan+ rank—their best (lowest) leaderboard + * placement among the Leviathan+ seasons. + */ +function bestSeasonResult( + userId: number, + seasonResults: Array, +): { seasonSkill: TieredSkill | undefined; seasonTop: number | null } { + let seasonSkill: TieredSkill | undefined; + let seasonTop: number | null = null; + + for (const { skills, placementsByUserId } of seasonResults) { + const skill = skills[userId]; + if (!skill || skill.approximate) continue; + + if ( + !seasonSkill || + tierStrength(skill.tier) > tierStrength(seasonSkill.tier) + ) { + seasonSkill = skill; + } + + if (isLeviathanPlus(skill.tier)) { + const placement = placementsByUserId.get(userId); + if ( + typeof placement === "number" && + (seasonTop === null || placement < seasonTop) + ) { + seasonTop = placement; + } + } + } + + return { seasonSkill, seasonTop }; +} + +function enrichUserCardData( + cardData: RawUserCardData, + { + seasonSkill, + seasonTop, + }: { seasonSkill: TieredSkill | undefined; seasonTop: number | null }, + includeHiddenStats: boolean, +): UserCardData { + const hiddenStats: Array = + cardData.hiddenCardStats ?? []; + + const stats = userCardStats({ + div: cardData.div, + plusTier: cardData.plusTier, + xpVerified: cardData.xpVerified, + xpUnverified: cardData.xpUnverified, + seasonSkill, + seasonTop, + }); + + return { + id: cardData.id, + username: cardData.username, + discordId: cardData.discordId, + discordAvatar: cardData.discordAvatar, + customUrl: cardData.customUrl, + customAvatarUrl: cardData.customAvatarUrl, + shortBio: cardData.shortBio, + customTheme: cardData.customTheme, + banner: enrichBanner(cardData.banner), + friendCode: cardData.friendCode, + freeAgentPostId: cardData.freeAgentPostId, + privateNote: cardData.privateNote, + stats: includeHiddenStats + ? stats + : stats.filter((stat) => !hiddenStats.includes(stat.type)), + }; +} + +function enrichBanner( + banner: RawUserCardData["banner"], +): UserCardData["banner"] { + if (banner.type === "URL" && banner.url) { + return { type: "URL", url: banner.url }; + } + + if (banner.type === "STAGE") { + return { type: "STAGE", stageId: banner.stageId as StageId }; + } + + return { type: "COLOR", hexCode: banner.hexCode ?? "" }; +} + +function userCardStats({ + div, + plusTier, + xpVerified, + xpUnverified, + seasonSkill, + seasonTop, +}: { + div: string | null; + plusTier: number | null; + xpVerified: { points: number; region: XRankPlacementRegion } | null; + xpUnverified: { points: number; region: XRankPlacementRegion } | null; + seasonSkill: TieredSkill | undefined; + seasonTop: number | null; +}): Array { + const stats: Array = []; + + const xpValues: Array = []; + if (xpUnverified) { + xpValues.push({ + isVerified: false, + region: xpUnverified.region, + points: xpUnverified.points, + }); + } + if (xpVerified) { + xpValues.push({ + isVerified: true, + region: xpVerified.region, + points: xpVerified.points, + }); + } + if (xpValues.length > 0) { + stats.push({ type: "XP", values: xpValues }); + } + + if (seasonSkill && !seasonSkill.approximate) { + stats.push({ type: "SEASON", value: seasonSkill.tier, top: seasonTop }); + } + + if (typeof plusTier === "number") { + stats.push({ type: "PLUS", value: plusTier }); + } + + if (div) { + stats.push({ type: "DIV", value: div }); + } + + return stats; +} diff --git a/app/features/user-card/actions/user-card.edit.server.ts b/app/features/user-card/actions/user-card.edit.server.ts new file mode 100644 index 000000000..3533a74fd --- /dev/null +++ b/app/features/user-card/actions/user-card.edit.server.ts @@ -0,0 +1,108 @@ +import { type ActionFunction, redirect } from "react-router"; +import type { HideableUserCardStat } from "~/db/tables"; +import { requireUser } from "~/features/auth/core/user.server"; +import * as XRankPlacementRepository from "~/features/top-search/XRankPlacementRepository.server"; +import { parseFormDataWithImages } from "~/form/parse.server"; +import { userPage } from "~/utils/urls"; +import * as UserCardRepository from "../UserCardRepository.server"; +import { updateUserCardSchema } from "../user-card-schemas"; +import { maxUnverifiedXp } from "../user-card-utils"; + +export const action: ActionFunction = async ({ request }) => { + const user = requireUser(); + + const returnTo = safeReturnTo( + new URL(request.url).searchParams.get("returnTo"), + ); + + const result = await parseFormDataWithImages({ + request, + schema: updateUserCardSchema, + }); + + if (!result.success) { + return { fieldErrors: result.fieldErrors }; + } + + const data = result.data; + + if (data.unverifiedXpPoints) { + const hasLinkedPlayer = + await XRankPlacementRepository.isPlayerLinkedByUserId(user.id); + const max = maxUnverifiedXp({ + division: data.unverifiedXpDivision, + hasLinkedPlayer, + }); + if (data.unverifiedXpPoints > max) { + return { + fieldErrors: { unverifiedXpPoints: "forms:errors.unverifiedXpTooHigh" }, + }; + } + } + + const isSupporter = Boolean(user.roles?.includes("SUPPORTER")); + + await UserCardRepository.updateOwnCard({ + shortBio: data.shortBio || null, + ...resolveBanner({ ...data, isSupporter }), + unverifiedPeakXP: data.unverifiedXpPoints + ? { + overall: data.unverifiedXpPoints, + tentatek: + data.unverifiedXpDivision === "WEST" + ? data.unverifiedXpPoints + : null, + takoroka: + data.unverifiedXpDivision === "JPN" + ? data.unverifiedXpPoints + : null, + } + : null, + hiddenCardStats: resolveHiddenStats(data), + }); + + throw redirect(returnTo ?? userPage(user)); +}; + +function safeReturnTo(value: string | null) { + if (!value) return null; + if (!value.startsWith("/") || value.startsWith("//")) return null; + + return value; +} + +function resolveBanner({ + bannerType, + bannerColor, + bannerStageId, + bannerImage, + isSupporter, +}: { + bannerType: "COLOR" | "STAGE" | "URL"; + bannerColor: string; + bannerStageId: number; + bannerImage: number | null; + isSupporter: boolean; +}): { bannerPresetImg: string | null; bannerImgId: number | null } { + switch (bannerType) { + case "STAGE": + return { bannerPresetImg: String(bannerStageId), bannerImgId: null }; + case "URL": + return { + bannerPresetImg: null, + bannerImgId: isSupporter ? bannerImage : null, + }; + default: + return { bannerPresetImg: bannerColor, bannerImgId: null }; + } +} + +function resolveHiddenStats(data: { + hideXp: boolean; + hideDiv: boolean; +}): Array { + return [ + data.hideXp ? ("XP" as const) : null, + data.hideDiv ? ("DIV" as const) : null, + ].filter((stat) => stat !== null); +} diff --git a/app/features/sendouq-match/components/AddPrivateNoteDialog.tsx b/app/features/user-card/components/AddPrivateNoteDialog.tsx similarity index 66% rename from app/features/sendouq-match/components/AddPrivateNoteDialog.tsx rename to app/features/user-card/components/AddPrivateNoteDialog.tsx index e2d5a9462..0eef1a194 100644 --- a/app/features/sendouq-match/components/AddPrivateNoteDialog.tsx +++ b/app/features/user-card/components/AddPrivateNoteDialog.tsx @@ -6,37 +6,57 @@ import { FormMessage } from "~/components/FormMessage"; import { Label } from "~/components/Label"; import { SubmitButton } from "~/components/SubmitButton"; import type { Tables } from "~/db/tables"; -import type { SQMatchGroup } from "~/features/sendouq/core/SendouQ.server"; import { SENDOUQ } from "~/features/sendouq/q-constants"; -import { preferenceEmojiUrl } from "~/utils/urls"; +import { preferenceEmojiUrl, userCardNotePage } from "~/utils/urls"; +type PrivateNote = Pick; + +/** + * Modal for adding/editing the viewer's private note about a user, posting to the + * `/user-card/:id/note` resource route. Closes once the fetcher settles (save succeeds → + * automatic revalidation refreshes the card). Clearing the text with a neutral sentiment and saving + * deletes the note (handled by the route). Rendered wherever a `UserCard` lives. + */ export function AddPrivateNoteDialog({ - aboutUser, - close, + userId, + username, + note, + onClose, }: { - aboutUser?: Pick< - SQMatchGroup["members"][number], - "id" | "username" | "privateNote" - >; - close: () => void; + userId: number; + username: string; + note: PrivateNote | null; + onClose: () => void; }) { const { t } = useTranslation(["q", "common"]); const fetcher = useFetcher(); - if (!aboutUser) return null; + const wasSubmittingRef = React.useRef(false); + React.useEffect(() => { + if (fetcher.state !== "idle") { + wasSubmittingRef.current = true; + } else if (wasSubmittingRef.current) { + wasSubmittingRef.current = false; + if ((fetcher.data as { ok?: boolean } | undefined)?.ok) { + onClose(); + } + } + }, [fetcher.state, fetcher.data, onClose]); return ( - - -