diff --git a/app/db/seed/index.ts b/app/db/seed/index.ts index 122a24638..da0d50160 100644 --- a/app/db/seed/index.ts +++ b/app/db/seed/index.ts @@ -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) { diff --git a/app/db/tables.ts b/app/db/tables.ts index 805efb357..406b3b01e 100644 --- a/app/db/tables.ts +++ b/app/db/tables.ts @@ -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; splId: string; userId: number | null; /** Players best XP across both divisions. Denormalized for performance. */ - peakXp: number | null; + peakXp: JSONColumnTypeNullable; } export interface TaggedArt { @@ -1091,7 +1100,10 @@ export interface User { /** 1 = permabanned, timestamp = ban active till then */ banned: Generated; bannedReason: string | null; + /** Shown on old user profile and Plus Voting */ bio: string | null; + /** Shown on user card */ + shortBio: string | null; commissionsOpen: Generated; commissionsOpenedAt: number | null; commissionText: string | null; @@ -1136,8 +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; + /** Div in the latest finished LUTI (e.g. "2" or "X"). Must have been in a team that did not drop and the user played at least one match (got result as well) */ + div: string | null; + /** Peak XP as indicated by the user. Should have either `takoroka` or `tentatek` key defined but not both. */ + unverifiedPeakXP: JSONColumnTypeNullable; } /** Represents User joined with PlusTier table */ diff --git a/app/features/api-public/routes/user.$identifier.ts b/app/features/api-public/routes/user.$identifier.ts index b382e1169..d7883e97c 100644 --- a/app/features/api-public/routes/user.$identifier.ts +++ b/app/features/api-public/routes/user.$identifier.ts @@ -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`"SplatoonPlayer"."peakXp" ->> '$.overall'`.as( + "peakXp", + ), jsonArrayFrom( eb .selectFrom("TeamMemberWithSecondary") diff --git a/app/features/badges/BadgeRepository.server.test.ts b/app/features/badges/BadgeRepository.server.test.ts index 0129447a8..16a7e73a9 100644 --- a/app/features/badges/BadgeRepository.server.test.ts +++ b/app/features/badges/BadgeRepository.server.test.ts @@ -65,7 +65,21 @@ async function insertSplatoonPlayer(args: { userId: number | null; peakXp: number | null; }) { - await db.insertInto("SplatoonPlayer").values(args).execute(); + await db + .insertInto("SplatoonPlayer") + .values({ + splId: args.splId, + userId: args.userId, + peakXp: + args.peakXp === null + ? null + : JSON.stringify({ + overall: args.peakXp, + tentatek: args.peakXp, + takoroka: null, + }), + }) + .execute(); } async function findBadgeByCode(code: string) { diff --git a/app/features/badges/BadgeRepository.server.ts b/app/features/badges/BadgeRepository.server.ts index c64f2accd..742dcc4b9 100644 --- a/app/features/badges/BadgeRepository.server.ts +++ b/app/features/badges/BadgeRepository.server.ts @@ -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`"SplatoonPlayer"."peakXp" ->> '$.overall'`.as( + "peakXp", + ), + ]) .where("userId", "is not", null) .where("peakXp", "is not", null) .$narrowType<{ userId: NotNull; peakXp: NotNull }>() diff --git a/app/features/components-showcase/routes/components.tsx b/app/features/components-showcase/routes/components.tsx index f5594b243..23b19929a 100644 --- a/app/features/components-showcase/routes/components.tsx +++ b/app/features/components-showcase/routes/components.tsx @@ -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; diff --git a/app/features/live-streams/LiveStreamRepository.server.ts b/app/features/live-streams/LiveStreamRepository.server.ts index bf52a5e82..d657c4efe 100644 --- a/app/features/live-streams/LiveStreamRepository.server.ts +++ b/app/features/live-streams/LiveStreamRepository.server.ts @@ -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`"SplatoonPlayer"."peakXp" ->> '$.overall'`, ">=", StreamRanking.minXpForStreamToBeShown(), ) .where("LiveStream.twitch", "is not", null) .select((eb) => [ ...commonUserSelect(eb), - "SplatoonPlayer.peakXp", + sql`"SplatoonPlayer"."peakXp" ->> '$.overall'`.as("peakXp"), "LiveStream.viewerCount", "LiveStream.thumbnailUrl", "LiveStream.twitch as twitchUsername", diff --git a/app/features/scrims/scrims-utils.test.ts b/app/features/scrims/scrims-utils.test.ts index 605c23233..eebc32475 100644 --- a/app/features/scrims/scrims-utils.test.ts +++ b/app/features/scrims/scrims-utils.test.ts @@ -3,9 +3,32 @@ import { MapPool } from "~/features/map-list-generator/core/map-pool"; import { formatFlexTimeDisplay, generateTimeOptions, + parseLutiDivFromName, parseMapPoolInput, } from "./scrims-utils"; +describe("parseLutiDivFromName", () => { + it("parses a numeric division", () => { + expect(parseLutiDivFromName("LUTI: Season 15 - Division 2")).toBe("2"); + }); + + it("parses division X", () => { + expect(parseLutiDivFromName("LUTI Season 15 Division X")).toBe("X"); + }); + + it("parses a two-digit division without matching a single digit", () => { + expect(parseLutiDivFromName("LUTI Season 15 Div 10")).toBe("10"); + }); + + it("returns null when no division token is present", () => { + expect(parseLutiDivFromName("Leagues Under The Ink Season 15")).toBeNull(); + }); + + it("returns null for an out-of-range division", () => { + expect(parseLutiDivFromName("LUTI Division 12")).toBeNull(); + }); +}); + describe("generateTimeOptions", () => { it("includes both start and end times", () => { const start = new Date("2025-01-15T14:15:00"); diff --git a/app/features/scrims/scrims-utils.ts b/app/features/scrims/scrims-utils.ts index 53ad090b9..6ad99fa7b 100644 --- a/app/features/scrims/scrims-utils.ts +++ b/app/features/scrims/scrims-utils.ts @@ -3,6 +3,7 @@ import * as R from "remeda"; import { MapPool } from "~/features/map-list-generator/core/map-pool"; import { databaseTimestampToDate } from "~/utils/dates"; import * as Scrim from "./core/Scrim"; +import { LUTI_DIVS } from "./scrims-constants"; import type { LutiDiv, ScrimPost } from "./scrims-types"; export const getPostRequestCensor = @@ -53,6 +54,20 @@ export const parseLutiDiv = (div: number): LutiDiv => { return String(div) as LutiDiv; }; +/** + * Extracts the LUTI division (e.g. `"X"`, `"2"`) from a tournament name such as + * "LUTI: Season 15 - Division 2". Returns `null` if no valid division token is found. + */ +export const parseLutiDivFromName = (name: string): LutiDiv | null => { + const match = name.match(/\bdiv(?:ision)?\.?\s*(X|11|10|[1-9])\b/i); + if (!match) return null; + + const token = match[1].toUpperCase(); + return (LUTI_DIVS as readonly string[]).includes(token) + ? (token as LutiDiv) + : null; +}; + export const serializeLutiDiv = (div: LutiDiv): number => { if (div === "X") return 0; diff --git a/app/features/sendouq/components/GroupCard.module.css b/app/features/sendouq/components/GroupCard.module.css index b99b81df4..fb3f705b7 100644 --- a/app/features/sendouq/components/GroupCard.module.css +++ b/app/features/sendouq/components/GroupCard.module.css @@ -40,22 +40,6 @@ color: var(--color-text); } -.avatar { - min-width: 36px; -} - -.avatarPositive { - outline: 2px solid var(--color-success-low); -} - -.avatarNeutral { - outline: 2px solid var(--color-warning-low); -} - -.avatarNegative { - outline: 2px solid var(--color-error-low); -} - .tier { margin-inline-start: auto; } diff --git a/app/features/sendouq/components/GroupCard.tsx b/app/features/sendouq/components/GroupCard.tsx index 8bea40c26..6de5ccd58 100644 --- a/app/features/sendouq/components/GroupCard.tsx +++ b/app/features/sendouq/components/GroupCard.tsx @@ -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({
- {showNote && member.privateNote ? ( - - - - } - > - {member.privateNote.text} -
+ + + + {member.inGameName ? ( + <> + + {t("user:ign.short")}: + {" "} + {inGameNameWithoutDiscriminator(member.inGameName)} + + ) : ( + member.username )} - > - - -
-
- ) : ( - - )} - - {member.inGameName ? ( - <> - - {t("user:ign.short")}: - {" "} - {inGameNameWithoutDiscriminator(member.inGameName)} - - ) : ( - member.username - )} - + + + {member.pronouns ? ( {member.pronouns.subject}/{member.pronouns.object} @@ -525,30 +477,6 @@ function AddPrivateNoteForm({ ); } -function DeletePrivateNoteForm({ - targetId, - name, -}: { - targetId: number; - name: string; -}) { - const { t } = useTranslation(["q"]); - - return ( - - - - - - ); -} - function GroupSkillDifference({ skillDifference, }: { diff --git a/app/features/sendouq/loaders/q.looking.server.ts b/app/features/sendouq/loaders/q.looking.server.ts index 5df1fc2d5..c2af3c4f2 100644 --- a/app/features/sendouq/loaders/q.looking.server.ts +++ b/app/features/sendouq/loaders/q.looking.server.ts @@ -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) diff --git a/app/features/sendouq/routes/q.looking.tsx b/app/features/sendouq/routes/q.looking.tsx index fde36a96a..183961a98 100644 --- a/app/features/sendouq/routes/q.looking.tsx +++ b/app/features/sendouq/routes/q.looking.tsx @@ -76,6 +76,8 @@ export default function QLookingShell() { return ; } +// 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() { {t("q:looking.columns.myGroup")} - + {data.ownGroup.inviteCode ? ( @@ -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} /> diff --git a/app/features/top-search/XRankPlacementRepository.server.test.ts b/app/features/top-search/XRankPlacementRepository.server.test.ts index 8e79380d9..0a600cb93 100644 --- a/app/features/top-search/XRankPlacementRepository.server.test.ts +++ b/app/features/top-search/XRankPlacementRepository.server.test.ts @@ -84,8 +84,37 @@ describe("refreshAllPeakXp", () => { .orderBy("id", "asc") .execute(); - expect(players[0].peakXp).toBe(2700); - expect(players[1].peakXp).toBe(3000); + expect(players[0].peakXp).toEqual({ + overall: 2700, + tentatek: 2700, + takoroka: null, + }); + expect(players[1].peakXp).toEqual({ + overall: 3000, + tentatek: 3000, + takoroka: null, + }); + }); + + test("splits peakXp by division (region)", async () => { + const playerId = await createSplatoonPlayer("player1"); + + await createXRankPlacement({ playerId, power: 2700, region: "WEST" }); + await createXRankPlacement({ playerId, power: 2900, region: "JPN" }); + + await XRankPlacementRepository.refreshAllPeakXp(); + + const player = await db + .selectFrom("SplatoonPlayer") + .select("peakXp") + .where("id", "=", playerId) + .executeTakeFirstOrThrow(); + + expect(player.peakXp).toEqual({ + overall: 2900, + tentatek: 2700, + takoroka: 2900, + }); }); test("sets peakXp to null for player with no placements", async () => { diff --git a/app/features/top-search/XRankPlacementRepository.server.ts b/app/features/top-search/XRankPlacementRepository.server.ts index c5d357a44..79f1b12b8 100644 --- a/app/features/top-search/XRankPlacementRepository.server.ts +++ b/app/features/top-search/XRankPlacementRepository.server.ts @@ -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`( + select iif( + max("XRankPlacement"."power") is null, + null, + json_object( + 'overall', max("XRankPlacement"."power"), + 'tentatek', max(iif("XRankPlacement"."region" = 'WEST', "XRankPlacement"."power", null)), + 'takoroka', max(iif("XRankPlacement"."region" != 'WEST', "XRankPlacement"."power", null)) + ) + ) + from "XRankPlacement" + where "XRankPlacement"."playerId" = "SplatoonPlayer"."id" + )`, + }) .execute(); } diff --git a/app/features/tournament/TournamentRepository.server.ts b/app/features/tournament/TournamentRepository.server.ts index f893a3698..3148e8e01 100644 --- a/app/features/tournament/TournamentRepository.server.ts +++ b/app/features/tournament/TournamentRepository.server.ts @@ -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 diff --git a/app/features/user-card/UserCardRepository.server.test.ts b/app/features/user-card/UserCardRepository.server.test.ts new file mode 100644 index 000000000..97db060ed --- /dev/null +++ b/app/features/user-card/UserCardRepository.server.test.ts @@ -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); + }); +}); diff --git a/app/features/user-card/UserCardRepository.server.ts b/app/features/user-card/UserCardRepository.server.ts new file mode 100644 index 000000000..3301d43b9 --- /dev/null +++ b/app/features/user-card/UserCardRepository.server.ts @@ -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; + viewerId: number | null; + /** Opt-in fields skipped from the query by default; defaults to `false` each. */ + include?: { friendCode?: boolean }; +}): Promise<{ userCards: Map }> { + 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 = + season !== null ? userSkills(season).userSkills : {}; + const seasonTopByUserId = + season !== null + ? new Map( + (await cachedFullUserLeaderboard(season)).map((entry) => [ + entry.id, + entry.placementRank, + ]), + ) + : new Map(); + + const userCards = new Map(); + 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, + { + 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`null`, + privateNote: privateNoteJson(eb, viewerId), + plusTier: plusTierScalar(eb), + xpVerified: xpVerifiedJson(eb), + xpUnverified: xpUnverifiedJson(), + }); +} + +type RawUserCardData = + ReturnType extends Expression + ? 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` + 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) { + return eb + .selectFrom("UserFriendCode") + .select("UserFriendCode.friendCode") + .whereRef("UserFriendCode.userId", "=", "User.id") + .orderBy("UserFriendCode.createdAt", "desc") + .limit(1) + .$asScalar(); +} + +function privateNoteJson( + eb: ExpressionBuilder, + viewerId: number | null, +) { + if (viewerId === null) { + return sql | null>`null`; + } + + return jsonObjectFrom( + eb + .selectFrom("PrivateUserNote") + .select(["PrivateUserNote.text", "PrivateUserNote.sentiment"]) + .where("PrivateUserNote.authorId", "=", viewerId) + .whereRef("PrivateUserNote.targetId", "=", "User.id"), + ); +} + +function plusTierScalar(eb: ExpressionBuilder) { + return eb + .selectFrom("PlusTier") + .select("PlusTier.tier") + .whereRef("PlusTier.userId", "=", "User.id") + .$asScalar(); +} + +/** Single highest X Rank power placement (verified XP). `WEST` region = Tentatek, otherwise Takoroka. */ +function xpVerifiedJson(eb: ExpressionBuilder) { + return jsonObjectFrom( + eb + .selectFrom("XRankPlacement") + .innerJoin( + "SplatoonPlayer", + "SplatoonPlayer.id", + "XRankPlacement.playerId", + ) + .whereRef("SplatoonPlayer.userId", "=", "User.id") + .select([ + sql`"XRankPlacement"."power"`.as("points"), + 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 { + const stats: Array = []; + + const xpValues: Array = []; + 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; +} diff --git a/app/features/user-card/components/UserCard.module.css b/app/features/user-card/components/UserCard.module.css index 477e490de..42943e795 100644 --- a/app/features/user-card/components/UserCard.module.css +++ b/app/features/user-card/components/UserCard.module.css @@ -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); diff --git a/app/features/user-card/components/UserCard.tsx b/app/features/user-card/components/UserCard.tsx index eb99401ba..242f60c01 100644 --- a/app/features/user-card/components/UserCard.tsx +++ b/app/features/user-card/components/UserCard.tsx @@ -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 = { }; /** - * 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(null); + const popoverRef = React.useRef(null); const openTimeout = React.useRef>(undefined); const closeTimeout = React.useRef>(undefined); + const lastPointerType = + React.useRef("mouse"); const [isOpen, setIsOpen] = React.useState(false); + const [openedByTouch, setOpenedByTouch] = React.useState(false); + + const fetcher = useFetcher(); + 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) */} setIsOpen(true)} onBlur={(event) => { if (!event.currentTarget.contains(event.relatedTarget)) { @@ -108,15 +176,20 @@ export function UserCard({ {children} { + setIsOpen(open); + if (!open) setOpenedByTouch(false); + }} isNonModal placement="bottom" className={styles.popover} > @@ -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 } + | 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} > + {data.isFreeAgent ? ( + } + className={styles.freeAgentBadge} + > + {t("user:card.freeAgent")} + + ) : null}
- {!data.isFriend ? ( + {friendship && !friendship.isFriend ? (

{data.username}

- + {data.customUrl ? ( +
{data.customUrl}
+ ) : null} {data.friendCode ? ( - {data.friendCode} + SW-{data.friendCode} ) : ( /** reserve space */ {"\u200b"} @@ -187,7 +297,7 @@ function CardContent({ ))}
) : null} - + {data.shortBio ?

{data.shortBio}

: null} + {friendship === undefined ? null : friendship.mutualFriends.length === + 0 ? ( + + {t("user:card.noMutualFriends")} + + ) : ( + + )} +
+ ); +} + function Banner({ banner }: { banner: UserCardData["banner"] }) { const style = (() => { switch (banner.type) { @@ -220,27 +355,6 @@ function Banner({ banner }: { banner: UserCardData["banner"] }) { return
; } -function Subtitle({ data }: { data: UserCardData }) { - const parts: Array = []; - - if (data.customUrl) { - parts.push(data.customUrl); - } - - if (parts.length === 0) return null; - - return ( -
- {parts.map((part, i) => ( - - {i > 0 ? · : null} - {part} - - ))} -
- ); -} - 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 {stat.value}; + return Div {stat.value}; case "PLUS": return ( diff --git a/app/features/user-card/routes/user-card.$id.friendship.ts b/app/features/user-card/routes/user-card.$id.friendship.ts new file mode 100644 index 000000000..79a2e1c07 --- /dev/null +++ b/app/features/user-card/routes/user-card.$id.friendship.ts @@ -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; + +/** + * 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 => { + 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 }; +}; diff --git a/app/features/user-card/user-card-types.ts b/app/features/user-card/user-card-types.ts index b59e1324b..3305df205 100644 --- a/app/features/user-card/user-card-types.ts +++ b/app/features/user-card/user-card-types.ts @@ -8,12 +8,21 @@ export interface UserCardData extends CommonUser { shortBio: string | null; customTheme: CustomTheme | null; friendCode: string | null; - isFriend: boolean; - mutualFriends: Array; + isFreeAgent: boolean; privateNote: Pick; stats: Array; } +/** + * 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; +} + 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; diff --git a/app/features/user-page/UserRepository.server.ts b/app/features/user-page/UserRepository.server.ts index a850da84c..c649eda36 100644 --- a/app/features/user-page/UserRepository.server.ts +++ b/app/features/user-page/UserRepository.server.ts @@ -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") diff --git a/app/routes.ts b/app/routes.ts index 0e3395c83..ea8340af3 100644 --- a/app/routes.ts +++ b/app/routes.ts @@ -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"), diff --git a/app/routines/computeLutiDivs.ts b/app/routines/computeLutiDivs.ts new file mode 100644 index 000000000..36f32eece --- /dev/null +++ b/app/routines/computeLutiDivs.ts @@ -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; +} diff --git a/app/routines/list.server.ts b/app/routines/list.server.ts index 7ea48a4b7..21943b366 100644 --- a/app/routines/list.server.ts +++ b/app/routines/list.server.ts @@ -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, ]; diff --git a/app/utils/kysely.server.ts b/app/utils/kysely.server.ts index e84dceae5..225342ebc 100644 --- a/app/utils/kysely.server.ts +++ b/app/utils/kysely.server.ts @@ -55,21 +55,24 @@ const userChatNameHueRaw = sql< export const userChatNameHue = userChatNameHueRaw.as("chatNameHue"); -export function commonUserJsonObject(eb: ExpressionBuilder) { - 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) { + 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(), - }); + customAvatarUrl: customAvatarUrl(eb), + }; +} + +export function commonUserJsonObject(eb: ExpressionBuilder) { + return jsonBuildObject(commonUserObjectFields(eb)); } const USER_SUBMITTED_IMAGE_ROOT = diff --git a/app/utils/urls.ts b/app/utils/urls.ts index 872cec882..874322d6a 100644 --- a/app/utils/urls.ts +++ b/app/utils/urls.ts @@ -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"]; diff --git a/db-test.sqlite3 b/db-test.sqlite3 index 418efb00b..07cc0e632 100644 Binary files a/db-test.sqlite3 and b/db-test.sqlite3 differ diff --git a/e2e/seeds/db-seed-AB_RR.sqlite3 b/e2e/seeds/db-seed-AB_RR.sqlite3 index b9a83bf95..837eb7a48 100644 Binary files a/e2e/seeds/db-seed-AB_RR.sqlite3 and b/e2e/seeds/db-seed-AB_RR.sqlite3 differ diff --git a/e2e/seeds/db-seed-DEFAULT.sqlite3 b/e2e/seeds/db-seed-DEFAULT.sqlite3 index 3548178d8..1f2836960 100644 Binary files a/e2e/seeds/db-seed-DEFAULT.sqlite3 and b/e2e/seeds/db-seed-DEFAULT.sqlite3 differ diff --git a/e2e/seeds/db-seed-FINALIZED_BRACKET.sqlite3 b/e2e/seeds/db-seed-FINALIZED_BRACKET.sqlite3 index 293218a1e..9458745ba 100644 Binary files a/e2e/seeds/db-seed-FINALIZED_BRACKET.sqlite3 and b/e2e/seeds/db-seed-FINALIZED_BRACKET.sqlite3 differ diff --git a/e2e/seeds/db-seed-IN_SQ_MATCH.sqlite3 b/e2e/seeds/db-seed-IN_SQ_MATCH.sqlite3 index 754722b81..ff28584e5 100644 Binary files a/e2e/seeds/db-seed-IN_SQ_MATCH.sqlite3 and b/e2e/seeds/db-seed-IN_SQ_MATCH.sqlite3 differ diff --git a/e2e/seeds/db-seed-NO_SCRIMS.sqlite3 b/e2e/seeds/db-seed-NO_SCRIMS.sqlite3 index 1a2cdfc4f..cb556bc7b 100644 Binary files a/e2e/seeds/db-seed-NO_SCRIMS.sqlite3 and b/e2e/seeds/db-seed-NO_SCRIMS.sqlite3 differ diff --git a/e2e/seeds/db-seed-NO_SQ_GROUPS.sqlite3 b/e2e/seeds/db-seed-NO_SQ_GROUPS.sqlite3 index fb604254f..e56cc9491 100644 Binary files a/e2e/seeds/db-seed-NO_SQ_GROUPS.sqlite3 and b/e2e/seeds/db-seed-NO_SQ_GROUPS.sqlite3 differ diff --git a/e2e/seeds/db-seed-NO_TOURNAMENT_TEAMS.sqlite3 b/e2e/seeds/db-seed-NO_TOURNAMENT_TEAMS.sqlite3 index 8ee2b9734..f1ee19da7 100644 Binary files a/e2e/seeds/db-seed-NO_TOURNAMENT_TEAMS.sqlite3 and b/e2e/seeds/db-seed-NO_TOURNAMENT_TEAMS.sqlite3 differ diff --git a/e2e/seeds/db-seed-NZAP_IN_TEAM.sqlite3 b/e2e/seeds/db-seed-NZAP_IN_TEAM.sqlite3 index 622b5ab61..d847ca1e3 100644 Binary files a/e2e/seeds/db-seed-NZAP_IN_TEAM.sqlite3 and b/e2e/seeds/db-seed-NZAP_IN_TEAM.sqlite3 differ diff --git a/e2e/seeds/db-seed-REG_OPEN.sqlite3 b/e2e/seeds/db-seed-REG_OPEN.sqlite3 index e416d258a..4e33798a6 100644 Binary files a/e2e/seeds/db-seed-REG_OPEN.sqlite3 and b/e2e/seeds/db-seed-REG_OPEN.sqlite3 differ diff --git a/e2e/seeds/db-seed-SMALL_SOS.sqlite3 b/e2e/seeds/db-seed-SMALL_SOS.sqlite3 index e98a2cb1d..53d25ea1b 100644 Binary files a/e2e/seeds/db-seed-SMALL_SOS.sqlite3 and b/e2e/seeds/db-seed-SMALL_SOS.sqlite3 differ diff --git a/e2e/seeds/db-seed-TEAM_MAP_PREFS.sqlite3 b/e2e/seeds/db-seed-TEAM_MAP_PREFS.sqlite3 index ab0c9c3ad..6b861441c 100644 Binary files a/e2e/seeds/db-seed-TEAM_MAP_PREFS.sqlite3 and b/e2e/seeds/db-seed-TEAM_MAP_PREFS.sqlite3 differ diff --git a/knip.ts b/knip.ts index fe2d29a9e..73573da97 100644 --- a/knip.ts +++ b/knip.ts @@ -4,7 +4,6 @@ const config = { type: true, }, tags: ["-lintignore"], - ignore: ["scripts/dicts/**"], entry: [ "app/features/*/routes/**/*.{ts,tsx}", "migrations/**/*.js", diff --git a/locales/da/user.json b/locales/da/user.json index 0353354dc..fa7ae4e1b 100644 --- a/locales/da/user.json +++ b/locales/da/user.json @@ -206,5 +206,7 @@ "card.viewUserPage": "", "card.sendFriendRequest": "", "card.editPrivateNote": "", - "card.xp": "" + "card.xp": "", + "card.freeAgent": "", + "card.noMutualFriends": "" } diff --git a/locales/de/user.json b/locales/de/user.json index 07a9fc21b..fae1a916f 100644 --- a/locales/de/user.json +++ b/locales/de/user.json @@ -206,5 +206,7 @@ "card.viewUserPage": "", "card.sendFriendRequest": "", "card.editPrivateNote": "", - "card.xp": "" + "card.xp": "", + "card.freeAgent": "", + "card.noMutualFriends": "" } diff --git a/locales/en/user.json b/locales/en/user.json index f2ae326b1..94f8ed43b 100644 --- a/locales/en/user.json +++ b/locales/en/user.json @@ -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" } diff --git a/locales/es-ES/user.json b/locales/es-ES/user.json index 6340c8fd9..015b6ff6d 100644 --- a/locales/es-ES/user.json +++ b/locales/es-ES/user.json @@ -207,5 +207,7 @@ "card.viewUserPage": "", "card.sendFriendRequest": "", "card.editPrivateNote": "", - "card.xp": "" + "card.xp": "", + "card.freeAgent": "", + "card.noMutualFriends": "" } diff --git a/locales/es-US/user.json b/locales/es-US/user.json index 82c09ff0e..1d4daf447 100644 --- a/locales/es-US/user.json +++ b/locales/es-US/user.json @@ -207,5 +207,7 @@ "card.viewUserPage": "", "card.sendFriendRequest": "", "card.editPrivateNote": "", - "card.xp": "" + "card.xp": "", + "card.freeAgent": "", + "card.noMutualFriends": "" } diff --git a/locales/fr-CA/user.json b/locales/fr-CA/user.json index 12ec11839..7f2c3ba38 100644 --- a/locales/fr-CA/user.json +++ b/locales/fr-CA/user.json @@ -207,5 +207,7 @@ "card.viewUserPage": "", "card.sendFriendRequest": "", "card.editPrivateNote": "", - "card.xp": "" + "card.xp": "", + "card.freeAgent": "", + "card.noMutualFriends": "" } diff --git a/locales/fr-EU/user.json b/locales/fr-EU/user.json index 84c4f60da..f909aeefb 100644 --- a/locales/fr-EU/user.json +++ b/locales/fr-EU/user.json @@ -207,5 +207,7 @@ "card.viewUserPage": "", "card.sendFriendRequest": "", "card.editPrivateNote": "", - "card.xp": "" + "card.xp": "", + "card.freeAgent": "", + "card.noMutualFriends": "" } diff --git a/locales/he/user.json b/locales/he/user.json index 2c5ab48cc..a87ffd21c 100644 --- a/locales/he/user.json +++ b/locales/he/user.json @@ -207,5 +207,7 @@ "card.viewUserPage": "", "card.sendFriendRequest": "", "card.editPrivateNote": "", - "card.xp": "" + "card.xp": "", + "card.freeAgent": "", + "card.noMutualFriends": "" } diff --git a/locales/it/user.json b/locales/it/user.json index 326e80fed..e74e1c8ef 100644 --- a/locales/it/user.json +++ b/locales/it/user.json @@ -207,5 +207,7 @@ "card.viewUserPage": "", "card.sendFriendRequest": "", "card.editPrivateNote": "", - "card.xp": "" + "card.xp": "", + "card.freeAgent": "", + "card.noMutualFriends": "" } diff --git a/locales/ja/user.json b/locales/ja/user.json index 45d416ff0..cf23e52a5 100644 --- a/locales/ja/user.json +++ b/locales/ja/user.json @@ -204,5 +204,7 @@ "card.viewUserPage": "", "card.sendFriendRequest": "", "card.editPrivateNote": "", - "card.xp": "" + "card.xp": "", + "card.freeAgent": "", + "card.noMutualFriends": "" } diff --git a/locales/ko/user.json b/locales/ko/user.json index b4b000d9e..c20ac98e4 100644 --- a/locales/ko/user.json +++ b/locales/ko/user.json @@ -204,5 +204,7 @@ "card.viewUserPage": "", "card.sendFriendRequest": "", "card.editPrivateNote": "", - "card.xp": "" + "card.xp": "", + "card.freeAgent": "", + "card.noMutualFriends": "" } diff --git a/locales/nl/user.json b/locales/nl/user.json index ca2b7215a..70776ceeb 100644 --- a/locales/nl/user.json +++ b/locales/nl/user.json @@ -206,5 +206,7 @@ "card.viewUserPage": "", "card.sendFriendRequest": "", "card.editPrivateNote": "", - "card.xp": "" + "card.xp": "", + "card.freeAgent": "", + "card.noMutualFriends": "" } diff --git a/locales/pl/user.json b/locales/pl/user.json index 175cb70c5..cfba7aae3 100644 --- a/locales/pl/user.json +++ b/locales/pl/user.json @@ -208,5 +208,7 @@ "card.viewUserPage": "", "card.sendFriendRequest": "", "card.editPrivateNote": "", - "card.xp": "" + "card.xp": "", + "card.freeAgent": "", + "card.noMutualFriends": "" } diff --git a/locales/pt-BR/user.json b/locales/pt-BR/user.json index e4c3f8a6a..1161bfd57 100644 --- a/locales/pt-BR/user.json +++ b/locales/pt-BR/user.json @@ -207,5 +207,7 @@ "card.viewUserPage": "", "card.sendFriendRequest": "", "card.editPrivateNote": "", - "card.xp": "" + "card.xp": "", + "card.freeAgent": "", + "card.noMutualFriends": "" } diff --git a/locales/ru/user.json b/locales/ru/user.json index 8d45cece0..155750a76 100644 --- a/locales/ru/user.json +++ b/locales/ru/user.json @@ -208,5 +208,7 @@ "card.viewUserPage": "", "card.sendFriendRequest": "", "card.editPrivateNote": "", - "card.xp": "" + "card.xp": "", + "card.freeAgent": "", + "card.noMutualFriends": "" } diff --git a/locales/zh/tournament.json b/locales/zh/tournament.json index 338bbbb2c..583385673 100644 --- a/locales/zh/tournament.json +++ b/locales/zh/tournament.json @@ -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": "结束赛事", diff --git a/locales/zh/user.json b/locales/zh/user.json index 62df38b64..a07ff8286 100644 --- a/locales/zh/user.json +++ b/locales/zh/user.json @@ -205,5 +205,7 @@ "card.viewUserPage": "", "card.sendFriendRequest": "", "card.editPrivateNote": "", - "card.xp": "" + "card.xp": "", + "card.freeAgent": "", + "card.noMutualFriends": "" } diff --git a/migrations/154-user-card-fields.js b/migrations/154-user-card-fields.js new file mode 100644 index 000000000..9cfc01370 --- /dev/null +++ b/migrations/154-user-card-fields.js @@ -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"); +}