User card (#3213)
Some checks are pending
E2E Tests / e2e (push) Waiting to run
Tests and checks on push / run-checks-and-tests (push) Waiting to run
Updates translation progress / update-translation-progress-issue (push) Waiting to run

This commit is contained in:
Kalle 2026-07-05 08:28:42 +03:00 committed by GitHub
parent f25a2e410c
commit a4dfd82141
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
146 changed files with 4049 additions and 1078 deletions

View File

@ -122,6 +122,12 @@ export function Avatar({
const [isErrored, setIsErrored] = React.useState(false);
const isClient = useHydrated();
// an <img> 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 (
<div className={clsx(styles.avatarWrapper, className)}>
<img
ref={checkAlreadyErrored}
src={src}
alt={alt}
title={alt ? alt : undefined}

View File

@ -0,0 +1,66 @@
.wrapper {
position: relative;
display: inline-flex;
flex-shrink: 0;
width: fit-content;
}
.badge {
position: absolute;
bottom: 15%;
inset-inline-start: 15%;
transform: translate(-50%, 50%);
display: grid;
place-items: center;
border-radius: var(--radius-full);
border: 2px solid var(--color-bg);
color: var(--color-bg);
& > 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);
}

View File

@ -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<Sentiment, string> = {
POSITIVE: styles.positive,
NEUTRAL: styles.neutral,
NEGATIVE: styles.negative,
};
const BADGE_ICON: Record<Sentiment, React.ReactNode> = {
POSITIVE: <Check />,
NEUTRAL: <Minus />,
NEGATIVE: <X />,
};
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 (
<div className={clsx(styles.wrapper, className)}>
{children}
{sentiment ? (
<span
className={clsx(
styles.badge,
SIZE_CLASS[size],
BADGE_CLASS[sentiment],
)}
aria-hidden
>
{BADGE_ICON[sentiment]}
</span>
) : null}
</div>
);
}

View File

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

View File

@ -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<MainWeaponId>;
friendCode?: string | null;
privateNote?: { sentiment: "POSITIVE" | "NEUTRAL" | "NEGATIVE" } | null;
inGameName?: string | null;
};
@ -166,10 +157,7 @@ function TeamRoster({
<div className={styles.memberTier}>
<MemberTierPopover tier={member.tier} />
</div>
<MemberMeta
plusTier={member.plusTier}
weaponPool={member.weaponPool}
/>
<MemberMeta weaponPool={member.weaponPool} />
</div>
</li>
))}
@ -403,27 +391,14 @@ function MemberTierPopoverContent({
);
}
function MemberMeta({
plusTier,
weaponPool,
}: {
plusTier?: number | null;
weaponPool?: Array<MainWeaponId>;
}) {
const hasPlusTier = typeof plusTier === "number";
function MemberMeta({ weaponPool }: { weaponPool?: Array<MainWeaponId> }) {
const hasWeapons = weaponPool && weaponPool.length > 0;
if (!hasPlusTier && !hasWeapons) return null;
if (!hasWeapons) return null;
return (
<div className={styles.memberMeta}>
{hasPlusTier ? (
<div className={styles.plusTier}>
<Image path={navIconUrl("plus")} width={16} height={16} alt="" />
<span>{plusTier}</span>
</div>
) : null}
{hasWeapons ? <WeaponPool weapons={weaponPool} size={18} /> : null}
<WeaponPool weapons={weaponPool} size={18} />
</div>
);
}
@ -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 = <span>{member.inGameName ?? member.username}</span>;
if (!useMenu) {
return (
<Link to={userPage(member)} className={className}>
<Avatar user={member} size="xxs" />
{nameContent}
</Link>
);
}
const headerContent = member.friendCode ? (
<div className={styles.memberMenuHeader}>
<span>{`SW-${member.friendCode}`}</span>
</div>
) : undefined;
const cardData = useUserCardData(member.id);
return (
<SendouMenu
trigger={
<ReactAriaButton className={clsx(className, styles.memberMenuTrigger)}>
<UserCard userId={member.id}>
<span className={className}>
<NoteAvatar sentiment={cardData?.privateNote?.sentiment} size="xs">
<Avatar user={member} size="xxs" />
{nameContent}
</ReactAriaButton>
}
>
<SendouMenuSection
headerText={headerContent}
headerClassName={styles.friendCodeHeader}
>
<SendouMenuItem href={userPage(member)} icon={<User />}>
{t("friends:friendsList.viewUserPage")}
</SendouMenuItem>
{showNoteItem ? (
<SendouMenuItem
href={`?note=${member.id}`}
icon={
member.privateNote ? (
<img
src={preferenceEmojiUrl(
member.privateNote.sentiment === "POSITIVE"
? "PREFER"
: member.privateNote.sentiment === "NEGATIVE"
? "AVOID"
: undefined,
)}
alt=""
width={18}
height={18}
/>
) : (
<Edit />
)
}
>
{member.privateNote
? t("q:looking.groups.editNote")
: t("q:looking.groups.addNote")}
</SendouMenuItem>
) : null}
</SendouMenuSection>
</SendouMenu>
</NoteAvatar>
<div className={styles.memberNameStack}>
<span>{member.username}</span>
{member.inGameName ? (
<span className={styles.memberInGameName}>{member.inGameName}</span>
) : null}
</div>
</span>
</UserCard>
);
}

View File

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

View File

@ -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<number>;
splId: string;
userId: number | null;
/** Players best XP across both divisions. Denormalized for performance. */
peakXp: number | null;
peakXp: JSONColumnTypeNullable<PeakXP>;
}
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<number | null>;
bannedReason: string | null;
/** Shown on old user profile and Plus Voting */
bio: string | null;
/** Shown on user card */
shortBio: string | null;
commissionsOpen: Generated<number | null>;
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<string | StageId>;
/** 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<Array<HideableUserCardStat>>;
/** 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<PeakXP>;
}
/** 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;

View File

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

View File

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

View File

@ -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 }>()

View File

@ -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 (
<Section>
<SectionTitle id={id}>User Card</SectionTitle>
<div className="stack md">
<ComponentRow label="Click the trigger">
<UserCard data={USER_CARD_DATA}>Sendou</UserCard>
</ComponentRow>
</div>
</Section>
);
}
function FormMessageSection({ id }: { id: string }) {
return (
<Section>

View File

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

View File

@ -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 (
<TeamLFGPost post={{ ...post, team: post.team }} tiersMap={tiersMap} />
);
return <TeamLFGPost post={{ ...post, team: post.team }} />;
}
return <UserLFGPost post={post} tiersMap={tiersMap} />;
return <UserLFGPost post={post} />;
}
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 }) {
<PostTime createdAt={post.createdAt} updatedAt={post.updatedAt} />
<PostPills
languages={post.languages}
plusTier={post.author.plusTier}
timezone={post.timezone}
tiers={
post.type !== "COACH_FOR_TEAM"
? tiersMap.get(post.author.id)
: undefined
}
canEdit={post.author.id === user?.id}
postId={post.id}
/>
@ -88,10 +77,8 @@ function UserLFGPost({ post, tiersMap }: { post: Post; tiersMap: TiersMap }) {
function TeamLFGPost({
post,
tiersMap,
}: {
post: Post & { team: NonNullable<Post["team"]> };
tiersMap: TiersMap;
}) {
const isHydrated = useHydrated();
const user = useUser();
@ -120,13 +107,9 @@ function TeamLFGPost({
</div>
</div>
{isExpanded ? (
<PostTeamMembersFull
team={post.team}
tiersMap={tiersMap}
postId={post.id}
/>
<PostTeamMembersFull team={post.team} />
) : (
<PostTeamMembersPeek team={post.team} tiersMap={tiersMap} />
<PostTeamMembersPeek team={post.team} />
)}
</div>
<div>
@ -155,42 +138,21 @@ function PostTeamLogoHeader({ team }: { team: NonNullable<Post["team"]> }) {
);
}
function PostTeamMembersPeek({
team,
tiersMap,
}: {
team: NonNullable<Post["team"]>;
tiersMap: TiersMap;
}) {
function PostTeamMembersPeek({ team }: { team: NonNullable<Post["team"]> }) {
return (
<div className="stack sm xs-row horizontal flex-wrap">
{team.members.map((member) => (
<PostTeamMember key={member.id} member={member} tiersMap={tiersMap} />
<PostTeamMember key={member.id} member={member} />
))}
</div>
);
}
function PostTeamMembersFull({
team,
tiersMap,
postId,
}: {
team: NonNullable<Post["team"]>;
tiersMap: TiersMap;
postId: number;
}) {
function PostTeamMembersFull({ team }: { team: NonNullable<Post["team"]> }) {
return (
<div className="stack lg">
{team.members.map((member) => (
<div key={member.id} className="stack sm">
<PostUserHeader author={member} includeWeapons />
<PostPills
plusTier={member.plusTier}
tiers={tiersMap.get(member.id)}
postId={postId}
/>
</div>
<PostUserHeader key={member.id} author={member} includeWeapons />
))}
</div>
);
@ -198,23 +160,21 @@ function PostTeamMembersFull({
function PostTeamMember({
member,
tiersMap,
}: {
member: NonNullable<Post["team"]>["members"][number];
tiersMap: TiersMap;
}) {
const tiers = tiersMap.get(member.id);
const tier = tiers?.latest ?? tiers?.previous;
const cardData = useUserCardData(member.id);
return (
<div className="stack sm items-center flex-same-size">
<div className="stack sm items-center">
<Avatar size="xs" user={member} />
<Link to={userPage(member)} className={styles.teamMemberName}>
{member.username}
</Link>
{tier ? <TierImage tier={tier} width={32} /> : null}
</div>
<UserCard userId={member.id} withMutualFriends>
<span className="stack sm items-center">
<NoteAvatar sentiment={cardData?.privateNote?.sentiment} size="sm">
<Avatar size="xs" user={member} />
</NoteAvatar>
<span className={styles.teamMemberName}>{member.username}</span>
</span>
</UserCard>
</div>
);
}
@ -226,17 +186,24 @@ function PostUserHeader({
author: Post["author"];
includeWeapons: boolean;
}) {
const cardData = useUserCardData(author.id);
return (
<div className="stack sm">
<div className="stack sm horizontal items-center">
<Avatar size="xsm" user={author} />
<div>
<div className="stack horizontal sm items-center text-md font-bold">
<Link to={userPage(author)} className={styles.userName}>
{author.username}
</Link>{" "}
{author.country ? <Flag countryCode={author.country} tiny /> : null}
</div>
<div className="stack horizontal sm items-center text-md font-bold">
<UserCard userId={author.id} withMutualFriends>
<span className="stack sm horizontal items-center">
<NoteAvatar
sentiment={cardData?.privateNote?.sentiment}
size="md"
>
<Avatar size="xsm" user={author} />
</NoteAvatar>
<span className={styles.userName}>{author.username}</span>
</span>
</UserCard>{" "}
{author.country ? <Flag countryCode={author.country} tiny /> : null}
</div>
</div>
{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<ReturnType<TiersMap["get"]>>;
canEdit?: boolean;
postId: number;
}) {
@ -316,10 +279,6 @@ function PostPills({
<PostTimezonePill timezone={timezone} />
)}
{!isHydrated && <PostTimezonePillPlaceholder />}
{typeof plusTier === "number" && (
<PostPlusServerPill plusTier={plusTier} />
)}
{tiers && <PostSkillPills tiers={tiers} />}
{typeof languages === "string" && (
<PostLanguagePill languages={languages} />
)}
@ -332,66 +291,6 @@ function PostTimezonePillPlaceholder() {
return <div className={clsx(styles.pill, styles.pillPlaceholder)} />;
}
const currentSeasonNth = Seasons.currentOrPrevious()!.nth;
function PostSkillPills({
tiers,
}: {
tiers: NonNullable<ReturnType<TiersMap["get"]>>;
}) {
const hasBoth = tiers.latest && tiers.previous;
return (
<div className="stack xxxs horizontal">
{tiers.latest ? (
<PostSkillPill
seasonNth={currentSeasonNth}
tier={tiers.latest}
cut={hasBoth ? "END" : undefined}
/>
) : null}
{tiers.previous ? (
<PostSkillPill
seasonNth={currentSeasonNth - 1}
tier={tiers.previous}
cut={hasBoth ? "START" : undefined}
/>
) : null}
</div>
);
}
function PostSkillPill({
seasonNth,
tier,
cut,
}: {
seasonNth: number;
tier: TieredSkill["tier"];
cut?: "START" | "END";
}) {
return (
<div
className={clsx(styles.pill, styles.tierPill, {
[styles.tierPillStart]: cut === "START",
[styles.tierPillEnd]: cut === "END",
})}
>
S{seasonNth}
<TierImage tier={tier} width={32} className={styles.tier} />
</div>
);
}
function PostPlusServerPill({ plusTier }: { plusTier: number }) {
return (
<div className={styles.pill}>
<Image alt="" path={navIconUrl("plus")} size={18} />
{plusTier}
</div>
);
}
function PostTimezonePill({ timezone }: { timezone: string }) {
const userTimezone = Intl.DateTimeFormat().resolvedOptions().timeZone;
const diff = hourDifferenceBetweenTimezones(userTimezone, timezone);

View File

@ -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,
})),
};
};

View File

@ -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) ? <PostExpiryAlert postId={post.id} /> : null}
<LFGPost post={post} tiersMap={tiersMap} />
<LFGPost post={post} />
</div>
))}
{filteredPosts.length === 0 ? (

View File

@ -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<number>().as("peakXp"),
"LiveStream.viewerCount",
"LiveStream.thumbnailUrl",
"LiveStream.twitch as twitchUsername",

View File

@ -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,
},

View File

@ -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 <Avatar size="xs" url={teamAvatarUrl} alt={teamName} />;
}
return <Avatar size="xs" user={owner} alt={owner.username} />;
return (
<UserCard userId={owner.id} withMutualFriends>
<NoteAvatar sentiment={cardData?.privateNote?.sentiment} size="sm">
<Avatar size="xs" user={owner} alt={owner.username} />
</NoteAvatar>
</UserCard>
);
}
function ScrimVisibilityPopover() {
@ -178,20 +191,28 @@ function ScrimTeamMembersPopover({ users }: { users: ScrimPost["users"] }) {
>
<div className="stack md">
{users.map((user) => (
<Link
to={userPage(user)}
key={user.id}
className="stack horizontal sm"
>
<Avatar size="xxs" user={user} />
{user.username}
</Link>
<ScrimTeamMemberRow key={user.id} user={user} />
))}
</div>
</SendouPopover>
);
}
function ScrimTeamMemberRow({ user }: { user: ScrimPost["users"][number] }) {
const cardData = useUserCardData(user.id);
return (
<UserCard userId={user.id} withMutualFriends>
<span className="stack horizontal sm items-center">
<NoteAvatar sentiment={cardData?.privateNote?.sentiment} size="xs">
<Avatar size="xxs" user={user} />
</NoteAvatar>
{user.username}
</span>
</UserCard>
);
}
function ScrimTournamentPopover({
tournament,
}: {

View File

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

View File

@ -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(),

View File

@ -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(),

View File

@ -38,7 +38,7 @@ import styles from "./scrims.module.css";
export type NewRequestFormFields = z.infer<typeof newRequestSchema>;
export const handle: SendouRouteHandle = {
i18n: ["calendar", "scrims"],
i18n: ["calendar", "scrims", "user", "q"],
breadcrumb: () => ({
imgPath: navIconUrl("scrims"),
href: scrimsPage(),

View File

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

View File

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

View File

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

View File

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

View File

@ -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 (
<>
<AddPrivateNoteDialog
aboutUser={addingNoteFor}
close={() => navigate(sendouQMatchPage(data.match.id))}
<MatchTabs tabs={tabs} alertTabs={alertTabs}>
{isLocked || hasReportedMaps ? (
<MatchResultTab
teams={resolveTimelineTeams(data.match, t)}
score={{ alpha: alphaWins, bravo: bravoWins }}
maps={resolveTimelineMaps(data.match, data.reportedWeapons)}
spChanges={resolveTimelineSpChanges(data.match)}
isOngoing={!isLocked && hasReportedMaps}
>
{data.match.cancelRequestedByUserId ? (
<p className="text-lighter text-xxs text-center mt-4">
{t("q:match.canceled.detail", {
requester: resolveCancelRequesterUsername(data.match),
accepter: resolveCancelAccepterUsername(data.match),
})}
</p>
) : null}
</MatchResultTab>
) : null}
<MatchRosterTab
minMembersPerTeam={4}
canEditSubbedOut={[false, false]}
teams={[
{
team: mapRosterTeam(data.match.groupAlpha.team),
defaultName: t("q:match.groupAlpha"),
members: mapRosterMembers(data.match.groupAlpha.members),
tier: data.match.groupAlpha.tier ?? undefined,
},
{
team: mapRosterTeam(data.match.groupBravo.team),
defaultName: t("q:match.groupBravo"),
members: mapRosterMembers(data.match.groupBravo.members),
tier: data.match.groupBravo.tier ?? undefined,
},
]}
/>
<MatchTabs tabs={tabs} alertTabs={alertTabs}>
{isLocked || hasReportedMaps ? (
<MatchResultTab
teams={resolveTimelineTeams(data.match, t)}
score={{ alpha: alphaWins, bravo: bravoWins }}
maps={resolveTimelineMaps(data.match, data.reportedWeapons)}
spChanges={resolveTimelineSpChanges(data.match)}
isOngoing={!isLocked && hasReportedMaps}
>
{data.match.cancelRequestedByUserId ? (
<p className="text-lighter text-xxs text-center mt-4">
{t("q:match.canceled.detail", {
requester: resolveCancelRequesterUsername(data.match),
accepter: resolveCancelAccepterUsername(data.match),
})}
</p>
) : null}
</MatchResultTab>
) : null}
<MatchRosterTab
minMembersPerTeam={4}
canEditSubbedOut={[false, false]}
teams={[
{
team: mapRosterTeam(data.match.groupAlpha.team),
defaultName: t("q:match.groupAlpha"),
members: mapRosterMembers(data.match.groupAlpha.members, {
viewerId: user?.id,
isOwnTeam: userSide === "ALPHA",
}),
tier: data.match.groupAlpha.tier ?? undefined,
},
{
team: mapRosterTeam(data.match.groupBravo.team),
defaultName: t("q:match.groupBravo"),
members: mapRosterMembers(data.match.groupBravo.members, {
viewerId: user?.id,
isOwnTeam: userSide === "BRAVO",
}),
tier: data.match.groupBravo.tier ?? undefined,
},
]}
{showActionTab ? (
<SendouQMatchActionTab
data={data}
currentMap={currentMap ?? undefined}
ownTeamId={ownTeamId}
reportedCount={
data.match.mapList.filter((m) => m.winnerGroupId !== null).length
}
viewerSide={userSide}
/>
{showActionTab ? (
<SendouQMatchActionTab
data={data}
currentMap={currentMap ?? undefined}
ownTeamId={ownTeamId}
reportedCount={
data.match.mapList.filter((m) => m.winnerGroupId !== null).length
}
viewerSide={userSide}
/>
) : null}
</MatchTabs>
</>
) : null}
</MatchTabs>
);
}
@ -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,
}));
}

View File

@ -383,7 +383,6 @@ export function createMatchMemento(
return [
member.id,
{
plusTier: member.plusTier ?? undefined,
skill:
!skill || skill.approximate ? ("CALCULATING" as const) : skill,
},

View File

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

View File

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

View File

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

View File

@ -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"),
}),
])

View File

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

View File

@ -29,11 +29,9 @@ function createMember(overrides: Partial<SQGroupMember> = {}): 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,

View File

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

View File

@ -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 (
<div className="stack xxs" data-testid="sendouq-group-card-member">
<div className={styles.member}>
<div className="text-main-forced stack xs horizontal items-center">
{showNote && member.privateNote ? (
<SendouPopover
trigger={
<SendouButton variant="minimal">
<Avatar
user={member}
size="xs"
className={clsx(
styles.avatar,
SENTIMENT_STYLES[member.privateNote.sentiment],
)}
/>
</SendouButton>
}
>
{member.privateNote.text}
<div
className={clsx(
"stack sm horizontal justify-between items-center",
{ "mt-2": member.privateNote.text },
)}
<UserCard userId={member.id} withMutualFriends>
<span className="stack xs horizontal items-center">
<NoteAvatar
sentiment={cardData?.privateNote?.sentiment}
size="sm"
>
<LocaleTime
date={member.privateNote.updatedAt}
options={{
hour: "numeric",
minute: "numeric",
day: "numeric",
month: "numeric",
year: "numeric",
}}
className="text-xxs text-lighter"
/>
<DeletePrivateNoteForm
name={member.username}
targetId={member.id}
/>
</div>
</SendouPopover>
) : (
<Avatar user={member} size="xs" />
)}
<Link to={userPage(member)} className={styles.name}>
{member.inGameName ? (
<>
<span className="text-lighter font-bold text-xxxs">
{t("user:ign.short")}:
</span>{" "}
{inGameNameWithoutDiscriminator(member.inGameName)}
</>
) : (
member.username
)}
</Link>
<Avatar user={member} size="xs" />
</NoteAvatar>
<span className={styles.name}>
{member.inGameName ? (
<>
<span className="text-lighter font-bold text-xxxs">
{t("user:ign.short")}:
</span>{" "}
{inGameNameWithoutDiscriminator(member.inGameName)}
</>
) : (
member.username
)}
</span>
</span>
</UserCard>
{member.pronouns ? (
<span className="text-lighter ml-1 text-xxxs">
{member.pronouns.subject}/{member.pronouns.object}
@ -361,12 +314,6 @@ function GroupMember({
<VoiceChatInfo member={member} />
</div>
) : null}
{member.plusTier ? (
<div className={styles.extraInfo}>
<Image path={navIconUrl("plus")} width={20} height={20} alt="" />
{member.plusTier}
</div>
) : null}
{member.friendCode ? (
<SendouPopover
trigger={
@ -378,19 +325,6 @@ function GroupMember({
SW-{member.friendCode}
</SendouPopover>
) : null}
{showAddNote ? (
<LinkButton
to={`?note=${member.id}`}
icon={<PenSquare />}
className={clsx(styles.addNoteButton, {
[styles.addNoteButtonEdit]: member.privateNote,
})}
>
{member.privateNote
? t("q:looking.groups.editNote")
: t("q:looking.groups.addNote")}
</LinkButton>
) : null}
</div>
{member.weapons && member.weapons.length > 0 && !hideWeapons ? (
<div className={styles.extraInfo}>
@ -524,30 +458,6 @@ function AddPrivateNoteForm({
);
}
function DeletePrivateNoteForm({
targetId,
name,
}: {
targetId: number;
name: string;
}) {
const { t } = useTranslation(["q"]);
return (
<FormWithConfirm
dialogHeading={t("q:privateNote.delete.header", { name })}
fields={[
["targetId", targetId],
["_action", "DELETE_PRIVATE_USER_NOTE"],
]}
>
<SubmitButton variant="minimal-destructive" size="small" type="submit">
<Trash className="small-icon" />
</SubmitButton>
</FormWithConfirm>
);
}
function GroupSkillDifference({
skillDifference,
}: {

View File

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

View File

@ -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<typeof SQGroupRepository.findCurrentGroups>
>[number];
type DBPrivateNoteRow = Awaited<
ReturnType<typeof PrivateUserNoteRepository.ownNotes>
>[number];
type DBRecentlyFinishedMatchRow = Awaited<
ReturnType<typeof SQGroupRepository.findRecentlyFinishedMatches>
>[number];
@ -42,7 +38,6 @@ export type SQOwnGroup = SerializeFrom<
NonNullable<ReturnType<SendouQClass["findOwnGroup"]>>
>;
export type SQMatch = SerializeFrom<ReturnType<SendouQClass["mapMatch"]>>;
export type SQMatchGroup = SQMatch["groupAlpha"] | SQMatch["groupBravo"];
export type SQGroupMember = NonNullable<SQGroup["members"]>[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 <T extends { members: { id: number }[] }>(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];

View File

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

View File

@ -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({

View File

@ -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 = (
<div className="stack sm">
<ColumnHeader isMobile={isMobile}>
@ -248,7 +251,7 @@ function Groups() {
: "q:looking.columns.invited",
)}
</ColumnHeader>
{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() {
<ColumnHeader isMobile={isMobile}>
{t("q:looking.columns.myGroup")}
</ColumnHeader>
<GroupCard group={data.ownGroup} showNote ownGroup={data.ownGroup} />
<GroupCard group={data.ownGroup} ownGroup={data.ownGroup} />
{data.ownGroup.inviteCode ? (
<MemberAdder
inviteCode={data.ownGroup.inviteCode}
@ -286,12 +288,12 @@ function Groups() {
</div>
) : 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")}
</ColumnHeader>
{(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<number, UserCardData>): 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,

View File

@ -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<typeof loader>();
const location = useLocation();
const matchProfile = data.matchProfile;
if (!matchProfile) return null;
return (
<SendouForm
schema={updateMatchProfileSchema}
defaultValues={{
mapModePreferences: preferencesFromRaw(matchProfile.mapModePreferences),
weaponPool: (matchProfile.weaponPool ?? []).map((w) => ({
id: w.weaponSplId,
isFavorite: Boolean(w.isFavorite),
})),
vc: matchProfile.vc ?? "NO",
languages: matchProfile.languages ?? [],
noScreen: Boolean(matchProfile.noScreen),
}}
revalidateRoot
>
{({ FormField }) => (
<>
<FormField name="mapModePreferences">
{(props: {
value: unknown;
onChange: (value: UserMapModePreferences) => void;
}) => (
<MapModePreferencesField
value={props.value as UserMapModePreferences}
onChange={props.onChange}
/>
)}
</FormField>
<FormField name="weaponPool" />
<FormField name="vc" />
<FormField name="languages" />
<FormField name="noScreen" />
</>
)}
</SendouForm>
<div className="stack md">
<LinkButton
to={userCardEditPage({
returnTo: `${location.pathname}${location.search}`,
})}
size="small"
variant="outlined"
icon={<Pencil />}
className="self-start"
>
{t("user:card.edit.title")}
</LinkButton>
<SendouForm
schema={updateMatchProfileSchema}
defaultValues={{
mapModePreferences: preferencesFromRaw(
matchProfile.mapModePreferences,
),
weaponPool: (matchProfile.weaponPool ?? []).map((w) => ({
id: w.weaponSplId,
isFavorite: Boolean(w.isFavorite),
})),
vc: matchProfile.vc ?? "NO",
languages: matchProfile.languages ?? [],
noScreen: Boolean(matchProfile.noScreen),
}}
revalidateRoot
>
{({ FormField }) => (
<>
<FormField name="mapModePreferences">
{(props: {
value: unknown;
onChange: (value: UserMapModePreferences) => void;
}) => (
<MapModePreferencesField
value={props.value as UserMapModePreferences}
onChange={props.onChange}
/>
)}
</FormField>
<FormField name="weaponPool" />
<FormField name="vc" />
<FormField name="languages" />
<FormField name="noScreen" />
</>
)}
</SendouForm>
</div>
);
}

View File

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

View File

@ -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 () => {

View File

@ -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<T extends number | null = number | null>() {
return sql<T>`"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<boolean> {
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<string | null>`(
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();
}

View File

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

View File

@ -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 (
<SendouPopover
trigger={
<SendouButton variant="minimal" size="small" className="self-start">
{t("common:xsearch.link.trigger")}
</SendouButton>
}
>
<div className={styles.content}>
<div>
<div className="font-bold">
{t("common:xsearch.link.about.title")}
</div>
<ul className={styles.list}>
<li>{t("common:xsearch.link.about.endPower")}</li>
<li>{t("common:xsearch.link.about.notPeak")}</li>
<li>{t("common:xsearch.link.about.wait")}</li>
</ul>
</div>
<div className="stack sm">
<div>
<Trans t={t} i18nKey="common:xsearch.link.example">
Post this message on our Discord (
<a href={SENDOU_INK_DISCORD_URL} target="_blank" rel="noreferrer">
discord.gg/sendou
</a>
), on the #helpdesk channel:
</Trans>
</div>
<code className={styles.example}>
{t("common:xsearch.link.exampleRequest")} {EXAMPLE_PLAYER_URL}
</code>
</div>
<div className="text-lighter">
{t("common:xsearch.link.noScreenshots")}
</div>
</div>
</SendouPopover>
);
}

View File

@ -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 (
<Main halfWidth className="stack lg">
<div>
<h2 className="text-lg">
{hasUserLinked(placementUser) ? (
<Link to={userPage(placementUser)}>{data.names.primary}</Link>
) : (
data.names.primary
)}{" "}
{t("common:xsearch.placements")}
</h2>
{data.names.aliases.length > 0 ? (
<div className="text-lighter text-sm">
{t("common:xsearch.aliases")} {data.names.aliases.join(", ")}
</div>
) : null}
<div>
<h2 className="text-lg">
{hasUserLinked(placementUser) ? (
<Link to={userPage(placementUser)}>{data.names.primary}</Link>
) : (
data.names.primary
)}{" "}
{t("common:xsearch.placements")}
</h2>
{data.names.aliases.length > 0 ? (
<div className="text-lighter text-sm">
{t("common:xsearch.aliases")} {data.names.aliases.join(", ")}
</div>
) : null}
</div>
{!hasUserLinked(placementUser) ? <HowToLinkPopover /> : null}
</div>
<PlacementsTable placements={data.placements} type="MODE_INFO" />
{isLinkedToCurrentUser ? <UnlinkFormWithButton /> : null}

View File

@ -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,
})),
};
};

View File

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

View File

@ -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,
})}
>
<Link to={userResultsPage(member, true)}>
{member.username}
</Link>
{member.plusTier ? (
<span className={styles.plusTier}>
<Image
path={navIconUrl("plus")}
width={14}
height={14}
alt=""
/>
{member.plusTier}
</span>
) : null}
<UserCard userId={member.userId}>
<span>{member.username}</span>
</UserCard>
{isNew ? (
<span className={styles.playerNewBadge}>NEW</span>
) : null}

View File

@ -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 (
<div className="stack xxs">
<div className={styles.member}>
<div className="text-main-forced stack xs horizontal items-center">
<Avatar user={member} size="xs" />
<Link to={userPage(member)} className={styles.name}>
{member.username}
</Link>
<UserCard userId={member.id} withMutualFriends>
<span className="stack xs horizontal items-center">
<NoteAvatar
sentiment={cardData?.privateNote?.sentiment}
size="sm"
>
<Avatar user={member} size="xs" />
</NoteAvatar>
<span className={styles.name}>{member.username}</span>
</span>
</UserCard>
{member.pronouns ? (
<span className="text-lighter ml-1 text-xxxs">
{member.pronouns.subject}/{member.pronouns.object}

View File

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

View File

@ -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 = [
<div key="vc" className={styles.subsInfoVc}>
@ -322,10 +327,18 @@ function SubCard({ sub }: { sub: SubEntry }) {
return (
<div>
<section className={styles.subsSection}>
<Avatar user={sub} size="sm" className={styles.subsSectionAvatar} />
<Link to={userPage(sub)} className={styles.subsSectionName}>
{sub.username}
</Link>
<div className={styles.subsSectionAvatar}>
<UserCard userId={sub.userId} withMutualFriends>
<NoteAvatar sentiment={cardData?.privateNote?.sentiment} size="sm">
<Avatar user={sub} size="sm" />
</NoteAvatar>
</UserCard>
</div>
<div className={styles.subsSectionName}>
<UserCard userId={sub.userId} withMutualFriends>
<span>{sub.username}</span>
</UserCard>
</div>
<div className={styles.subsSectionInfo}>{infos}</div>
{sub.weapons ? (
<div className={styles.subsSectionWeapons}>

View File

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

View File

@ -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() {

View File

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

View File

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

View File

@ -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<number>;
/** 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<number, UserCardData> }> {
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<number, UserCardData>();
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<HideableUserCardStat>;
}) {
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<Tables, "User">,
{
viewerId,
include,
}: {
viewerId: number | null;
include?: { friendCode?: boolean };
},
) {
return jsonBuildObject({
...commonUserObjectFields(eb),
shortBio: eb.ref("User.shortBio"),
div: eb.ref("User.div"),
customTheme: sql<CustomTheme | null>`IIF(COALESCE("User"."patronTier", 0) >= 2, "User"."customTheme", null)`,
hiddenCardStats: eb.ref("User.hiddenCardStats"),
banner: bannerJson(eb),
friendCode: include?.friendCode
? friendCodeScalar(eb)
: sql<string | null>`null`,
privateNote: privateNoteJson(eb, viewerId),
freeAgentPostId: freeAgentPostIdScalar(eb),
plusTier: plusTierScalar(eb),
xpVerified: xpVerifiedJson(eb),
xpUnverified: xpUnverifiedJson(),
});
}
type RawUserCardData =
ReturnType<typeof userCardDataJsonObject> extends Expression<infer T>
? 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<Tables, "User">) {
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<string | null>`
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<Tables, "User">) {
return concatUserSubmittedImagePrefix(
eb
.selectFrom("UserSubmittedImage")
.select("UserSubmittedImage.url")
.whereRef("UserSubmittedImage.id", "=", "User.bannerImgId")
.$asScalar(),
).$castTo<string | null>();
}
function friendCodeScalar(eb: ExpressionBuilder<Tables, "User">) {
return eb
.selectFrom("UserFriendCode")
.select("UserFriendCode.friendCode")
.whereRef("UserFriendCode.userId", "=", "User.id")
.orderBy("UserFriendCode.createdAt", "desc")
.limit(1)
.$asScalar();
}
function privateNoteJson(
eb: ExpressionBuilder<Tables, "User">,
viewerId: number | null,
) {
if (viewerId === null) {
return sql<Pick<
Tables["PrivateUserNote"],
"text" | "sentiment" | "updatedAt"
> | 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<Tables, "User">) {
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<Tables, "User">) {
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<Tables, "User">) {
return jsonObjectFrom(
eb
.selectFrom("XRankPlacement")
.innerJoin(
"SplatoonPlayer",
"SplatoonPlayer.id",
"XRankPlacement.playerId",
)
.whereRef("SplatoonPlayer.userId", "=", "User.id")
.select([
sql<number>`"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<string, TieredSkill>;
placementsByUserId: Map<number, number>;
};
/**
* 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 seasonplacements 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<number>,
): Promise<SeasonResult> {
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<number, number>();
return { skills, placementsByUserId };
}
const placementsBySeason = new Map<number, Map<number, number>>();
/**
* 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<Map<number, number>> {
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 thresholdsthe same ordinal can
* map to different tiers across seasonsso 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), andonly when that includes the very top Leviathan+ ranktheir best (lowest) leaderboard
* placement among the Leviathan+ seasons.
*/
function bestSeasonResult(
userId: number,
seasonResults: Array<SeasonResult>,
): { 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<UserCardStat["type"]> =
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<UserCardStat> {
const stats: Array<UserCardStat> = [];
const xpValues: Array<UserCardStatXPValue> = [];
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;
}

View File

@ -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<HideableUserCardStat> {
return [
data.hideXp ? ("XP" as const) : null,
data.hideDiv ? ("DIV" as const) : null,
].filter((stat) => stat !== null);
}

View File

@ -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<Tables["PrivateUserNote"], "text" | "sentiment">;
/**
* 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 (
<SendouDialog
isOpen
heading={t("q:privateNote.header", { name: aboutUser.username })}
onClose={close}
heading={t("q:privateNote.header", { name: username })}
onClose={onClose}
>
<fetcher.Form method="post" className="stack md">
<input type="hidden" name="targetId" value={aboutUser.id} />
<Textarea initialValue={aboutUser.privateNote?.text} />
<Sentiment initialValue={aboutUser.privateNote?.sentiment} />
<fetcher.Form
method="post"
action={userCardNotePage(userId)}
className="stack md"
>
<Textarea initialValue={note?.text} />
<Sentiment initialValue={note?.sentiment} />
<div className="stack items-center mt-2">
<SubmitButton _action="ADD_PRIVATE_USER_NOTE">
<SubmitButton _action="SAVE" state={fetcher.state}>
{t("common:actions.save")}
</SubmitButton>
</div>
@ -105,9 +125,9 @@ function Textarea({ initialValue }: { initialValue?: string | null }) {
const [value, setValue] = React.useState(initialValue ?? "");
return (
<div className="u-edit__bio-container">
<div className="stack">
<Label
htmlFor="text"
htmlFor="comment"
valueLimits={{
current: value.length,
max: SENDOUQ.PRIVATE_USER_NOTE_MAX_LENGTH,
@ -116,7 +136,7 @@ function Textarea({ initialValue }: { initialValue?: string | null }) {
{t("q:privateNote.comment.header")}
</Label>
<textarea
id="text"
id="comment"
name="comment"
value={value}
onChange={(e) => setValue(e.target.value)}

View File

@ -0,0 +1,247 @@
.trigger {
display: inline-flex;
width: fit-content;
padding: 0;
margin: 0;
border: none;
background: none;
color: inherit;
font: inherit;
text-align: inherit;
cursor: pointer;
}
.popover {
border: none;
background: none;
}
.dialog {
outline: none;
}
.card {
position: relative;
display: flex;
flex-direction: column;
gap: var(--s-5);
width: 18rem;
max-width: calc(100vw - var(--s-4));
padding: 0 var(--s-4) var(--s-4);
background-color: var(--color-bg);
border: 1px solid var(--color-border);
border-radius: var(--radius-box);
overflow: hidden;
}
.banner {
height: 6rem;
margin-inline: calc(-1 * var(--s-4));
background-size: cover;
background-position: center;
background-color: var(--color-bg-higher);
}
.iconButtons {
position: absolute;
top: var(--s-2);
inset-inline-end: var(--s-2);
display: flex;
gap: var(--s-2);
}
.freeAgentBadge {
position: absolute;
top: var(--s-2);
inset-inline-start: var(--s-2);
}
.identity {
display: flex;
align-items: flex-end;
gap: var(--s-2-5);
margin-top: calc(-1 * (var(--s-8)));
position: relative;
}
.avatar {
border: 3px solid var(--color-bg);
margin-block-end: -3px;
}
.nameGroup {
display: flex;
flex-direction: column;
padding-bottom: var(--s-6);
position: absolute;
top: 18px;
inset-inline-start: 92px;
}
.username {
font-size: var(--font-lg);
font-weight: var(--weight-bold);
line-height: 1.1;
max-width: 170px;
text-overflow: ellipsis;
white-space: nowrap;
overflow: hidden;
}
.subtitle {
max-width: 170px;
font-size: var(--font-xs);
color: var(--color-text-high);
text-overflow: ellipsis;
white-space: nowrap;
overflow: hidden;
}
.friendCode {
font-size: var(--font-xs);
color: var(--color-text-high);
}
.stats {
display: flex;
align-items: center;
justify-content: center;
gap: var(--s-2-5);
font-weight: var(--weight-bold);
}
.stat {
white-space: nowrap;
}
.plusStat {
display: inline-flex;
align-items: center;
gap: var(--s-1);
}
.xpStat {
display: inline-flex;
flex-direction: column;
align-items: flex-end;
line-height: 1.1;
}
.xpPrimary {
display: inline-flex;
align-items: center;
gap: var(--s-1);
}
.xpVerified {
display: inline-flex;
align-items: center;
gap: var(--s-1);
font-size: var(--font-2xs);
color: var(--color-text-high);
}
.xpVerifiedIconSmall {
width: var(--s-4);
height: var(--s-4);
color: var(--color-success);
}
.xpVerifiedIconLarge {
width: var(--s-5);
height: var(--s-5);
color: var(--color-success);
}
.seasonStat {
position: relative;
display: inline-flex;
}
.seasonTop {
position: absolute;
bottom: calc(-1 * var(--s-1));
left: 50%;
transform: translateX(-50%);
display: inline-flex;
align-items: center;
padding: 0 var(--s-1);
border: 1px solid var(--color-border);
border-radius: var(--radius-full);
background-color: var(--color-bg);
font-size: var(--font-2xs);
font-weight: var(--weight-bold);
line-height: 1;
}
.statDivider {
width: 2px;
align-self: stretch;
background-color: var(--color-border);
}
.mutualFriends {
display: flex;
align-items: center;
min-height: var(--field-size-sm);
}
.noMutualFriends {
margin-inline: auto;
font-style: italic;
font-size: var(--font-xs);
color: var(--color-text-high);
}
.bio {
font-size: var(--font-xs);
color: var(--color-text);
display: -webkit-box;
-webkit-line-clamp: 2;
line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
}
.viewUserPage {
margin-top: var(--s-1);
align-self: center;
}
.noteView {
display: flex;
flex-direction: column;
gap: var(--s-2);
}
.noteHeaderGroup {
display: flex;
flex-direction: column;
gap: var(--s-0-5);
}
.noteHeader {
font-size: var(--font-xs);
font-weight: var(--weight-bold);
color: var(--color-text);
}
.noteDate {
font-size: var(--font-2xs);
color: var(--color-text-high);
}
.noteText {
font-size: var(--font-xs);
color: var(--color-text);
white-space: pre-wrap;
overflow-wrap: anywhere;
}
.noteViewActions {
margin-block-start: var(--s-2);
display: flex;
align-items: center;
justify-content: center;
gap: var(--s-4);
}

View File

@ -0,0 +1,572 @@
import clsx from "clsx";
import {
BadgeCheck,
Megaphone,
NotebookPen,
NotebookText,
Pencil,
Trash2,
UserPlus,
UserRoundCheck,
} from "lucide-react";
import * as React from "react";
import { Button, Dialog, DialogTrigger, Popover } from "react-aria-components";
import { useTranslation } from "react-i18next";
import { useFetcher, useLocation, useMatches } from "react-router";
import * as R from "remeda";
import { Avatar } from "~/components/Avatar";
import { LinkButton, SendouButton } from "~/components/elements/Button";
import { toastQueue } from "~/components/elements/Toast";
import { FormWithConfirm } from "~/components/FormWithConfirm";
import { Image, TierImage } from "~/components/Image";
import { LocaleTime } from "~/components/LocaleTime";
import { NoteAvatar } from "~/components/NoteAvatar";
import { Placement } from "~/components/Placement";
import type { XRankPlacementRegion } from "~/db/tables";
import { useUser } from "~/features/auth/core/user";
import { MutualFriends } from "~/features/user-page/components/MutualFriends";
import type { BrandId } from "~/modules/in-game-lists/types";
import { assertUnreachable } from "~/utils/types";
import {
brandImageUrl,
FRIENDS_PAGE,
LFG_PAGE,
navIconUrl,
stageBannerImageUrl,
userCardEditPage,
userCardFriendshipPage,
userCardNotePage,
userPage,
} from "~/utils/urls";
import type { UserCardFriendshipLoaderData } from "../routes/user-card.$id.friendship";
import type {
UserCardData,
UserCardFriendship,
UserCardStat,
} from "../user-card-types";
import { AddPrivateNoteDialog } from "./AddPrivateNoteDialog";
import styles from "./UserCard.module.css";
const TENTATEK_BRAND_ID: BrandId = "B10";
const STAT_ORDER: Record<UserCardStat["type"], number> = {
XP: 0,
SEASON: 1,
PLUS: 2,
DIV: 3,
};
/**
* Click-to-open trigger that shows a popover with the user's card. Card data is resolved from the
* route tree by `userId` (a parent loader spreads `{ userCards }` from `UserCardRepository.userCards`);
* pass `data` directly to bypass the lookup (e.g. the components showcase). When no card data exists
* for the user, the `children` are rendered plain without a trigger.
*
* Viewer-relative friendship data (`isFriend`) is lazy-loaded from the `/user-card/:id/friendship`
* route the first time the card opens. Mutual friends are only fetched and shown when
* `withMutualFriends` is set (e.g. the SendouQ looking page); other views (e.g. match pages) skip
* both the extra query and the row.
*/
export function UserCard({
userId,
data: dataProp,
withMutualFriends = false,
children,
}: {
userId?: number;
data?: UserCardData;
/** Fetch and show the mutual friends row. Off by default. */
withMutualFriends?: boolean;
children: React.ReactNode;
}) {
const { t } = useTranslation(["common", "q"]);
const lookedUpData = useUserCardData(userId);
const data = dataProp ?? lookedUpData;
const user = useUser();
const isOwnCard = user?.id === data?.id;
const [isOpen, setIsOpen] = React.useState(false);
// kept at this level (outside the popover) so the modals survive the popover closing when they
// take focus; the note view inside the card opens them
const [isNoteDialogOpen, setIsNoteDialogOpen] = React.useState(false);
const [isDeleteConfirmOpen, setIsDeleteConfirmOpen] = React.useState(false);
const fetcher = useFetcher<UserCardFriendshipLoaderData>();
const friendshipLoadedRef = React.useRef(false);
React.useEffect(() => {
if (!isOpen) return;
if (friendshipLoadedRef.current) return;
if (isOwnCard) return;
if (typeof data?.id !== "number") return;
friendshipLoadedRef.current = true;
fetcher.load(userCardFriendshipPage(data.id, { withMutualFriends }));
}, [isOpen, isOwnCard, data?.id, withMutualFriends, fetcher.load]);
const friendship = fetcher.data;
// close the popover so only the modal is shown
const openNoteDialog = () => {
setIsOpen(false);
setIsNoteDialogOpen(true);
};
const openDeleteConfirm = () => {
setIsOpen(false);
setIsDeleteConfirmOpen(true);
};
if (!data) return <>{children}</>;
return (
<>
<DialogTrigger isOpen={isOpen} onOpenChange={setIsOpen}>
<Button className={styles.trigger}>{children}</Button>
<Popover placement="right" className={styles.popover}>
<Dialog className={styles.dialog}>
<CardContent
data={data}
friendship={friendship}
isOwnCard={isOwnCard}
withMutualFriends={withMutualFriends}
onEditNote={openNoteDialog}
onDeleteNote={openDeleteConfirm}
/>
</Dialog>
</Popover>
</DialogTrigger>
{isNoteDialogOpen ? (
<AddPrivateNoteDialog
userId={data.id}
username={data.username}
note={data.privateNote}
onClose={() => setIsNoteDialogOpen(false)}
/>
) : null}
<FormWithConfirm
isOpen={isDeleteConfirmOpen}
onOpenChange={setIsDeleteConfirmOpen}
action={userCardNotePage(data.id)}
fields={[["_action", "DELETE"]]}
dialogHeading={t("q:privateNote.delete.header", {
name: data.username,
})}
submitButtonText={t("common:actions.delete")}
/>
</>
);
}
/**
* Resolves a user's `UserCardData` from any matched route loader that spread `{ userCards }`
* (see `UserCardRepository.userCards`). Returns `undefined` when no loader on the current route
* tree carries data for the given user.
*/
export function useUserCardData(
userId: number | undefined,
): UserCardData | undefined {
const matches = useMatches();
if (typeof userId !== "number") return undefined;
for (const match of matches) {
const data = match.loaderData as
| { userCards?: Map<number, UserCardData> }
| undefined;
const card = data?.userCards?.get(userId);
if (card) return card;
}
return undefined;
}
function CardContent({
data,
friendship,
isOwnCard,
withMutualFriends,
onEditNote,
onDeleteNote,
}: {
data: UserCardData;
/** Lazy-loaded; `undefined` while the friendship fetch is in flight. */
friendship: UserCardFriendship | undefined;
isOwnCard: boolean;
withMutualFriends: boolean;
onEditNote: () => void;
onDeleteNote: () => void;
}) {
const { t } = useTranslation(["common", "user"]);
const location = useLocation();
const [isNoteOpen, setIsNoteOpen] = React.useState(false);
const stats = data.stats.toSorted(
(a, b) => STAT_ORDER[a.type] - STAT_ORDER[b.type],
);
const editPageUrl = userCardEditPage({
returnTo: `${location.pathname}${location.search}`,
});
// an existing note shows in-place (toggled); with no note the button opens the add modal directly
const showNoteView = isNoteOpen && data.privateNote !== null;
const onNoteButtonPress = () => {
if (data.privateNote === null) {
onEditNote();
return;
}
setIsNoteOpen((prev) => !prev);
};
return (
<div
className={styles.card}
style={customThemeStyle(data.customTheme)}
data-custom-theme={data.customTheme ? true : undefined}
>
<Banner banner={data.banner} />
{data.freeAgentPostId !== null ? (
<LinkButton
to={`${LFG_PAGE}#${data.freeAgentPostId}`}
size="miniscule"
icon={<Megaphone />}
className={styles.freeAgentBadge}
>
{t("user:card.freeAgent")}
</LinkButton>
) : null}
<div className={styles.iconButtons}>
{isOwnCard ? (
<LinkButton to={editPageUrl} size="miniscule" icon={<Pencil />}>
{t("common:actions.edit")}
</LinkButton>
) : (
<>
{friendship && !friendship.isFriend ? (
<FriendRequestButton
targetUserId={data.id}
hasPendingFriendRequest={friendship.hasPendingFriendRequest}
/>
) : null}
<SendouButton
size="miniscule"
shape="circle"
icon={
data.privateNote !== null ? <NotebookText /> : <NotebookPen />
}
onPress={onNoteButtonPress}
aria-label={t("user:card.editPrivateNote")}
/>
</>
)}
</div>
<div className={styles.identity}>
<NoteAvatar sentiment={data.privateNote?.sentiment}>
<Avatar user={data} size="md" className={styles.avatar} />
</NoteAvatar>
<div className={styles.nameGroup}>
<h2 className={styles.username}>{data.username}</h2>
{data.customUrl ? (
<div className={styles.subtitle}>{data.customUrl}</div>
) : null}
{data.friendCode ? (
<span className={styles.friendCode}>SW-{data.friendCode}</span>
) : (
/** reserve space */
<span className={styles.friendCode}>{"\u200b"}</span>
)}
</div>
</div>
{showNoteView ? (
<NoteView
note={data.privateNote}
onEdit={onEditNote}
onDelete={onDeleteNote}
/>
) : (
<>
{stats.length > 0 ? (
<div className={styles.stats}>
{stats.map((stat, i) => (
<React.Fragment key={stat.type}>
{i > 0 ? <span className={styles.statDivider} /> : null}
<Stat stat={stat} />
</React.Fragment>
))}
</div>
) : null}
{isOwnCard || !withMutualFriends ? null : (
<CardMutualFriends friendship={friendship} />
)}
{data.shortBio ? <p className={styles.bio}>{data.shortBio}</p> : null}
<LinkButton
to={userPage(data)}
variant="outlined"
size="small"
className={styles.viewUserPage}
>
{t("user:card.viewUserPage")}
</LinkButton>
</>
)}
</div>
);
}
function NoteView({
note,
onEdit,
onDelete,
}: {
note: UserCardData["privateNote"];
onEdit: () => void;
onDelete: () => void;
}) {
const { t } = useTranslation(["common", "user"]);
return (
<div className={styles.noteView}>
<div className={styles.noteHeaderGroup}>
<span className={styles.noteHeader}>{t("user:card.privateNote")}</span>
{note ? (
<LocaleTime
date={note.updatedAt}
options={{ day: "numeric", month: "numeric", year: "numeric" }}
className={styles.noteDate}
inline
/>
) : null}
</div>
{note?.text ? <p className={styles.noteText}>{note.text}</p> : null}
<div className={styles.noteViewActions}>
<SendouButton
variant="minimal"
size="miniscule"
icon={<Pencil />}
onPress={onEdit}
>
{t("common:actions.edit")}
</SendouButton>
<SendouButton
variant="minimal-destructive"
size="miniscule"
icon={<Trash2 />}
onPress={onDelete}
>
{t("common:actions.delete")}
</SendouButton>
</div>
</div>
);
}
/**
* Send friend request action on the card. Submits to the `/friends` route action and shows a
* checkmark once a request is pending (server-known or just sent). Cancelling a pending request
* is done on the `/friends` page.
*/
function FriendRequestButton({
targetUserId,
hasPendingFriendRequest,
}: {
targetUserId: number;
hasPendingFriendRequest: boolean;
}) {
const { t } = useTranslation(["user"]);
const fetcher = useFetcher();
const previousStateRef = React.useRef(fetcher.state);
React.useEffect(() => {
if (
previousStateRef.current !== "idle" &&
fetcher.state === "idle" &&
fetcher.data === null
) {
toastQueue.add(
{
message: t("user:card.friendRequestSent"),
variant: "success",
},
{ timeout: 5000 },
);
}
previousStateRef.current = fetcher.state;
}, [fetcher.state, fetcher.data, t]);
const requestPending =
hasPendingFriendRequest ||
fetcher.state !== "idle" ||
fetcher.data === null;
if (requestPending) {
return (
<SendouButton
size="miniscule"
shape="circle"
icon={<UserRoundCheck />}
isDisabled
aria-label={t("user:card.friendRequestPending")}
/>
);
}
return (
<SendouButton
size="miniscule"
shape="circle"
icon={<UserPlus />}
aria-label={t("user:card.sendFriendRequest")}
onPress={() =>
fetcher.submit(
{ _action: "SEND_REQUEST", userId: targetUserId },
{ method: "post", action: FRIENDS_PAGE },
)
}
/>
);
}
/**
* Mutual friends row with reserved height so the card does not shift when the lazy friendship fetch
* resolves: empty while loading, "No mutual friends" when there are none, the avatar stack otherwise.
*/
function CardMutualFriends({
friendship,
}: {
friendship: UserCardFriendship | undefined;
}) {
const { t } = useTranslation(["user"]);
return (
<div className={styles.mutualFriends}>
{friendship === undefined ? null : friendship.mutualFriends.length ===
0 ? (
<span className={styles.noMutualFriends}>
{t("user:card.noMutualFriends")}
</span>
) : (
<MutualFriends
mutualFriends={friendship.mutualFriends}
withoutPopover
/>
)}
</div>
);
}
function Banner({ banner }: { banner: UserCardData["banner"] }) {
const style = (() => {
switch (banner.type) {
case "STAGE":
return {
backgroundImage: `url(${stageBannerImageUrl(banner.stageId)})`,
};
case "URL":
return { backgroundImage: `url(${banner.url})` };
case "COLOR":
return { backgroundColor: banner.hexCode };
default:
assertUnreachable(banner);
}
})();
return (
<div
className={styles.banner}
style={style}
data-testid="user-card-banner"
/>
);
}
function Stat({ stat }: { stat: UserCardData["stats"][number] }) {
const { t } = useTranslation(["user"]);
switch (stat.type) {
case "XP": {
const unverified = stat.values.find((value) => !value.isVerified);
const verified = stat.values.find((value) => value.isVerified);
const primary = unverified ?? verified;
const secondary = unverified ? verified : undefined;
return (
<span className={clsx(styles.stat, styles.xpStat)}>
{primary ? (
<span className={styles.xpPrimary}>
{primary.isVerified ? (
<BadgeCheck className={styles.xpVerifiedIconLarge} />
) : null}
<DivImage region={primary.region} />
{primary.points}
{t("user:card.xp")}
</span>
) : null}
{secondary ? (
<span className={styles.xpVerified}>
<BadgeCheck className={styles.xpVerifiedIconSmall} />
<DivImage region={secondary.region} />
{secondary.points}
{t("user:card.xp")}
</span>
) : null}
</span>
);
}
case "DIV":
return <span className={styles.stat}>Div {stat.value}</span>;
case "PLUS":
return (
<span className={clsx(styles.stat, styles.plusStat)}>
<Image path={navIconUrl("plus")} alt="+" size={24} />
{stat.value}
</span>
);
case "SEASON":
return (
<span className={styles.seasonStat}>
<TierImage tier={stat.value} width={32} />
{typeof stat.top === "number" ? (
<span className={styles.seasonTop}>
<Placement
placement={stat.top}
size={14}
showAsSuperscript={false}
/>
</span>
) : null}
</span>
);
default:
assertUnreachable(stat);
}
}
function DivImage({ region }: { region: XRankPlacementRegion }) {
const { t } = useTranslation(["common"]);
if (region !== "WEST") return null;
return (
<Image
path={brandImageUrl(TENTATEK_BRAND_ID)}
alt={t("common:divisions.WEST")}
width={18}
height={18}
/>
);
}
function customThemeStyle(
customTheme: UserCardData["customTheme"],
): React.CSSProperties {
if (!customTheme) return {};
return R.pickBy(
customTheme,
(value, key) =>
value !== null && !key.includes("--_size") && !key.includes("--_border"),
) as React.CSSProperties;
}

View File

@ -0,0 +1,28 @@
import { requireUser } from "~/features/auth/core/user.server";
import * as XRankPlacementRepository from "~/features/top-search/XRankPlacementRepository.server";
import invariant from "~/utils/invariant";
import * as UserCardRepository from "../UserCardRepository.server";
export const loader = async () => {
const user = requireUser();
const [{ userCards }, extras, hasLinkedPlayer] = await Promise.all([
UserCardRepository.userCards({
userIds: [user.id],
includeHiddenStats: true,
}),
UserCardRepository.cardEditExtras(user.id),
XRankPlacementRepository.isPlayerLinkedByUserId(user.id),
]);
const card = userCards.get(user.id);
invariant(card, "card data not found for own user");
return {
card,
extras,
isSupporter: Boolean(user.roles?.includes("SUPPORTER")),
presentStats: card.stats.map((stat) => stat.type),
hasLinkedPlayer,
};
};

View File

@ -0,0 +1,56 @@
import type { LoaderFunctionArgs } from "react-router";
import { getUser } from "~/features/auth/core/user.server";
import * as FriendRepository from "~/features/friends/FriendRepository.server";
import type { SerializeFrom } from "~/utils/remix";
import type { UserCardFriendship } from "../user-card-types";
export type UserCardFriendshipLoaderData = SerializeFrom<typeof loader>;
/**
* Viewer-relative friendship data for a single user, lazy-loaded by the `UserCard`
* popover when it opens (keeps `isFriend` + `mutualFriends` out of the batched card
* query). Resolves to empty values when there is no logged-in viewer. Mutual friends
* are only resolved when the card opts in via the `mutuals=true` query param (some
* views, e.g. match pages, don't surface them), so the extra query is otherwise skipped.
*/
export const loader = async ({
params,
request,
}: LoaderFunctionArgs): Promise<UserCardFriendship> => {
const viewer = getUser();
const targetUserId = Number(params.id);
if (!viewer || Number.isNaN(targetUserId)) {
return {
isFriend: false,
hasPendingFriendRequest: false,
mutualFriends: [],
};
}
const withMutualFriends =
new URL(request.url).searchParams.get("mutuals") === "true";
const [friendship, pendingRequest, mutualFriends] = await Promise.all([
FriendRepository.findFriendship({
userOneId: viewer.id,
userTwoId: targetUserId,
}),
FriendRepository.findFriendRequestBetween({
senderId: viewer.id,
receiverId: targetUserId,
}),
withMutualFriends
? FriendRepository.findMutualFriends({
loggedInUserId: viewer.id,
targetUserId,
})
: [],
]);
return {
isFriend: Boolean(friendship),
hasPendingFriendRequest: Boolean(pendingRequest),
mutualFriends,
};
};

View File

@ -0,0 +1,39 @@
import type { ActionFunctionArgs } from "react-router";
import { requireUser } from "~/features/auth/core/user.server";
import * as PrivateUserNoteRepository from "~/features/sendouq/PrivateUserNoteRepository.server";
import { parseParams, parseRequestPayload } from "~/utils/remix.server";
import {
userCardNoteParamsSchema,
userCardNoteSchema,
} from "../user-card-schemas";
export const action = async ({ request, params }: ActionFunctionArgs) => {
requireUser();
const targetId = parseParams({
params,
schema: userCardNoteParamsSchema,
}).id;
const data = await parseRequestPayload({
request,
schema: userCardNoteSchema,
});
const isEmptySave =
data._action === "SAVE" &&
data.comment === null &&
data.sentiment === "NEUTRAL";
if (data._action === "DELETE" || isEmptySave) {
await PrivateUserNoteRepository.deleteOwnNoteById(targetId);
return { ok: true };
}
await PrivateUserNoteRepository.upsertOwnNote({
targetId,
sentiment: data.sentiment,
text: data.comment,
});
return { ok: true };
};

View File

@ -0,0 +1,31 @@
.swatchesLegend {
font-size: var(--font-sm);
margin-bottom: var(--s-1-5);
}
.swatches {
display: flex;
flex-wrap: wrap;
gap: var(--s-2);
}
.swatch {
width: 2rem;
height: 2rem;
border-radius: var(--radius-selector);
border: 2px solid transparent;
cursor: pointer;
&:hover {
border-color: var(--color-border-high);
}
}
.swatchSelected {
border-color: var(--color-text);
}
.supporterOnly {
font-size: var(--font-sm);
color: var(--color-text-high);
}

View File

@ -0,0 +1,141 @@
import clsx from "clsx";
import { useTranslation } from "react-i18next";
import {
type MetaFunction,
useLoaderData,
useSearchParams,
} from "react-router";
import { Main } from "~/components/Main";
import type { XRankPlacementRegion } from "~/db/tables";
import { HowToLinkPopover } from "~/features/top-search/components/HowToLinkPopover";
import { type CustomFieldRenderProps, FormField } from "~/form/FormField";
import { existingImage } from "~/form/image-field";
import { SendouForm, useFormFieldContext } from "~/form/SendouForm";
import { metaTags } from "~/utils/remix";
import type { SendouRouteHandle } from "~/utils/remix.server";
import { userCardEditPage } from "~/utils/urls";
import { PRESET_COLORS } from "../../tier-list-maker/tier-list-maker-constants";
import { action } from "../actions/user-card.edit.server";
import { loader } from "../loaders/user-card.edit.server";
import { updateUserCardSchema } from "../user-card-schemas";
import styles from "./user-card.edit.module.css";
export { action, loader };
export const handle: SendouRouteHandle = {
i18n: ["user"],
};
export const meta: MetaFunction = (args) => {
return metaTags({
title: "Edit user card",
location: args.location,
});
};
export default function UserCardEditPage() {
const { t } = useTranslation(["user"]);
const data = useLoaderData<typeof loader>();
const [searchParams] = useSearchParams();
const returnTo = searchParams.get("returnTo");
return (
<Main halfWidth>
<div className="stack md">
<div>
<h2 className="text-lg">{t("user:card.edit.title")}</h2>
{!data.hasLinkedPlayer ? <HowToLinkPopover /> : null}
</div>
<SendouForm
schema={updateUserCardSchema}
action={returnTo ? userCardEditPage({ returnTo }) : undefined}
defaultValues={defaultValues(data)}
>
<CardEditFields />
</SendouForm>
</div>
</Main>
);
}
function defaultValues(data: Awaited<ReturnType<typeof loader>>) {
const { card, extras } = data;
const banner = card.banner;
const peakXp = extras.unverifiedPeakXP;
return {
shortBio: card.shortBio ?? "",
bannerType: banner.type,
bannerColor: banner.type === "COLOR" ? banner.hexCode : PRESET_COLORS[0],
bannerStageId: banner.type === "STAGE" ? banner.stageId : 1,
bannerImage: existingImage(extras.bannerImgId, extras.bannerImageUrl),
unverifiedXpPoints: peakXp?.overall,
unverifiedXpDivision: (typeof peakXp?.takoroka === "number"
? "JPN"
: "WEST") as XRankPlacementRegion,
hideXp: extras.hiddenCardStats.includes("XP"),
hideDiv: extras.hiddenCardStats.includes("DIV"),
};
}
function CardEditFields() {
const { t } = useTranslation(["user"]);
const { isSupporter, presentStats } = useLoaderData<typeof loader>();
const { values } = useFormFieldContext();
const bannerType = values.bannerType as "COLOR" | "STAGE" | "URL";
return (
<div className="stack md">
<FormField name="shortBio" />
<FormField name="bannerType" />
{bannerType === "COLOR" ? <BannerColorField /> : null}
{bannerType === "STAGE" ? <FormField name="bannerStageId" /> : null}
{bannerType === "URL" ? (
isSupporter ? (
<FormField name="bannerImage" />
) : (
<p className={styles.supporterOnly}>
{t("user:card.edit.supporterOnlyBanner")}
</p>
)
) : null}
<FormField name="unverifiedXpPoints" />
<FormField name="unverifiedXpDivision" />
{presentStats.includes("XP") ? <FormField name="hideXp" /> : null}
{presentStats.includes("DIV") ? <FormField name="hideDiv" /> : null}
</div>
);
}
function BannerColorField() {
const { t } = useTranslation(["user"]);
return (
<FormField name="bannerColor">
{({ value, onChange }: CustomFieldRenderProps) => (
<fieldset>
<legend className={styles.swatchesLegend}>
{t("user:card.edit.bannerColor")}
</legend>
<div className={styles.swatches}>
{PRESET_COLORS.map((color) => (
<button
key={color}
type="button"
className={clsx(styles.swatch, {
[styles.swatchSelected]: value === color,
})}
style={{ backgroundColor: color }}
onClick={() => onChange(color)}
aria-label={color}
aria-pressed={value === color}
/>
))}
</div>
</fieldset>
)}
</FormField>
);
}

View File

@ -0,0 +1,17 @@
export const USER_CARD = {
SHORT_BIO_MAX_LENGTH: 64,
/**
* Highest self-reported peak XP accepted for a user with no linked, verified
* Splatoon player. The Takoroka (JPN) division has a higher ceiling than
* Tentatek (WEST).
*/
MAX_UNVERIFIED_XP_BY_DIVISION: {
WEST: 2200,
JPN: 2700,
},
/**
* Extra peak XP a user with a linked, verified Splatoon player may self-report
* on top of their division's cap.
*/
MAX_UNVERIFIED_XP_LINKED_PLAYER_BONUS: 200,
} as const;

View File

@ -0,0 +1,70 @@
import { z } from "zod";
import { SENDOUQ } from "~/features/sendouq/q-constants";
import {
customField,
image,
numberFieldOptional,
select,
stageSelect,
textAreaOptional,
toggle,
} from "~/form/fields";
import { _action, falsyToNull, id } from "~/utils/zod";
import { PRESET_COLORS } from "../tier-list-maker/tier-list-maker-constants";
import { USER_CARD } from "./user-card-constants";
export const updateUserCardSchema = z.object({
shortBio: textAreaOptional({
label: "labels.shortBio",
maxLength: USER_CARD.SHORT_BIO_MAX_LENGTH,
}),
bannerType: select({
label: "labels.banner",
items: [
{ label: "options.bannerType.COLOR", value: "COLOR" },
{ label: "options.bannerType.STAGE", value: "STAGE" },
{ label: "options.bannerType.URL", value: "URL" },
],
}),
bannerColor: customField(
{ initialValue: PRESET_COLORS[0] },
z.string().regex(/^#[0-9a-f]{6}$/i),
),
bannerStageId: stageSelect({ label: "labels.bannerStage" }),
bannerImage: image({
label: "labels.bannerImage",
dimensions: "thick-banner",
autoValidate: true,
}),
unverifiedXpPoints: numberFieldOptional({
label: "labels.unverifiedXp",
bottomText: "bottomTexts.unverifiedXp",
}),
unverifiedXpDivision: select({
label: "labels.division",
items: [
{ label: "options.xpDivision.WEST", value: "WEST" },
{ label: "options.xpDivision.JPN", value: "JPN" },
],
}),
hideXp: toggle({ label: "labels.hideXp" }),
hideDiv: toggle({ label: "labels.hideDiv" }),
});
export const userCardNoteSchema = z.union([
z.object({
_action: _action("SAVE"),
comment: z.preprocess(
falsyToNull,
z.string().max(SENDOUQ.PRIVATE_USER_NOTE_MAX_LENGTH).nullable(),
),
sentiment: z.enum(["POSITIVE", "NEUTRAL", "NEGATIVE"]),
}),
z.object({
_action: _action("DELETE"),
}),
]);
export const userCardNoteParamsSchema = z.object({
id,
});

View File

@ -0,0 +1,70 @@
import type { CustomTheme, Tables, XRankPlacementRegion } from "~/db/tables";
import type { StageId } from "~/modules/in-game-lists/types";
import type { CommonUser } from "~/utils/kysely.server";
import type { TieredSkill } from "../mmr/tiered.server";
export interface UserCardData extends CommonUser {
banner: UserCarBannerData;
shortBio: string | null;
customTheme: CustomTheme | null;
friendCode: string | null;
/** Id of the user's free agent LFG post, or `null` if they have none. */
freeAgentPostId: number | null;
/** The viewer's private note about this user, or `null` when they have none. */
privateNote: Pick<
Tables["PrivateUserNote"],
"text" | "sentiment" | "updatedAt"
> | null;
stats: Array<UserCardStat>;
}
/**
* Viewer-relative card fields lazy-loaded when the card opens (see the
* `/user-card/:id/friendship` resource route), kept out of the batched `UserCardData`
* query because they are only needed for the one card a viewer actually opens.
*/
export interface UserCardFriendship {
isFriend: boolean;
/** Whether a friend request between the viewer and this user is currently pending. */
hasPendingFriendRequest: boolean;
mutualFriends: Array<CommonUser>;
}
type UserCarBannerData =
| {
type: "URL";
url: string;
}
| {
type: "COLOR";
hexCode: string;
}
| {
type: "STAGE";
stageId: StageId;
};
export type UserCardStat =
| {
type: "XP";
values: Array<UserCardStatXPValue>;
}
| {
type: "DIV";
value: string;
}
| {
type: "PLUS";
value: number;
}
| {
type: "SEASON";
value: TieredSkill["tier"];
top: number | null;
};
export interface UserCardStatXPValue {
isVerified: boolean;
region: XRankPlacementRegion;
points: number;
}

View File

@ -0,0 +1,19 @@
import type { XRankPlacementRegion } from "~/db/tables";
import { USER_CARD } from "./user-card-constants";
/**
* Highest self-reported peak XP accepted for the given division. A user with a
* linked, verified Splatoon player is allowed a higher cap than one without.
*/
export function maxUnverifiedXp({
division,
hasLinkedPlayer,
}: {
division: XRankPlacementRegion;
hasLinkedPlayer: boolean;
}) {
const base = USER_CARD.MAX_UNVERIFIED_XP_BY_DIVISION[division];
return hasLinkedPlayer
? base + USER_CARD.MAX_UNVERIFIED_XP_LINKED_PLAYER_BONUS
: base;
}

View File

@ -1140,6 +1140,23 @@ export function updateOwnProfile(args: UpdateProfileArgs) {
});
}
/** Bulk-sets each user's latest LUTI division. Used by the `ComputeLutiDivs` routine. */
export function updateManyDivs(
updates: Array<{ userId: number; div: string }>,
) {
if (updates.length === 0) return;
return db.transaction().execute(async (trx) => {
for (const { userId, div } of updates) {
await trx
.updateTable("User")
.set({ div })
.where("id", "=", userId)
.execute();
}
});
}
export function updateOwnCustomTheme(css: CustomTheme | null) {
return db
.updateTable("User")

View File

@ -1,6 +1,8 @@
.trigger {
display: flex;
align-items: center;
font-size: var(--font-xs);
font-weight: var(--weight-bold);
span {
color: var(--color-text-high);

View File

@ -3,23 +3,29 @@ import { Link } from "react-router";
import { Avatar } from "~/components/Avatar";
import { SendouButton } from "~/components/elements/Button";
import { SendouPopover } from "~/components/elements/Popover";
import type { CommonUser } from "~/utils/kysely.server";
import { userPage } from "~/utils/urls";
import type { UserPageLoaderData } from "../loaders/u.$identifier.server";
import styles from "./MutualFriends.module.css";
const MAX_VISIBLE_AVATARS = 5;
export function MutualFriends({
mutualFriends,
withoutPopover = false,
}: {
mutualFriends: UserPageLoaderData["mutualFriends"];
mutualFriends: Array<CommonUser>;
/** When true renders a static avatar stack without the interactive popover, e.g. on the user card. */
withoutPopover?: boolean;
}) {
const { t } = useTranslation(["user"]);
if (mutualFriends.length === 0) return null;
const visibleFriends = mutualFriends.slice(0, MAX_VISIBLE_AVATARS);
const overflowCount = mutualFriends.length - MAX_VISIBLE_AVATARS;
if (withoutPopover) {
return (
<div className={styles.trigger}>
<AvatarStack mutualFriends={mutualFriends} />
</div>
);
}
return (
<div>
@ -27,24 +33,7 @@ export function MutualFriends({
trigger={
<SendouButton variant="minimal" size="small">
<div className={styles.trigger}>
<div className={styles.avatarStack}>
{visibleFriends.map((friend) => (
<Avatar
key={friend.id}
user={friend}
size="xxs"
className={styles.stackedAvatar}
/>
))}
</div>
{overflowCount > 0 ? (
<span className={styles.overflow}>+{overflowCount}</span>
) : null}
<span>
{t("user:mutualFriends.count", {
count: mutualFriends.length,
})}
</span>
<AvatarStack mutualFriends={mutualFriends} />
</div>
</SendouButton>
}
@ -65,3 +54,33 @@ export function MutualFriends({
</div>
);
}
function AvatarStack({ mutualFriends }: { mutualFriends: Array<CommonUser> }) {
const { t } = useTranslation(["user"]);
const visibleFriends = mutualFriends.slice(0, MAX_VISIBLE_AVATARS);
const overflowCount = mutualFriends.length - MAX_VISIBLE_AVATARS;
return (
<>
<div className={styles.avatarStack}>
{visibleFriends.map((friend) => (
<Avatar
key={friend.id}
user={friend}
size="xxs"
className={styles.stackedAvatar}
/>
))}
</div>
{overflowCount > 0 ? (
<span className={styles.overflow}>+{overflowCount}</span>
) : null}
<span>
{t("user:mutualFriends.count", {
count: mutualFriends.length,
})}
</span>
</>
);
}

View File

@ -53,6 +53,18 @@ export default [
route("/friends", "features/friends/routes/friends.tsx"),
route("/user-card/edit", "features/user-card/routes/user-card.edit.tsx"),
route(
"/user-card/:id/friendship",
"features/user-card/routes/user-card.$id.friendship.ts",
),
route(
"/user-card/:id/note",
"features/user-card/routes/user-card.$id.note.ts",
),
route("/events", "features/calendar/routes/events.tsx"),
route("/suspended", "features/ban/routes/suspended.tsx"),

View File

@ -0,0 +1,56 @@
import { parseLutiDivFromName } from "../features/scrims/scrims-utils";
import * as TournamentRepository from "../features/tournament/TournamentRepository.server";
import { LEAGUES } from "../features/tournament/tournament-constants";
import * as UserRepository from "../features/user-page/UserRepository.server";
import { logger } from "../utils/logger";
import { Routine } from "./routine.server";
/**
* Recomputes `User.div` (the user's division in the latest finished LUTI). Looks at the most recent
* LUTI season whose division tournaments are all finalized and sets the division for every eligible
* participant (on a team that did not drop out and played at least one match). Users not in that
* season keep their previous division. Idempotent.
*/
export const ComputeLutiDivsRoutine = new Routine({
name: "ComputeLutiDivs",
func: async () => {
const children = await latestFinishedLutiDivisions();
if (!children) return;
const updates: Array<{ userId: number; div: string }> = [];
for (const child of children) {
const div = parseLutiDivFromName(child.name);
if (!div) {
logger.warn(
`ComputeLutiDivs: could not parse division from tournament name "${child.name}"`,
);
continue;
}
const userIds =
await TournamentRepository.findLeagueDivParticipantUserIds(
child.tournamentId,
);
for (const { userId } of userIds) {
updates.push({ userId, div });
}
}
await UserRepository.updateManyDivs(updates);
logger.info(`ComputeLutiDivs: updated div for ${updates.length} users`);
},
});
async function latestFinishedLutiDivisions() {
for (const league of [...(LEAGUES.LUTI ?? [])].reverse()) {
const children = await TournamentRepository.findChildTournamentsForDivCalc(
league.tournamentId,
);
if (children.length === 0) continue;
if (children.every((child) => child.isFinalized === 1)) {
return children;
}
}
return null;
}

View File

@ -1,5 +1,6 @@
import { CloseExpiredCommissionsRoutine } from "./closeExpiredCommissions";
import { CloseExpiredContinueVotesRoutine } from "./closeExpiredContinueVotes";
import { ComputeLutiDivsRoutine } from "./computeLutiDivs";
import { DeleteObsoleteMatchVodsRoutine } from "./deleteObsoleteMatchVods";
import { DeleteOldExternalStreamsRoutine } from "./deleteOldExternalStreams";
import { DeleteOldNotificationsRoutine } from "./deleteOldNotifications";
@ -43,6 +44,7 @@ export const daily = [
DeleteOldTournamentAuditLogsRoutine,
CloseExpiredCommissionsRoutine,
DeleteOrphanArtTagsRoutine,
ComputeLutiDivsRoutine,
OptimizeDatabaseRoutine,
];

View File

@ -79,7 +79,8 @@ cannot use currentColor inside data URLs
Any changes here NEED to be reflected in oklch-gamut.ts as well
*/
html.dark {
html.dark,
html.dark [data-custom-theme] {
--color-base-0: oklch(100% var(--_base-c-0) var(--_base-h));
--color-base-1: oklch(95% var(--_base-c-1) var(--_base-h));
--color-base-2: oklch(90% var(--_base-c-2) var(--_base-h));
@ -129,7 +130,8 @@ html.dark {
--field-icon-time: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' viewBox='0 0 24 24' fill='none' stroke='rgb(145, 145, 145)' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Ccircle cx='12' cy='12' r='10'%3E%3C/circle%3E%3Cpolyline points='12 6 12 12 16 14'%3E%3C/polyline%3E%3C/svg%3E");
}
html.light {
html.light,
html.light [data-custom-theme] {
--color-base-0: oklch(17% var(--_base-c-7) var(--_base-h));
--color-base-1: oklch(25% var(--_base-c-6) var(--_base-h));
--color-base-2: oklch(32% var(--_base-c-5) var(--_base-h));
@ -182,7 +184,9 @@ html.light {
/*
These are the vars you mainly want to use
*/
html {
html,
/* biome-ignore lint/style/noDescendingSpecificity: [data-custom-theme] re-declares theme vars for card subtrees; different properties than the html.dark/light blocks so no real override */
[data-custom-theme] {
--color-text: var(--color-base-0);
--color-text-high: var(--color-base-3);
--color-text-inverse: var(--color-base-7);

View File

@ -44,6 +44,15 @@ export function withUserId<T>(id: number, fn: () => T): T {
);
}
/**
* Runs `fn` inside a user AsyncLocalStorage store with no acting user, mirroring an
* anonymous visitor's request. Repository functions resolving the actor via
* `actorIdOrNull()` then see `null`, as they would inside a real request context.
*/
export function withNoUser<T>(fn: () => T): T {
return userAsyncLocalStorage.run({ user: undefined }, fn);
}
/**
* Wraps an action function to provide a strongly-typed, reusable handler for executing actions
* in unit tests as if it was a normal function. The returned function allows you to pass

View File

@ -55,21 +55,24 @@ const userChatNameHueRaw = sql<
export const userChatNameHue = userChatNameHueRaw.as("chatNameHue");
export function commonUserJsonObject(eb: ExpressionBuilder<Tables, "User">) {
return jsonBuildObject({
/**
* The {@link CommonUser} fields as a plain record of Kysely expressions, for spreading into a
* hand-built `jsonBuildObject` alongside extra fields. Prefer {@link commonUserJsonObject} when the
* common fields are the whole object.
*/
export function commonUserObjectFields(eb: ExpressionBuilder<Tables, "User">) {
return {
id: eb.ref("User.id"),
username: eb.ref("User.username"),
discordId: eb.ref("User.discordId"),
discordAvatar: eb.ref("User.discordAvatar"),
customUrl: eb.ref("User.customUrl"),
customAvatarUrl: concatUserSubmittedImagePrefix(
eb
.selectFrom("UserSubmittedImage")
.select("UserSubmittedImage.url")
.whereRef("UserSubmittedImage.id", "=", "User.customAvatarImgId")
.$asScalar(),
).$castTo<string | null>(),
});
customAvatarUrl: customAvatarUrl(eb),
};
}
export function commonUserJsonObject(eb: ExpressionBuilder<Tables, "User">) {
return jsonBuildObject(commonUserObjectFields(eb));
}
const USER_SUBMITTED_IMAGE_ROOT =

View File

@ -115,6 +115,7 @@ export const LFG_PAGE = "/lfg";
export const EVENTS_PAGE = "/events";
export const FRIENDS_PAGE = "/friends";
export const SETTINGS_PAGE = "/settings";
const USER_CARD_EDIT_PAGE = "/user-card/edit";
export const LUTI_PAGE = "/luti";
export const PLUS_VOTING_PAGE = "/plus/voting";
@ -141,6 +142,16 @@ export const PATRONS_LIST_ROUTE = "/patrons-list";
export const NOTIFICATIONS_URL = "/notifications";
export const NOTIFICATIONS_MARK_AS_SEEN_ROUTE = "/notifications/seen";
export const userCardFriendshipPage = (
userId: number,
args?: { withMutualFriends?: boolean },
) =>
`/user-card/${userId}/friendship${
args?.withMutualFriends ? "?mutuals=true" : ""
}`;
export const userCardNotePage = (userId: number) => `/user-card/${userId}/note`;
interface UserLinkArgs {
discordId: Tables["User"]["discordId"];
customUrl?: Tables["User"]["customUrl"];
@ -181,6 +192,10 @@ export const userBuildsPage = (user: UserLinkArgs) =>
export const userResultsPage = (user: UserLinkArgs, showAll?: boolean) =>
`${userPage(user)}/results${showAll ? "?all=true" : ""}`;
export const userVodsPage = (user: UserLinkArgs) => `${userPage(user)}/vods`;
export const userCardEditPage = (args?: { returnTo?: string }) =>
`${USER_CARD_EDIT_PAGE}${
args?.returnTo ? `?returnTo=${encodeURIComponent(args.returnTo)}` : ""
}`;
export const newVodPage = (vodToEditId?: number) =>
`${VODS_PAGE}/new${vodToEditId ? `?vod=${vodToEditId}` : ""}`;
export const userResultsEditHighlightsPage = (user: UserLinkArgs) =>

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

39
e2e/user-card.spec.ts Normal file
View File

@ -0,0 +1,39 @@
import { ADMIN_ID } from "~/features/admin/admin-constants";
import { SENDOUQ_LOOKING_PAGE } from "~/utils/urls";
import {
expect,
impersonate,
navigate,
seed,
submit,
test,
} from "./helpers/playwright";
test.describe("User card", () => {
test("edits banner and bio from the looking page", async ({ page }) => {
await seed(page);
await impersonate(page, ADMIN_ID);
await navigate({ page, url: SENDOUQ_LOOKING_PAGE });
const ownGroup = page.getByTestId("sendouq-group-card").first();
await ownGroup.getByRole("button", { name: "Sendou" }).click();
await page.getByRole("link", { name: "Edit" }).click();
await expect(page).toHaveURL(/\/user-card\/edit/);
const newBio = "New bio from e2e test";
await page.getByLabel("Short bio").fill(newBio);
await page.getByLabel("Banner", { exact: true }).selectOption("COLOR");
await page.getByRole("button", { name: "#4169e1" }).click();
await submit(page);
await expect(page).toHaveURL(SENDOUQ_LOOKING_PAGE);
await ownGroup.getByRole("button", { name: "Sendou" }).click();
await expect(page.getByText(newBio)).toBeVisible();
await expect(page.getByTestId("user-card-banner")).toHaveCSS(
"background-color",
"rgb(65, 105, 225)",
);
});
});

View File

@ -4,7 +4,6 @@ const config = {
type: true,
},
tags: ["-lintignore"],
ignore: ["scripts/dicts/**"],
entry: [
"app/features/*/routes/**/*.{ts,tsx}",
"migrations/**/*.js",

View File

@ -296,6 +296,14 @@
"xsearch.unlink.title": "",
"xsearch.unlink.action.long": "",
"xsearch.unlink.action.short": "",
"xsearch.link.trigger": "",
"xsearch.link.about.title": "",
"xsearch.link.about.endPower": "",
"xsearch.link.about.notPeak": "",
"xsearch.link.about.wait": "",
"xsearch.link.example": "",
"xsearch.link.exampleRequest": "",
"xsearch.link.noScreenshots": "",
"build.private": "Privat",
"or": "Eller",
"yes": "Ja",

View File

@ -4,6 +4,19 @@
"labels.bio": "Biografi",
"labels.logo": "",
"labels.banner": "",
"labels.bannerStage": "",
"labels.bannerImage": "",
"labels.shortBio": "",
"labels.unverifiedXp": "",
"labels.hideXp": "",
"labels.hideDiv": "",
"options.bannerType.COLOR": "",
"options.bannerType.STAGE": "",
"options.bannerType.URL": "",
"options.xpDivision.WEST": "",
"options.xpDivision.JPN": "",
"bottomTexts.unverifiedXp": "",
"errors.unverifiedXpTooHigh": "",
"labels.link": "",
"labels.tag": "",
"labels.teamBsky": "",

View File

@ -203,5 +203,17 @@
"commissions.closed": "",
"mutualFriends": "",
"mutualFriends.count_one": "",
"mutualFriends.count_other": ""
"mutualFriends.count_other": "",
"card.viewUserPage": "",
"card.sendFriendRequest": "",
"card.friendRequestPending": "",
"card.friendRequestSent": "",
"card.editPrivateNote": "",
"card.privateNote": "",
"card.xp": "",
"card.freeAgent": "",
"card.noMutualFriends": "",
"card.edit.title": "",
"card.edit.bannerColor": "",
"card.edit.supporterOnlyBanner": ""
}

View File

@ -296,6 +296,14 @@
"xsearch.unlink.title": "",
"xsearch.unlink.action.long": "",
"xsearch.unlink.action.short": "",
"xsearch.link.trigger": "",
"xsearch.link.about.title": "",
"xsearch.link.about.endPower": "",
"xsearch.link.about.notPeak": "",
"xsearch.link.about.wait": "",
"xsearch.link.example": "",
"xsearch.link.exampleRequest": "",
"xsearch.link.noScreenshots": "",
"build.private": "",
"or": "",
"yes": "",

Some files were not shown because too many files have changed in this diff Show More