Data loading progress

This commit is contained in:
Kalle
2026-06-27 07:40:14 +03:00
parent 26490a851c
commit e50f13ea73
59 changed files with 1138 additions and 231 deletions

View File

@@ -26,9 +26,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";
@@ -178,6 +180,7 @@ const basicSeeds = (variation?: SeedVariation | null) => [
adminUserWeaponPool,
adminUserWidgets,
userProfiles,
userCardData,
variation === "TEAM_MAP_PREFS" ? undefined : userMapModePreferences,
userMatchProfileWeaponPool,
seedingSkills,
@@ -899,6 +902,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 [];
@@ -3496,16 +3559,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 */ `
@@ -3513,9 +3567,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

@@ -500,12 +500,21 @@ export interface SeedingSkill {
type: "RANKED" | "UNRANKED";
}
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 {
@@ -1091,7 +1100,10 @@ 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 +1148,13 @@ 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;
// xxx: add bannerImgId
/** User card banner default selection, hex code or stage id. Note: supporters can also upload banner (stored in UserSubmittedImage) */
bannerPresetImg: JSONColumnTypeNullable<string | StageId>;
/** 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 */

View File

@@ -1,3 +1,4 @@
import { sql } from "kysely";
import { jsonArrayFrom } from "kysely/helpers/sqlite";
import type { LoaderFunctionArgs } from "react-router";
import { z } from "zod";
@@ -43,7 +44,9 @@ export const loader = async ({ params }: LoaderFunctionArgs) => {
.whereRef("UserWeapon.userId", "=", "User.id")
.orderBy("UserWeapon.order", "asc"),
).as("weapons"),
"SplatoonPlayer.peakXp",
sql<number | null>`"SplatoonPlayer"."peakXp" ->> '$.overall'`.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

@@ -1,4 +1,4 @@
import type { ExpressionBuilder, NotNull } from "kysely";
import { type ExpressionBuilder, type NotNull, sql } from "kysely";
import { jsonArrayFrom, jsonObjectFrom } from "kysely/helpers/sqlite";
import { db } from "~/db/sql";
import type { DB } from "~/db/tables";
@@ -232,7 +232,12 @@ export async function syncXPBadges() {
const userTopXPowers = await trx
.selectFrom("SplatoonPlayer")
.select(["userId", "peakXp"])
.select([
"userId",
sql<number | null>`"SplatoonPlayer"."peakXp" ->> '$.overall'`.as(
"peakXp",
),
])
.where("userId", "is not", null)
.where("peakXp", "is not", null)
.$narrowType<{ userId: NotNull; peakXp: NotNull }>()

View File

@@ -1573,24 +1573,6 @@ function AvatarSection({ id }: { id: string }) {
);
}
const USER_CARD_MUTUAL_FRIENDS = [
"100",
"200",
"300",
"400",
"500",
"600",
"700",
"800",
].map((discordId, i) => ({
id: i + 1,
username: `Friend ${i + 1}`,
discordId,
discordAvatar: null,
customUrl: null,
customAvatarUrl: null,
}));
const USER_CARD_DATA = {
id: 1,
username: "Sendou",
@@ -1601,9 +1583,8 @@ const USER_CARD_DATA = {
banner: { type: "STAGE", stageId: 5 },
shortBio: "Very show bio goes here maybe max two lines that gets clamped.",
customTheme: null,
friendCode: null,
isFriend: false,
mutualFriends: [],
friendCode: "1234-1234-1234",
isFreeAgent: true,
privateNote: { text: null, sentiment: "NEUTRAL" },
stats: [
{
@@ -1621,7 +1602,7 @@ const USER_CARD_DATA = {
name: "LEVIATHAN",
},
},
{ type: "DIV", value: "Div 1" },
{ type: "DIV", value: "1" },
{ type: "PLUS", value: 1 },
],
} satisfies UserCardData;

View File

@@ -1,3 +1,4 @@
import { sql } from "kysely";
import { db } from "~/db/sql";
import type { Tables, TablesInsertable } from "~/db/tables";
import { commonUserSelect } from "~/utils/kysely.server";
@@ -35,14 +36,14 @@ export function findXRankStreams() {
.innerJoin("User", "User.twitch", "LiveStream.twitch")
.innerJoin("SplatoonPlayer", "SplatoonPlayer.userId", "User.id")
.where(
"SplatoonPlayer.peakXp",
sql<number>`"SplatoonPlayer"."peakXp" ->> '$.overall'`,
">=",
StreamRanking.minXpForStreamToBeShown(),
)
.where("LiveStream.twitch", "is not", null)
.select((eb) => [
...commonUserSelect(eb),
"SplatoonPlayer.peakXp",
sql<number>`"SplatoonPlayer"."peakXp" ->> '$.overall'`.as("peakXp"),
"LiveStream.viewerCount",
"LiveStream.thumbnailUrl",
"LiveStream.twitch as twitchUsername",

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

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

View File

@@ -1,6 +1,6 @@
import clsx from "clsx";
import type { SqlBool } from "kysely";
import { Mic, PenSquare, Star, Trash, Volume2, VolumeX } from "lucide-react";
import { Mic, PenSquare, Star, Volume2, VolumeX } from "lucide-react";
import * as React from "react";
import { Flipped } from "react-flip-toolkit";
import { useTranslation } from "react-i18next";
@@ -8,15 +8,14 @@ import { Link, useFetcher } from "react-router";
import { Avatar } from "~/components/Avatar";
import { LinkButton, 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 { 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 } 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";
@@ -26,7 +25,6 @@ import {
specialWeaponImageUrl,
TIERS_PAGE,
tierImageUrl,
userPage,
} from "~/utils/urls";
import type {
SQGroup,
@@ -37,11 +35,7 @@ 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;
// xxx: red cross to indicate negative note left?
export function GroupCard({
group,
@@ -51,7 +45,6 @@ export function GroupCard({
hideWeapons = false,
hideNote: _hidenote = false,
showAddNote,
showNote = false,
ownGroup,
layout = "desktop",
}: {
@@ -62,7 +55,6 @@ export function GroupCard({
hideWeapons?: SqlBool;
hideNote?: boolean;
showAddNote?: SqlBool;
showNote?: boolean;
ownGroup?: SQOwnGroup;
layout?: "mobile" | "desktop";
}) {
@@ -104,7 +96,6 @@ export function GroupCard({
hideWeapons={hideWeapons}
hideNote={hideNote}
enableKicking={enableKicking}
showNote={showNote}
showAddNote={showAddNote && member.id !== user?.id}
/>
);
@@ -264,7 +255,6 @@ function GroupMember({
hideNote,
enableKicking,
showAddNote,
showNote,
}: {
member: SQGroupMember;
showActions: boolean;
@@ -274,7 +264,6 @@ function GroupMember({
hideNote?: boolean;
enableKicking?: boolean;
showAddNote?: SqlBool;
showNote?: boolean;
}) {
const { t } = useTranslation(["q", "user"]);
const user = useUser();
@@ -283,60 +272,23 @@ function GroupMember({
<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}>
<span className="stack xs horizontal items-center">
<Avatar user={member} size="xs" />
<span className={styles.name}>
{member.inGameName ? (
<>
<span className="text-lighter font-bold text-xxxs">
{t("user:ign.short")}:
</span>{" "}
{inGameNameWithoutDiscriminator(member.inGameName)}
</>
) : (
member.username
)}
>
<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>
</span>
</span>
</UserCard>
{member.pronouns ? (
<span className="text-lighter ml-1 text-xxxs">
{member.pronouns.subject}/{member.pronouns.object}
@@ -525,30 +477,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,7 +1,9 @@
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";
@@ -31,11 +33,24 @@ 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,
viewerId: user.id,
})),
groups: groupsToShow,
ownGroup,
likes: ownGroup
? await SQGroupRepository.allLikesByGroupId(ownGroup.id)

View File

@@ -76,6 +76,8 @@ export default function QLookingShell() {
return <QLookingPage />;
}
// xxx: update prompt to fill the profile to include UserCard stuff
function QLookingPage() {
const { t } = useTranslation(["q"]);
const user = useUser();
@@ -258,7 +260,6 @@ function Groups() {
key={group.id}
group={group}
action="UNLIKE"
showNote
ownGroup={data.ownGroup}
layout={layout}
/>
@@ -272,7 +273,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}
@@ -360,7 +361,6 @@ function Groups() {
? "UNLIKE"
: "LIKE"
}
showNote
ownGroup={data.ownGroup}
layout={layout}
/>
@@ -388,7 +388,6 @@ function Groups() {
key={group.id}
group={group}
action={action()}
showNote
ownGroup={data.ownGroup}
layout={layout}
/>
@@ -426,7 +425,6 @@ function Groups() {
key={group.id}
group={group}
action={action()}
showNote
ownGroup={data.ownGroup}
layout={layout}
/>

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

@@ -132,12 +132,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

@@ -419,6 +419,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,71 @@
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { db } from "~/db/sql";
import { dbInsertUsers, dbReset } 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 UserCardRepository.userCards({
userIds: [],
viewerId: null,
});
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 UserCardRepository.userCards({
userIds: [1, 2],
viewerId: null,
});
expect(userCards.size).toBe(2);
const card = userCards.get(1);
expect(card?.id).toBe(1);
expect(card?.isFreeAgent).toBe(false);
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, div: "TAKOROKA", 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);
});
});

View File

@@ -0,0 +1,320 @@
import type { Expression, ExpressionBuilder } from "kysely";
import { sql } from "kysely";
import { jsonBuildObject, jsonObjectFrom } from "kysely/helpers/sqlite";
import { db } from "~/db/sql";
import type { Tables } from "~/db/tables";
import { cachedFullUserLeaderboard } from "~/features/leaderboards/core/leaderboards.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 type { StageId } from "~/modules/in-game-lists/types";
import { commonUserObjectFields } from "~/utils/kysely.server";
import { PRESET_COLORS } from "../tier-list-maker/tier-list-maker-constants";
import type {
UserCardData,
UserCardStat,
UserCardStatXPValue,
XPDivision,
} 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. `viewerId` is the logged-in
* user viewing the cards (or `null`), used to resolve `isFriend`, `mutualFriends` and `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,
viewerId,
include,
}: {
userIds: Array<number>;
viewerId: number | null;
/** Opt-in fields skipped from the query by default; defaults to `false` each. */
include?: { friendCode?: boolean };
}): Promise<{ userCards: Map<number, UserCardData> }> {
if (userIds.length === 0) return { userCards: new Map() };
const rows = await db
.selectFrom("User")
.select((eb) =>
userCardDataJsonObject(eb, { viewerId, include }).as("cardData"),
)
.where("User.id", "in", userIds)
.execute();
// xxx: this should check last two and pick better
const season = Seasons.currentOrPrevious()?.nth ?? null;
const seasonSkills: Record<string, TieredSkill> =
season !== null ? userSkills(season).userSkills : {};
const seasonTopByUserId =
season !== null
? new Map(
(await cachedFullUserLeaderboard(season)).map((entry) => [
entry.id,
entry.placementRank,
]),
)
: new Map<number, number>();
const userCards = new Map<number, UserCardData>();
for (const { cardData } of rows) {
userCards.set(
cardData.id,
enrichUserCardData(cardData, {
seasonSkill: seasonSkills[cardData.id],
// xxx: only needed for leviathan+, maybe lazy load the leaderboard too
seasonTop: seasonTopByUserId.get(cardData.id) ?? null,
}),
);
}
return { userCards };
}
/** 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: eb.ref("User.customTheme"),
banner: bannerJson(),
friendCode: include?.friendCode
? friendCodeScalar(eb)
: sql<string | null>`null`,
privateNote: privateNoteJson(eb, viewerId),
plusTier: plusTierScalar(eb),
xpVerified: xpVerifiedJson(eb),
xpUnverified: xpUnverifiedJson(),
});
}
type RawUserCardData =
ReturnType<typeof userCardDataJsonObject> extends Expression<infer T>
? T
: never;
/**
* Loosely-typed banner pulled from the `User.bannerPresetImg` column ("hex code or stage id"). A
* numeric value is a stage id (`STAGE`), anything else is a `COLOR` hex code. When the column is
* null (no explicit choice) a preset color is derived from the user id. Narrow to the
* `{ COLOR | STAGE }` union in the enrich pass. (Supporter-uploaded URL banners are not yet backed
* by a column, so no `URL` variant is produced here.)
*/
function bannerJson() {
return jsonBuildObject({
type: sql<
"COLOR" | "STAGE"
>`iif("User"."bannerPresetImg" GLOB '[0-9]*', 'STAGE', 'COLOR')`,
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]*', "User"."bannerPresetImg", 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"
> | null>`null`;
}
return jsonObjectFrom(
eb
.selectFrom("PrivateUserNote")
.select(["PrivateUserNote.text", "PrivateUserNote.sentiment"])
.where("PrivateUserNote.authorId", "=", viewerId)
.whereRef("PrivateUserNote.targetId", "=", "User.id"),
);
}
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). `WEST` region = Tentatek, otherwise Takoroka. */
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"),
sql<
"TENTATEK" | "TAKOROKA"
>`iif("XRankPlacement"."region" = 'WEST', 'TENTATEK', 'TAKOROKA')`.as(
"div",
),
])
.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 division; `points` is that division's value.
*/
function xpUnverifiedJson() {
return sql<{ points: number; div: "TENTATEK" | "TAKOROKA" } | null>`
iif(
"User"."unverifiedPeakXP" is null,
null,
json_object(
'points', "User"."unverifiedPeakXP" ->> '$.overall',
'div', iif("User"."unverifiedPeakXP" ->> '$.tentatek' is not null, 'TENTATEK', 'TAKOROKA')
)
)
`;
}
function enrichUserCardData(
cardData: RawUserCardData,
{
seasonSkill,
seasonTop,
}: { seasonSkill: TieredSkill | undefined; seasonTop: number | null },
): UserCardData {
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,
// TODO: derive from LFG free agent posts
isFreeAgent: false,
privateNote: cardData.privateNote ?? { text: null, sentiment: "NEUTRAL" },
stats: userCardStats({
div: cardData.div,
plusTier: cardData.plusTier,
xpVerified: cardData.xpVerified,
xpUnverified: cardData.xpUnverified,
seasonSkill,
seasonTop,
}),
};
}
function enrichBanner(
banner: RawUserCardData["banner"],
): UserCardData["banner"] {
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; div: XPDivision } | null;
xpUnverified: { points: number; div: XPDivision } | null;
seasonSkill: TieredSkill | undefined;
seasonTop: number | null;
}): Array<UserCardStat> {
const stats: Array<UserCardStat> = [];
const xpValues: Array<UserCardStatXPValue> = [];
if (xpUnverified) {
xpValues.push({
isVerified: false,
div: xpUnverified.div,
points: xpUnverified.points,
});
}
if (xpVerified) {
xpValues.push({
isVerified: true,
div: xpVerified.div,
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

@@ -39,11 +39,18 @@
gap: var(--s-1);
}
.freeAgentBadge {
position: absolute;
top: var(--s-2);
left: var(--s-2);
}
.identity {
display: flex;
align-items: flex-end;
gap: var(--s-2-5);
margin-top: calc(-1 * (var(--s-6) + var(--s-1)));
position: relative;
}
.avatar {
@@ -54,13 +61,20 @@
.nameGroup {
display: flex;
flex-direction: column;
padding-bottom: var(--s-1);
padding-bottom: var(--s-6);
position: absolute;
top: 18px;
left: 92px;
}
.username {
font-size: var(--font-lg);
font-weight: var(--weight-bold);
line-height: 1.1;
max-width: 180px;
text-overflow: ellipsis;
white-space: nowrap;
overflow: hidden;
}
.subtitle {
@@ -134,7 +148,7 @@
.seasonTop {
position: absolute;
bottom: calc(-1 * var(--s-2));
bottom: calc(-1 * var(--s-1));
left: 50%;
transform: translateX(-50%);
display: inline-flex;
@@ -154,6 +168,19 @@
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-high);

View File

@@ -1,8 +1,9 @@
import clsx from "clsx";
import { BadgeCheck, NotebookPen, UserPlus } from "lucide-react";
import { BadgeCheck, Megaphone, NotebookPen, UserPlus } from "lucide-react";
import * as React from "react";
import { Popover } from "react-aria-components";
import { useTranslation } from "react-i18next";
import { useFetcher, useMatches } from "react-router";
import { Avatar } from "~/components/Avatar";
import { LinkButton, SendouButton } from "~/components/elements/Button";
import { Image, TierImage } from "~/components/Image";
@@ -12,12 +13,16 @@ import type { BrandId } from "~/modules/in-game-lists/types";
import { assertUnreachable } from "~/utils/types";
import {
brandImageUrl,
LFG_PAGE,
navIconUrl,
stageBannerImageUrl,
userCardFriendshipPage,
userPage,
} from "~/utils/urls";
import type { UserCardFriendshipLoaderData } from "../routes/user-card.$id.friendship";
import type {
UserCardData,
UserCardFriendship,
UserCardStat,
XPDivision,
} from "../user-card-types";
@@ -38,20 +43,49 @@ const STAT_ORDER: Record<UserCardStat["type"], number> = {
};
/**
* xxx: docs here
* Hover/focus wrapper that opens 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 popover.
*
* Viewer-relative friendship data (`isFriend` + `mutualFriends`) is lazy-loaded from the
* `/user-card/:id/friendship` route the first time the card opens.
*/
export function UserCard({
data,
userId,
data: dataProp,
children,
}: {
data: UserCardData;
userId?: number;
data?: UserCardData;
// xxx: should this be a button or not?
children: React.ReactNode;
}) {
const lookedUpData = useUserCardData(userId);
const data = dataProp ?? lookedUpData;
const triggerRef = React.useRef<HTMLSpanElement>(null);
const popoverRef = React.useRef<HTMLElement>(null);
const openTimeout = React.useRef<ReturnType<typeof setTimeout>>(undefined);
const closeTimeout = React.useRef<ReturnType<typeof setTimeout>>(undefined);
const lastPointerType =
React.useRef<React.PointerEvent["pointerType"]>("mouse");
const [isOpen, setIsOpen] = React.useState(false);
const [openedByTouch, setOpenedByTouch] = React.useState(false);
const fetcher = useFetcher<UserCardFriendshipLoaderData>();
const friendshipLoadedRef = React.useRef(false);
React.useEffect(() => {
if (!isOpen) return;
if (friendshipLoadedRef.current) return;
if (typeof data?.id !== "number") return;
friendshipLoadedRef.current = true;
fetcher.load(userCardFriendshipPage(data.id));
}, [isOpen, data?.id, fetcher.load]);
const friendship = fetcher.data;
// xxx: probably not the play
React.useEffect(
@@ -62,6 +96,24 @@ export function UserCard({
[],
);
// a non-modal popover does not close on interact outside; for touch-opened cards we close it
// ourselves so the page stays interactive without making the popover modal (which would steal focus)
React.useEffect(() => {
if (!isOpen || !openedByTouch) return;
const onPointerDownOutside = (event: PointerEvent) => {
const target = event.target as Node;
if (triggerRef.current?.contains(target)) return;
if (popoverRef.current?.contains(target)) return;
setIsOpen(false);
setOpenedByTouch(false);
};
document.addEventListener("pointerdown", onPointerDownOutside);
return () =>
document.removeEventListener("pointerdown", onPointerDownOutside);
}, [isOpen, openedByTouch]);
const scheduleOpen = () => {
clearTimeout(closeTimeout.current);
openTimeout.current = setTimeout(
@@ -90,14 +142,30 @@ export function UserCard({
scheduleClose();
};
const onPointerDown = (event: React.PointerEvent) => {
lastPointerType.current = event.pointerType;
};
const onClick = (event: React.MouseEvent) => {
if (lastPointerType.current === "mouse") return;
// on touch/pen open the card instead of activating the child (e.g. following a link)
event.preventDefault();
setOpenedByTouch(true);
setIsOpen((prev) => !prev);
};
if (!data) return <>{children}</>;
return (
<>
{/* biome-ignore lint/a11y/noStaticElementInteractions: hover/focus wrapper delegating to the interactive child trigger; the card opens on hover/focus */}
{/* biome-ignore lint/a11y/noStaticElementInteractions: hover/focus/tap wrapper delegating to the interactive child trigger; the card opens on hover/focus (mouse) or tap (touch) */}
<span
ref={triggerRef}
className={styles.triggerWrapper}
onPointerEnter={onPointerEnter}
onPointerLeave={onPointerLeave}
onPointerDown={onPointerDown}
onClick={onClick}
onFocus={() => setIsOpen(true)}
onBlur={(event) => {
if (!event.currentTarget.contains(event.relatedTarget)) {
@@ -108,15 +176,20 @@ export function UserCard({
{children}
</span>
<Popover
ref={popoverRef}
triggerRef={triggerRef}
isOpen={isOpen}
onOpenChange={setIsOpen}
onOpenChange={(open) => {
setIsOpen(open);
if (!open) setOpenedByTouch(false);
}}
isNonModal
placement="bottom"
className={styles.popover}
>
<CardContent
data={data}
friendship={friendship}
onPointerEnter={cancelClose}
onPointerLeave={onPointerLeave}
/>
@@ -125,12 +198,36 @@ export function UserCard({
);
}
/**
* 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.
*/
function useUserCardData(userId: number | undefined): UserCardData | undefined {
const matches = useMatches();
if (typeof userId !== "number") return undefined;
for (const match of matches) {
const data = match.data as
| { userCards?: Map<number, UserCardData> }
| undefined;
const card = data?.userCards?.get(userId);
if (card) return card;
}
return undefined;
}
function CardContent({
data,
friendship,
onPointerEnter,
onPointerLeave,
}: {
data: UserCardData;
/** Lazy-loaded; `undefined` while the friendship fetch is in flight. */
friendship: UserCardFriendship | undefined;
onPointerEnter: () => void;
onPointerLeave: (event: React.PointerEvent) => void;
}) {
@@ -148,8 +245,19 @@ function CardContent({
onPointerLeave={onPointerLeave}
>
<Banner banner={data.banner} />
{data.isFreeAgent ? (
<LinkButton
// xxx: make it scroll to the fa post
to={LFG_PAGE}
size="miniscule"
icon={<Megaphone />}
className={styles.freeAgentBadge}
>
{t("user:card.freeAgent")}
</LinkButton>
) : null}
<div className={styles.iconButtons}>
{!data.isFriend ? (
{friendship && !friendship.isFriend ? (
<SendouButton
size="miniscule"
shape="circle"
@@ -168,9 +276,11 @@ function CardContent({
<Avatar user={data} size="md" className={styles.avatar} />
<div className={styles.nameGroup}>
<h2 className={styles.username}>{data.username}</h2>
<Subtitle data={data} />
{data.customUrl ? (
<div className={styles.subtitle}>{data.customUrl}</div>
) : null}
{data.friendCode ? (
<span className={styles.friendCode}>{data.friendCode}</span>
<span className={styles.friendCode}>SW-{data.friendCode}</span>
) : (
/** reserve space */
<span className={styles.friendCode}>{"\u200b"}</span>
@@ -187,7 +297,7 @@ function CardContent({
))}
</div>
) : null}
<MutualFriends mutualFriends={data.mutualFriends} />
<CardMutualFriends friendship={friendship} />
{data.shortBio ? <p className={styles.bio}>{data.shortBio}</p> : null}
<LinkButton
to={userPage(data)}
@@ -201,6 +311,31 @@ function CardContent({
);
}
/**
* 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} />
)}
</div>
);
}
function Banner({ banner }: { banner: UserCardData["banner"] }) {
const style = (() => {
switch (banner.type) {
@@ -220,27 +355,6 @@ function Banner({ banner }: { banner: UserCardData["banner"] }) {
return <div className={styles.banner} style={style} />;
}
function Subtitle({ data }: { data: UserCardData }) {
const parts: Array<string> = [];
if (data.customUrl) {
parts.push(data.customUrl);
}
if (parts.length === 0) return null;
return (
<div className={styles.subtitle}>
{parts.map((part, i) => (
<span key={part} className="stack horizontal xs items-center">
{i > 0 ? <span>·</span> : null}
{part}
</span>
))}
</div>
);
}
function Stat({ stat }: { stat: UserCardData["stats"][number] }) {
const { t } = useTranslation(["user"]);
@@ -275,7 +389,7 @@ function Stat({ stat }: { stat: UserCardData["stats"][number] }) {
);
}
case "DIV":
return <span className={styles.stat}>{stat.value}</span>;
return <span className={styles.stat}>Div {stat.value}</span>;
case "PLUS":
return (
<span className={clsx(styles.stat, styles.plusStat)}>

View File

@@ -0,0 +1,36 @@
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.
*/
export const loader = async ({
params,
}: LoaderFunctionArgs): Promise<UserCardFriendship> => {
const viewer = getUser();
const targetUserId = Number(params.id);
if (!viewer || Number.isNaN(targetUserId)) {
return { isFriend: false, mutualFriends: [] };
}
const [friendship, mutualFriends] = await Promise.all([
FriendRepository.findFriendship({
userOneId: viewer.id,
userTwoId: targetUserId,
}),
FriendRepository.findMutualFriends({
loggedInUserId: viewer.id,
targetUserId,
}),
]);
return { isFriend: Boolean(friendship), mutualFriends };
};

View File

@@ -8,12 +8,21 @@ export interface UserCardData extends CommonUser {
shortBio: string | null;
customTheme: CustomTheme | null;
friendCode: string | null;
isFriend: boolean;
mutualFriends: Array<CommonUser>;
isFreeAgent: boolean;
privateNote: Pick<Tables["PrivateUserNote"], "text" | "sentiment">;
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;
mutualFriends: Array<CommonUser>;
}
type UserCarBannerData =
| {
type: "URL";
@@ -50,7 +59,7 @@ export type UserCardStat =
// xxx: should live in tables.ts or something?
export type XPDivision = "TENTATEK" | "TAKOROKA";
interface UserCardStatXPValue {
export interface UserCardStatXPValue {
isVerified: boolean;
div: XPDivision;
points: number;

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

@@ -53,6 +53,11 @@ export default [
route("/friends", "features/friends/routes/friends.tsx"),
route(
"/user-card/:id/friendship",
"features/user-card/routes/user-card.$id.friendship.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

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

@@ -147,6 +147,9 @@ 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) =>
`/user-card/${userId}/friendship`;
interface UserLinkArgs {
discordId: Tables["User"]["discordId"];
customUrl?: Tables["User"]["customUrl"];

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

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

@@ -206,5 +206,7 @@
"card.viewUserPage": "",
"card.sendFriendRequest": "",
"card.editPrivateNote": "",
"card.xp": ""
"card.xp": "",
"card.freeAgent": "",
"card.noMutualFriends": ""
}

View File

@@ -206,5 +206,7 @@
"card.viewUserPage": "",
"card.sendFriendRequest": "",
"card.editPrivateNote": "",
"card.xp": ""
"card.xp": "",
"card.freeAgent": "",
"card.noMutualFriends": ""
}

View File

@@ -206,5 +206,7 @@
"card.viewUserPage": "View user page",
"card.sendFriendRequest": "Send friend request",
"card.editPrivateNote": "Edit private note",
"card.xp": "XP"
"card.xp": "XP",
"card.freeAgent": "FA",
"card.noMutualFriends": "No mutual friends"
}

View File

@@ -207,5 +207,7 @@
"card.viewUserPage": "",
"card.sendFriendRequest": "",
"card.editPrivateNote": "",
"card.xp": ""
"card.xp": "",
"card.freeAgent": "",
"card.noMutualFriends": ""
}

View File

@@ -207,5 +207,7 @@
"card.viewUserPage": "",
"card.sendFriendRequest": "",
"card.editPrivateNote": "",
"card.xp": ""
"card.xp": "",
"card.freeAgent": "",
"card.noMutualFriends": ""
}

View File

@@ -207,5 +207,7 @@
"card.viewUserPage": "",
"card.sendFriendRequest": "",
"card.editPrivateNote": "",
"card.xp": ""
"card.xp": "",
"card.freeAgent": "",
"card.noMutualFriends": ""
}

View File

@@ -207,5 +207,7 @@
"card.viewUserPage": "",
"card.sendFriendRequest": "",
"card.editPrivateNote": "",
"card.xp": ""
"card.xp": "",
"card.freeAgent": "",
"card.noMutualFriends": ""
}

View File

@@ -207,5 +207,7 @@
"card.viewUserPage": "",
"card.sendFriendRequest": "",
"card.editPrivateNote": "",
"card.xp": ""
"card.xp": "",
"card.freeAgent": "",
"card.noMutualFriends": ""
}

View File

@@ -207,5 +207,7 @@
"card.viewUserPage": "",
"card.sendFriendRequest": "",
"card.editPrivateNote": "",
"card.xp": ""
"card.xp": "",
"card.freeAgent": "",
"card.noMutualFriends": ""
}

View File

@@ -204,5 +204,7 @@
"card.viewUserPage": "",
"card.sendFriendRequest": "",
"card.editPrivateNote": "",
"card.xp": ""
"card.xp": "",
"card.freeAgent": "",
"card.noMutualFriends": ""
}

View File

@@ -204,5 +204,7 @@
"card.viewUserPage": "",
"card.sendFriendRequest": "",
"card.editPrivateNote": "",
"card.xp": ""
"card.xp": "",
"card.freeAgent": "",
"card.noMutualFriends": ""
}

View File

@@ -206,5 +206,7 @@
"card.viewUserPage": "",
"card.sendFriendRequest": "",
"card.editPrivateNote": "",
"card.xp": ""
"card.xp": "",
"card.freeAgent": "",
"card.noMutualFriends": ""
}

View File

@@ -208,5 +208,7 @@
"card.viewUserPage": "",
"card.sendFriendRequest": "",
"card.editPrivateNote": "",
"card.xp": ""
"card.xp": "",
"card.freeAgent": "",
"card.noMutualFriends": ""
}

View File

@@ -207,5 +207,7 @@
"card.viewUserPage": "",
"card.sendFriendRequest": "",
"card.editPrivateNote": "",
"card.xp": ""
"card.xp": "",
"card.freeAgent": "",
"card.noMutualFriends": ""
}

View File

@@ -208,5 +208,7 @@
"card.viewUserPage": "",
"card.sendFriendRequest": "",
"card.editPrivateNote": "",
"card.xp": ""
"card.xp": "",
"card.freeAgent": "",
"card.noMutualFriends": ""
}

View File

@@ -123,7 +123,6 @@
"actions.addSub": "添加替补",
"actions.shareLink": "分享您的邀请链接以添加成员: {{inviteLink}}",
"actions.sub.prompt_other": "您仍可以向阵容中添加 {{count}} 名替补",
"actions.sub.prompt_one": "您仍可以向阵容中添加 {{count}} 名替补",
"actions.sub.prompt_zero": "您的阵容已满,无法添加更多替补",
"actions.finalize": "正在结束赛事",
"actions.finalize.button": "结束赛事",

View File

@@ -205,5 +205,7 @@
"card.viewUserPage": "",
"card.sendFriendRequest": "",
"card.editPrivateNote": "",
"card.xp": ""
"card.xp": "",
"card.freeAgent": "",
"card.noMutualFriends": ""
}

View File

@@ -0,0 +1,88 @@
export function up(db) {
db.pragma("foreign_keys = OFF");
db.transaction(() => {
db.prepare(`alter table "User" drop column "lastSubMessage"`).run();
db.prepare(/* sql */ `alter table "User" add "shortBio" text`).run();
db.prepare(/* sql */ `alter table "User" add "div" text`).run();
db.prepare(
/* sql */ `alter table "User" add "unverifiedPeakXP" text`,
).run();
// nullable: null means no explicit choice, the card derives a preset color from the user id
db.prepare(/* sql */ `alter table "User" add "bannerPresetImg" text`).run();
// backfill unverifiedPeakXP from the existing "peak-xp-unverified" profile widget
db.prepare(
/* sql */ `
update "User"
set "unverifiedPeakXP" = (
select json_object(
'overall', json_extract(uw."widget", '$.settings.peakXp'),
'tentatek', iif(json_extract(uw."widget", '$.settings.division') = 'tentatek', json_extract(uw."widget", '$.settings.peakXp'), null),
'takoroka', iif(json_extract(uw."widget", '$.settings.division') = 'takoroka', json_extract(uw."widget", '$.settings.peakXp'), null)
)
from "UserWidget" uw
where uw."userId" = "User"."id"
and json_extract(uw."widget", '$.id') = 'peak-xp-unverified'
limit 1
)
where exists (
select 1 from "UserWidget" uw
where uw."userId" = "User"."id"
and json_extract(uw."widget", '$.id') = 'peak-xp-unverified'
)
`,
).run();
// rebuild SplatoonPlayer to change peakXp from a scalar (real) into the
// denormalized PeakXP json shape. per-division peaks are resolved from XRankPlacement
// (region 'WEST' = tentatek, otherwise takoroka), matching refreshAllPeakXp.
db.prepare(
/* sql */ `
create table "SplatoonPlayer_new" (
"id" integer primary key,
"userId" integer unique,
"splId" text unique not null,
"peakXp" text,
foreign key ("userId") references "User"("id") on delete cascade
) strict
`,
).run();
db.prepare(
/* sql */ `
insert into "SplatoonPlayer_new" ("id", "userId", "splId", "peakXp")
select
"id",
"userId",
"splId",
iif("peakXp" is null, null, json_object(
'overall', "peakXp",
'takoroka', (
select max("XRankPlacement"."power") from "XRankPlacement"
where "XRankPlacement"."playerId" = "SplatoonPlayer"."id"
and "XRankPlacement"."region" <> 'WEST'
),
'tentatek', (
select max("XRankPlacement"."power") from "XRankPlacement"
where "XRankPlacement"."playerId" = "SplatoonPlayer"."id"
and "XRankPlacement"."region" = 'WEST'
)
))
from "SplatoonPlayer"
`,
).run();
db.prepare(/* sql */ `drop table "SplatoonPlayer"`).run();
db.prepare(
/* sql */ `alter table "SplatoonPlayer_new" rename to "SplatoonPlayer"`,
).run();
db.prepare(
/* sql */ `create index splatoon_player_user_id on "SplatoonPlayer"("userId")`,
).run();
db.pragma("foreign_key_check");
})();
db.pragma("foreign_keys = ON");
}