@@ -524,30 +458,6 @@ function AddPrivateNoteForm({
);
}
-function DeletePrivateNoteForm({
- targetId,
- name,
-}: {
- targetId: number;
- name: string;
-}) {
- const { t } = useTranslation(["q"]);
-
- return (
-
-
-
-
-
- );
-}
-
function GroupSkillDifference({
skillDifference,
}: {
diff --git a/app/features/sendouq/core/SendouQ.server.test.ts b/app/features/sendouq/core/SendouQ.server.test.ts
index 40224a8be..8762a9c43 100644
--- a/app/features/sendouq/core/SendouQ.server.test.ts
+++ b/app/features/sendouq/core/SendouQ.server.test.ts
@@ -1,10 +1,8 @@
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
import { db } from "~/db/sql";
import { refreshUserSkills } from "~/features/mmr/tiered.server";
-import * as PrivateUserNoteRepository from "~/features/sendouq/PrivateUserNoteRepository.server";
-import * as UserRepository from "~/features/user-page/UserRepository.server";
import { databaseTimestampNow } from "~/utils/dates";
-import { dbInsertUsers, dbReset, withUser } from "~/utils/Test";
+import { dbInsertUsers, dbReset } from "~/utils/Test";
import * as SQGroupRepository from "../SQGroupRepository.server";
import { refreshSendouQInstance, SendouQ } from "./SendouQ.server";
@@ -69,29 +67,6 @@ const createMatch = async (
.execute();
};
-const ownNotesOf = async (authorId: number, targetUserIds?: number[]) => {
- const user = await UserRepository.findLeanById(authorId);
- return withUser(user!, () =>
- PrivateUserNoteRepository.ownNotes(targetUserIds),
- );
-};
-
-const createPrivateNote = async (
- authorId: number,
- targetId: number,
- sentiment: "POSITIVE" | "NEUTRAL" | "NEGATIVE",
- text = "test note",
-) => {
- const user = await UserRepository.findLeanById(authorId);
- await withUser(user!, () =>
- PrivateUserNoteRepository.upsertOwnNote({
- targetId,
- sentiment,
- text,
- }),
- );
-};
-
const insertSkill = async (userId: number, ordinal: number, season = 1) => {
await db
.insertInto("Skill")
@@ -273,8 +248,7 @@ describe("SendouQ", () => {
test("returns empty array when no groups exist", async () => {
await refreshSendouQInstance();
- const notes = await ownNotesOf(1);
- const groups = SendouQ.previewGroups(1, notes);
+ const groups = SendouQ.previewGroups(1);
expect(groups).toEqual([]);
});
@@ -283,8 +257,7 @@ describe("SendouQ", () => {
await createGroup([1, 2, 3, 4]);
await refreshSendouQInstance();
- const notes = await ownNotesOf(1);
- const groups = SendouQ.previewGroups(1, notes);
+ const groups = SendouQ.previewGroups(1);
expect(groups).toHaveLength(1);
expect(groups[0].members).toBeUndefined();
@@ -294,35 +267,19 @@ describe("SendouQ", () => {
await createGroup([1, 2]);
await refreshSendouQInstance();
- const notes = await ownNotesOf(1);
- const groups = SendouQ.previewGroups(1, notes);
+ const groups = SendouQ.previewGroups(1);
expect(groups).toHaveLength(1);
expect(groups[0].members).toBeDefined();
expect(groups[0].members).toHaveLength(2);
});
- test("attaches private notes to members", async () => {
- await createGroup([1, 2]);
- await createPrivateNote(3, 2, "POSITIVE", "Great player");
- await refreshSendouQInstance();
-
- const notes = await ownNotesOf(3);
- const groups = SendouQ.previewGroups(3, notes);
-
- expect(groups).toHaveLength(1);
- const member = groups[0].members?.find((m) => m.id === 2);
- expect(member?.privateNote).toBeDefined();
- expect(member?.privateNote?.sentiment).toBe("POSITIVE");
- });
-
test("removes inviteCode and chatCode from all groups", async () => {
await createGroup([1, 2], { inviteCode: "CODE1" });
await createGroup([3, 4, 5, 6], { inviteCode: "CODE2" });
await refreshSendouQInstance();
- const notes = await ownNotesOf(1);
- const groups = SendouQ.previewGroups(1, notes);
+ const groups = SendouQ.previewGroups(1);
expect(groups).toHaveLength(2);
for (const group of groups) {
@@ -337,8 +294,7 @@ describe("SendouQ", () => {
await createGroup([7, 8, 9]);
await refreshSendouQInstance();
- const notes = await ownNotesOf(1);
- const groups = SendouQ.previewGroups(1, notes);
+ const groups = SendouQ.previewGroups(1);
expect(groups).toHaveLength(3);
@@ -354,8 +310,7 @@ describe("SendouQ", () => {
await createGroup([5, 6]);
await refreshSendouQInstance();
- const notes = await ownNotesOf(1);
- const groups = SendouQ.previewGroups(1, notes);
+ const groups = SendouQ.previewGroups(1);
const fullGroup = groups.find((g) => g.members === undefined);
const partialGroup = groups.find((g) => g.members !== undefined);
@@ -399,8 +354,7 @@ describe("SendouQ", () => {
.execute();
await refreshSendouQInstance();
- const notes = await ownNotesOf(1);
- const groups = SendouQ.previewGroups(1, notes);
+ const groups = SendouQ.previewGroups(1);
expect(groups).toHaveLength(2);
expect(groups[0].id).toBe(group1Id);
@@ -427,8 +381,7 @@ describe("SendouQ", () => {
.execute();
await refreshSendouQInstance();
- const notes = await ownNotesOf(1);
- const groups = SendouQ.previewGroups(1, notes);
+ const groups = SendouQ.previewGroups(1);
expect(groups).toHaveLength(3);
expect(groups[0].members![0].id).toBe(4);
@@ -448,36 +401,13 @@ describe("SendouQ", () => {
const partialGroupId = await createGroup([6]);
await refreshSendouQInstance();
- const notes = await ownNotesOf(1);
- const groups = SendouQ.previewGroups(1, notes);
+ const groups = SendouQ.previewGroups(1);
expect(groups).toHaveLength(2);
expect(groups[0].id).toBe(partialGroupId);
expect(groups[1].id).toBe(fullGroupId);
});
- test("sorts by sentiment first, then tier within same sentiment", async () => {
- await insertSkill(1, 1000);
- await insertSkill(2, 500);
- await insertSkill(3, 2000);
- await insertSkill(4, 1050);
-
- await createGroup([2]);
- await createGroup([3]);
- await createGroup([4]);
- await createPrivateNote(1, 2, "POSITIVE");
- await createPrivateNote(1, 3, "NEGATIVE");
- await refreshSendouQInstance();
-
- const notes = await ownNotesOf(1);
- const groups = SendouQ.previewGroups(1, notes);
-
- expect(groups).toHaveLength(3);
- expect(groups[0].members![0].id).toBe(2);
- expect(groups[1].members![0].id).toBe(4);
- expect(groups[2].members![0].id).toBe(3);
- });
-
test("handles viewer without skill gracefully", async () => {
await insertSkill(2, 500);
await insertSkill(3, 2000);
@@ -486,8 +416,7 @@ describe("SendouQ", () => {
await createGroup([3]);
await refreshSendouQInstance();
- const notes = await ownNotesOf(1);
- const groups = SendouQ.previewGroups(1, notes);
+ const groups = SendouQ.previewGroups(1);
expect(groups).toHaveLength(2);
});
@@ -508,8 +437,7 @@ describe("SendouQ", () => {
await createGroup([1, 2, 3, 4]);
await refreshSendouQInstance();
- const notes = await ownNotesOf(5);
- const groups = SendouQ.lookingGroups(5, notes);
+ const groups = SendouQ.lookingGroups(5);
expect(groups).toEqual([]);
});
@@ -526,8 +454,7 @@ describe("SendouQ", () => {
await createGroup([4], { status: "ACTIVE" });
await refreshSendouQInstance();
- const notes = await ownNotesOf(1);
- const groups = SendouQ.lookingGroups(1, notes);
+ const groups = SendouQ.lookingGroups(1);
expect(groups).toHaveLength(1);
expect(groups[0].members![0].id).toBe(4);
@@ -542,8 +469,7 @@ describe("SendouQ", () => {
await refreshSendouQInstance();
- const notes = await ownNotesOf(1);
- const groups = SendouQ.lookingGroups(1, notes);
+ const groups = SendouQ.lookingGroups(1);
expect(groups).toHaveLength(1);
expect(groups[0].members![0].id).toBe(3);
@@ -554,8 +480,7 @@ describe("SendouQ", () => {
await createGroup([3, 4]);
await refreshSendouQInstance();
- const notes = await ownNotesOf(1);
- const groups = SendouQ.lookingGroups(1, notes);
+ const groups = SendouQ.lookingGroups(1);
expect(groups).toHaveLength(1);
expect(groups[0].members?.some((m) => m.id === 1)).toBe(false);
@@ -569,8 +494,7 @@ describe("SendouQ", () => {
await createGroup([11, 12, 13, 14]);
await refreshSendouQInstance();
- const notes = await ownNotesOf(1);
- const groups = SendouQ.lookingGroups(1, notes);
+ const groups = SendouQ.lookingGroups(1);
expect(groups).toHaveLength(1);
expect(groups[0].members).toBeUndefined();
@@ -584,8 +508,7 @@ describe("SendouQ", () => {
await createGroup([10, 11, 12, 13]);
await refreshSendouQInstance();
- const notes = await ownNotesOf(1);
- const groups = SendouQ.lookingGroups(1, notes);
+ const groups = SendouQ.lookingGroups(1);
expect(groups).toHaveLength(1);
expect(groups[0].members).toHaveLength(1);
@@ -600,8 +523,7 @@ describe("SendouQ", () => {
await createGroup([9, 10, 11, 12]);
await refreshSendouQInstance();
- const notes = await ownNotesOf(1);
- const groups = SendouQ.lookingGroups(1, notes);
+ const groups = SendouQ.lookingGroups(1);
expect(groups).toHaveLength(2);
const groupSizes = groups.map((g) => g.members!.length);
@@ -617,8 +539,7 @@ describe("SendouQ", () => {
await createGroup([8, 9, 10, 11]);
await refreshSendouQInstance();
- const notes = await ownNotesOf(1);
- const groups = SendouQ.lookingGroups(1, notes);
+ const groups = SendouQ.lookingGroups(1);
expect(groups).toHaveLength(3);
const groupSizes = groups.map((g) => g.members!.length);
@@ -655,8 +576,7 @@ describe("SendouQ", () => {
await refreshSendouQInstance();
- const notes = await ownNotesOf(1);
- const groups = SendouQ.lookingGroups(1, notes);
+ const groups = SendouQ.lookingGroups(1);
const fullGroups = groups.filter((g) => g.members === undefined);
expect(fullGroups.some((g) => g.isReplay)).toBe(true);
@@ -679,8 +599,7 @@ describe("SendouQ", () => {
await refreshSendouQInstance();
- const notes = await ownNotesOf(1);
- const groups = SendouQ.lookingGroups(1, notes);
+ const groups = SendouQ.lookingGroups(1);
for (const group of groups) {
expect(group.isReplay).toBe(false);
@@ -693,8 +612,7 @@ describe("SendouQ", () => {
await createGroup([3]);
await refreshSendouQInstance();
- const notes = await ownNotesOf(1);
- const groups = SendouQ.lookingGroups(1, notes);
+ const groups = SendouQ.lookingGroups(1);
for (const group of groups) {
expect(group.isReplay).toBe(false);
@@ -718,8 +636,7 @@ describe("SendouQ", () => {
await refreshSendouQInstance();
- const notes = await ownNotesOf(1);
- const groups = SendouQ.lookingGroups(1, notes);
+ const groups = SendouQ.lookingGroups(1);
const partialGroup = groups.find((g) =>
g.members?.some((m) => m.id === 5),
@@ -742,8 +659,7 @@ describe("SendouQ", () => {
await createGroup([5, 6, 7, 8]);
await refreshSendouQInstance();
- const notes = await ownNotesOf(1);
- const groups = SendouQ.lookingGroups(1, notes);
+ const groups = SendouQ.lookingGroups(1);
const fullGroup = groups.find((g) => g.members === undefined);
expect(fullGroup).toBeDefined();
@@ -754,8 +670,7 @@ describe("SendouQ", () => {
await createGroup([2, 3]);
await refreshSendouQInstance();
- const notes = await ownNotesOf(1);
- const groups = SendouQ.lookingGroups(1, notes);
+ const groups = SendouQ.lookingGroups(1);
const partialGroup = groups.find((g) => g.members?.length === 2);
expect(partialGroup).toBeDefined();
@@ -768,8 +683,7 @@ describe("SendouQ", () => {
await createGroup([3, 4, 5, 6]);
await refreshSendouQInstance();
- const notes = await ownNotesOf(1);
- const groups = SendouQ.lookingGroups(1, notes);
+ const groups = SendouQ.lookingGroups(1);
for (const group of groups) {
expect(group).not.toHaveProperty("inviteCode");
@@ -778,76 +692,6 @@ describe("SendouQ", () => {
});
});
- describe("private note sorting", () => {
- beforeEach(async () => {
- await dbInsertUsers(8);
- });
-
- afterEach(() => {
- dbReset();
- });
-
- test("users with positive note sorted first", async () => {
- await createGroup([1]);
- await createGroup([2]);
- await createGroup([3]);
- await createGroup([4]);
- await createGroup([5]);
- await createGroup([6, 7]);
- await createGroup([8]);
-
- await createPrivateNote(1, 5, "POSITIVE");
-
- await refreshSendouQInstance();
-
- const notes = await ownNotesOf(1);
- const groups = SendouQ.lookingGroups(1, notes);
-
- expect(groups[0].members![0].id).toBe(5);
- });
-
- test("users with negative note sorted last", async () => {
- await createGroup([1]);
- await createGroup([2]);
- await createGroup([3]);
- await createGroup([4]);
- await createGroup([5]);
- await createGroup([6, 7]);
- await createGroup([8]);
-
- await createPrivateNote(1, 5, "NEGATIVE");
-
- await refreshSendouQInstance();
-
- const notes = await ownNotesOf(1);
- const groups = SendouQ.lookingGroups(1, notes);
-
- expect(groups[groups.length - 1].members![0].id).toBe(5);
- });
-
- test("group with both negative and positive sentiment sorted last", async () => {
- await createGroup([1]);
- await createGroup([2]);
- await createGroup([3]);
- await createGroup([4]);
- await createGroup([5]);
- await createGroup([6, 7]);
- await createGroup([8]);
-
- await createPrivateNote(1, 6, "POSITIVE");
- await createPrivateNote(1, 7, "NEGATIVE");
-
- await refreshSendouQInstance();
-
- const notes = await ownNotesOf(1);
- const groups = SendouQ.lookingGroups(1, notes);
-
- expect(groups[groups.length - 1].members?.some((m) => m.id === 6)).toBe(
- true,
- );
- });
- });
-
describe("skill-based sorting", () => {
beforeEach(async () => {
refreshUserSkills(1);
@@ -858,26 +702,7 @@ describe("SendouQ", () => {
dbReset();
});
- test("sentiment still takes priority over skill", async () => {
- await insertSkill(1, 1000);
- await insertSkill(2, 500);
- await insertSkill(4, 2000);
-
- await createGroup([1]);
- await createGroup([2]);
- await createGroup([4]);
-
- await createPrivateNote(1, 4, "POSITIVE");
-
- await refreshSendouQInstance();
-
- const notes = await ownNotesOf(1);
- const groups = SendouQ.lookingGroups(1, notes);
-
- expect(groups[0].members![0].id).toBe(4);
- });
-
- test("groups with closer skill sorted first within same sentiment", async () => {
+ test("groups with closer skill sorted first", async () => {
await insertSkill(1, 1000);
await insertSkill(2, 1050);
await insertSkill(3, 500);
@@ -890,8 +715,7 @@ describe("SendouQ", () => {
await refreshSendouQInstance();
- const notes = await ownNotesOf(1);
- const groups = SendouQ.lookingGroups(1, notes);
+ const groups = SendouQ.lookingGroups(1);
expect(groups[0].members![0].id).toBe(2);
});
@@ -914,8 +738,7 @@ describe("SendouQ", () => {
await refreshSendouQInstance();
- const notes = await ownNotesOf(1);
- const groups = SendouQ.lookingGroups(1, notes);
+ const groups = SendouQ.lookingGroups(1);
expect(groups.length).toBeGreaterThan(0);
expect(groups[0].id).toBe(closerGroup);
@@ -946,8 +769,7 @@ describe("SendouQ", () => {
await createGroup([1]);
await refreshSendouQInstance();
- const notes = await ownNotesOf(1);
- const groups = SendouQ.lookingGroups(1, notes);
+ const groups = SendouQ.lookingGroups(1);
expect(groups[0].members![0].id).toBe(3);
expect(groups[1].members![0].id).toBe(2);
diff --git a/app/features/sendouq/core/SendouQ.server.ts b/app/features/sendouq/core/SendouQ.server.ts
index 1da60ac09..31d7c204b 100644
--- a/app/features/sendouq/core/SendouQ.server.ts
+++ b/app/features/sendouq/core/SendouQ.server.ts
@@ -5,7 +5,6 @@ import type { AuthenticatedUser } from "~/features/auth/core/user.server";
import * as Seasons from "~/features/mmr/core/Seasons";
import { defaultOrdinal } from "~/features/mmr/mmr-utils";
import { type TieredSkill, userSkills } from "~/features/mmr/tiered.server";
-import type * as PrivateUserNoteRepository from "~/features/sendouq/PrivateUserNoteRepository.server";
import * as SQGroupRepository from "~/features/sendouq/SQGroupRepository.server";
import * as SendouQMatch from "~/features/sendouq-match/core/SendouQMatch";
import type * as SQMatchRepository from "~/features/sendouq-match/SQMatchRepository.server";
@@ -22,9 +21,6 @@ import { tierDifferenceToRangeOrExact } from "./groups.server";
type DBGroupRow = Awaited<
ReturnType
>[number];
-type DBPrivateNoteRow = Awaited<
- ReturnType
->[number];
type DBRecentlyFinishedMatchRow = Awaited<
ReturnType
>[number];
@@ -42,7 +38,6 @@ export type SQOwnGroup = SerializeFrom<
NonNullable>
>;
export type SQMatch = SerializeFrom>;
-export type SQMatchGroup = SQMatch["groupAlpha"] | SQMatch["groupBravo"];
export type SQGroupMember = NonNullable[number];
const FALLBACK_TIER = { isPlus: false, name: "IRON" } as const;
@@ -91,7 +86,6 @@ class SendouQClass {
return {
...member,
- privateNote: null as DBPrivateNoteRow | null,
languages: member.languages?.split(",") || [],
skill: !skill || skill.approximate ? ("CALCULATING" as const) : skill,
mapModePreferences: undefined,
@@ -165,8 +159,6 @@ class SendouQClass {
match: DBMatch,
/** The authenticated user viewing the match (if any) */
user?: AuthenticatedUser,
- /** Array of private user notes to include */
- notes: DBPrivateNoteRow[] = [],
) {
const viewerSide = SendouQMatch.resolveGroupMemberOf({
groupAlpha: match.groupAlpha,
@@ -199,7 +191,6 @@ class SendouQClass {
return {
...member,
skill: match.memento?.users[member.id]?.skill,
- privateNote: null as DBPrivateNoteRow | null,
skillDifference: match.memento?.users[member.id]?.skillDifference,
noScreen: undefined,
isContinuing:
@@ -244,8 +235,8 @@ class SendouQClass {
...match,
chatCode: isMatchInsider ? match.chatCode : undefined,
currentMap,
- groupAlpha: this.#getAddMemberPrivateNoteMapper(notes)(alphaCensored),
- groupBravo: this.#getAddMemberPrivateNoteMapper(notes)(bravoCensored),
+ groupAlpha: alphaCensored,
+ groupBravo: bravoCensored,
};
}
@@ -256,14 +247,11 @@ class SendouQClass {
previewGroups(
/** The ID of the user viewing the preview */
userId: number,
- /** Array of private user notes to include */
- notes: DBPrivateNoteRow[],
) {
const usersTier = this.#getUserTier(userId);
return this.groups
.filter((group) => this.#isSuitableLookingGroup({ group }))
- .map(this.#getAddMemberPrivateNoteMapper(notes))
- .sort(this.#getSkillAndNoteSortComparator(usersTier))
+ .sort(this.#getSkillSortComparator(usersTier))
.map((group) => this.#addPreviewTierRange(group))
.map((group) => this.#censorGroup(group));
}
@@ -271,14 +259,12 @@ class SendouQClass {
/**
* Returns groups that are available for matchmaking for a specific user based on their current group size.
* Filters groups based on member count compatibility, activity status, and excludes stale groups.
- * Results are sorted by sentiment (notes), tier difference, and activity.
+ * Results are sorted by tier difference and activity.
* @returns Array of compatible groups sorted by relevance, or empty array if user has no group
*/
lookingGroups(
/** The ID of the user looking for groups */
userId: number,
- /** Array of private user notes to include */
- notes: DBPrivateNoteRow[] = [],
) {
const ownGroup = this.findOwnGroup(userId);
if (!ownGroup) return [];
@@ -302,8 +288,7 @@ class SendouQClass {
)
.map(this.#getGroupReplayMapper(userId))
.map(this.#getAddTierRangeMapper(ownGroup.tier))
- .map(this.#getAddMemberPrivateNoteMapper(notes))
- .sort(this.#getSkillAndNoteSortComparator(ownGroup.tier))
+ .sort(this.#getSkillSortComparator(ownGroup.tier))
.map((group) => this.#censorGroup(group));
}
@@ -416,27 +401,10 @@ class SendouQClass {
return skill.tier;
}
- #getAddMemberPrivateNoteMapper(notes: DBPrivateNoteRow[]) {
- return (group: T) => {
- const membersWithNotes = group.members.map((member) => {
- const note = notes.find((n) => n.targetUserId === member.id);
- return {
- ...member,
- privateNote: note ?? null,
- };
- });
-
- return {
- ...group,
- members: membersWithNotes,
- };
- };
- }
-
- #getSkillAndNoteSortComparator(ownTier?: TieredSkill["tier"] | null) {
+ #getSkillSortComparator(ownTier?: TieredSkill["tier"] | null) {
return <
T extends {
- members: { privateNote: DBPrivateNoteRow | null }[];
+ members: unknown[];
tierRange: TierRange | null;
tier: TieredSkill["tier"] | null;
latestActionAt: number;
@@ -452,26 +420,6 @@ class SendouQClass {
return aIsFull ? 1 : -1;
}
- const getGroupSentimentScore = (group: T) => {
- const hasNegative = group.members.some(
- (m) => m.privateNote?.sentiment === "NEGATIVE",
- );
- const hasPositive = group.members.some(
- (m) => m.privateNote?.sentiment === "POSITIVE",
- );
-
- if (hasNegative) return -1;
- if (hasPositive) return 1;
- return 0;
- };
-
- const scoreA = getGroupSentimentScore(a);
- const scoreB = getGroupSentimentScore(b);
-
- if (scoreA !== scoreB) {
- return scoreB - scoreA;
- }
-
if (a.tierRange && b.tierRange) {
if (a.tierRange.diff[1] !== b.tierRange.diff[1]) {
return a.tierRange.diff[1] - b.tierRange.diff[1];
diff --git a/app/features/sendouq/loaders/q.looking.server.ts b/app/features/sendouq/loaders/q.looking.server.ts
index 5df1fc2d5..5c01b7bb4 100644
--- a/app/features/sendouq/loaders/q.looking.server.ts
+++ b/app/features/sendouq/loaders/q.looking.server.ts
@@ -1,10 +1,11 @@
import type { LoaderFunctionArgs } from "react-router";
+import * as R from "remeda";
import { requireUser } from "~/features/auth/core/user.server";
import * as SQGroupRepository from "~/features/sendouq/SQGroupRepository.server";
import { cachedStreams } from "~/features/sendouq-streams/core/streams.server";
+import * as UserCardRepository from "~/features/user-card/UserCardRepository.server";
import { groupExpiryStatus } from "../core/groups";
import { SendouQ } from "../core/SendouQ.server";
-import * as PrivateUserNoteRepository from "../PrivateUserNoteRepository.server";
import { sqRedirectIfNeeded } from "../q-utils.server";
export const loader = async ({ url }: LoaderFunctionArgs) => {
@@ -14,15 +15,11 @@ export const loader = async ({ url }: LoaderFunctionArgs) => {
url.searchParams.get("preview") === "true" &&
user.roles.includes("SUPPORTER");
- const privateNotes = await PrivateUserNoteRepository.ownNotes(
- SendouQ.usersInQueue,
- );
-
const ownGroup = SendouQ.findOwnGroup(user.id);
const groups =
isPreview && !ownGroup
- ? SendouQ.previewGroups(user.id, privateNotes)
- : SendouQ.lookingGroups(user.id, privateNotes);
+ ? SendouQ.previewGroups(user.id)
+ : SendouQ.lookingGroups(user.id);
if (!isPreview) {
sqRedirectIfNeeded({
@@ -31,11 +28,23 @@ export const loader = async ({ url }: LoaderFunctionArgs) => {
});
}
+ const groupsToShow =
+ ownGroup && groupExpiryStatus(ownGroup.latestActionAt) === "EXPIRED"
+ ? []
+ : groups;
+
+ const cardUserIds = R.unique([
+ ...(ownGroup?.members ?? []).map((member) => member.id),
+ ...groupsToShow.flatMap((group) =>
+ (group.members ?? []).map((member) => member.id),
+ ),
+ ]);
+
return {
- groups:
- ownGroup && groupExpiryStatus(ownGroup.latestActionAt) === "EXPIRED"
- ? []
- : groups,
+ ...(await UserCardRepository.userCards({
+ userIds: cardUserIds,
+ })),
+ groups: groupsToShow,
ownGroup,
likes: ownGroup
? await SQGroupRepository.allLikesByGroupId(ownGroup.id)
diff --git a/app/features/sendouq/q-schemas.server.ts b/app/features/sendouq/q-schemas.server.ts
index 5070bddb6..7781b2963 100644
--- a/app/features/sendouq/q-schemas.server.ts
+++ b/app/features/sendouq/q-schemas.server.ts
@@ -80,10 +80,6 @@ export const lookingSchema = z.union([
z.string().max(SENDOUQ.OWN_PUBLIC_NOTE_MAX_LENGTH).nullable(),
),
}),
- z.object({
- _action: _action("DELETE_PRIVATE_USER_NOTE"),
- targetId: id,
- }),
]);
export const weaponUsageSearchParamsSchema = z.object({
diff --git a/app/features/sendouq/routes/q.looking.tsx b/app/features/sendouq/routes/q.looking.tsx
index fde36a96a..b590e2597 100644
--- a/app/features/sendouq/routes/q.looking.tsx
+++ b/app/features/sendouq/routes/q.looking.tsx
@@ -18,6 +18,7 @@ import { Placeholder } from "~/components/Placeholder";
import { SubmitButton } from "~/components/SubmitButton";
import { useUser } from "~/features/auth/core/user";
import { useWebsocketRevalidation } from "~/features/chat/chat-hooks";
+import type { UserCardData } from "~/features/user-card/user-card-types";
import { useDateTimeFormat } from "~/hooks/intl/useDateTimeFormat";
import { useHydrated } from "~/hooks/useHydrated";
import { useMainContentWidth } from "~/hooks/useMainContentWidth";
@@ -239,6 +240,8 @@ function Groups() {
const isFullGroup =
data.ownGroup && data.ownGroup.members.length === FULL_GROUP_SIZE;
+ const groups = sortGroupsByPrivateNoteSentiment(data.groups, data.userCards);
+
const invitedGroupsDesktop = (
@@ -248,7 +251,7 @@ function Groups() {
: "q:looking.columns.invited",
)}
- {data.groups
+ {groups
.filter((group) =>
data.likes.given.some((like) => like.groupId === group.id),
)
@@ -258,7 +261,6 @@ function Groups() {
key={group.id}
group={group}
action="UNLIKE"
- showNote
ownGroup={data.ownGroup}
layout={layout}
/>
@@ -272,7 +274,7 @@ function Groups() {
{t("q:looking.columns.myGroup")}
-
+
{data.ownGroup.inviteCode ? (
) : null;
- const neutralGroups = data.groups.filter(
+ const neutralGroups = groups.filter(
(group) =>
!data.likes.given.some((like) => like.groupId === group.id) &&
!data.likes.received.some((like) => like.groupId === group.id),
);
- const groupsReceivedLikesFrom = data.groups.filter((group) =>
+ const groupsReceivedLikesFrom = groups.filter((group) =>
data.likes.received.some((like) => like.groupId === group.id),
);
@@ -341,7 +343,7 @@ function Groups() {
{t("q:looking.columns.available")}
{(isMobile
- ? data.groups.filter(
+ ? groups.filter(
(group) =>
!data.likes.received.some(
(like) => like.groupId === group.id,
@@ -360,7 +362,6 @@ function Groups() {
? "UNLIKE"
: "LIKE"
}
- showNote
ownGroup={data.ownGroup}
layout={layout}
/>
@@ -388,7 +389,6 @@ function Groups() {
key={group.id}
group={group}
action={action()}
- showNote
ownGroup={data.ownGroup}
layout={layout}
/>
@@ -426,7 +426,6 @@ function Groups() {
key={group.id}
group={group}
action={action()}
- showNote
ownGroup={data.ownGroup}
layout={layout}
/>
@@ -439,6 +438,38 @@ function Groups() {
);
}
+/**
+ * Floats groups the viewer has a positive private note on up and groups with a
+ * negative note down, while preserving the server's tier/activity ordering
+ * within each sentiment bucket and keeping full (censored) groups last. The
+ * note sentiment is read from the already-loaded `userCards` data so the server
+ * does not need to attach notes to group members.
+ */
+function sortGroupsByPrivateNoteSentiment<
+ T extends { members?: { id: number }[] },
+>(groups: T[], userCards: Map): T[] {
+ const sentimentScore = (group: T) => {
+ if (!group.members) return 0;
+
+ let score = 0;
+ for (const member of group.members) {
+ const sentiment = userCards.get(member.id)?.privateNote?.sentiment;
+ if (sentiment === "NEGATIVE") return -1;
+ if (sentiment === "POSITIVE") score = 1;
+ }
+
+ return score;
+ };
+
+ return groups.toSorted((a, b) => {
+ const aIsFull = !a.members;
+ const bIsFull = !b.members;
+ if (aIsFull !== bIsFull) return aIsFull ? 1 : -1;
+
+ return sentimentScore(b) - sentimentScore(a);
+ });
+}
+
function ColumnHeader({
isMobile,
children,
diff --git a/app/features/settings/components/MatchProfileTab.tsx b/app/features/settings/components/MatchProfileTab.tsx
index 5fe9eaf4d..a5448c50d 100644
--- a/app/features/settings/components/MatchProfileTab.tsx
+++ b/app/features/settings/components/MatchProfileTab.tsx
@@ -1,5 +1,8 @@
+import { Pencil } from "lucide-react";
import * as React from "react";
-import { useLoaderData } from "react-router";
+import { useTranslation } from "react-i18next";
+import { useLoaderData, useLocation } from "react-router";
+import { LinkButton } from "~/components/elements/Button";
import { ModeImage } from "~/components/Image";
import type { Preference, UserMapModePreferences } from "~/db/tables";
import { BANNED_MAPS } from "~/features/match-profile/banned-maps";
@@ -7,52 +10,70 @@ import { AMOUNT_OF_MAPS_IN_POOL_PER_MODE } from "~/features/match-profile/match-
import { SendouForm } from "~/form/SendouForm";
import { modesShort } from "~/modules/in-game-lists/modes";
import type { ModeShort, StageId } from "~/modules/in-game-lists/types";
+import { userCardEditPage } from "~/utils/urls";
import type { loader } from "../loaders/settings.server";
import { updateMatchProfileSchema } from "../match-profile-schemas";
import { ModeMapPoolPicker } from "./ModeMapPoolPicker";
import { PreferenceRadioGroup } from "./PreferenceRadioGroup";
export function MatchProfileTab() {
+ const { t } = useTranslation(["user"]);
const data = useLoaderData();
+ const location = useLocation();
const matchProfile = data.matchProfile;
if (!matchProfile) return null;
return (
- ({
- id: w.weaponSplId,
- isFavorite: Boolean(w.isFavorite),
- })),
- vc: matchProfile.vc ?? "NO",
- languages: matchProfile.languages ?? [],
- noScreen: Boolean(matchProfile.noScreen),
- }}
- revalidateRoot
- >
- {({ FormField }) => (
- <>
-
- {(props: {
- value: unknown;
- onChange: (value: UserMapModePreferences) => void;
- }) => (
-
- )}
-
-
-
-
-
- >
- )}
-
+
+ }
+ className="self-start"
+ >
+ {t("user:card.edit.title")}
+
+ ({
+ id: w.weaponSplId,
+ isFavorite: Boolean(w.isFavorite),
+ })),
+ vc: matchProfile.vc ?? "NO",
+ languages: matchProfile.languages ?? [],
+ noScreen: Boolean(matchProfile.noScreen),
+ }}
+ revalidateRoot
+ >
+ {({ FormField }) => (
+ <>
+
+ {(props: {
+ value: unknown;
+ onChange: (value: UserMapModePreferences) => void;
+ }) => (
+
+ )}
+
+
+
+
+
+ >
+ )}
+
+
);
}
diff --git a/app/features/settings/routes/settings.tsx b/app/features/settings/routes/settings.tsx
index 36a959a44..e5641592e 100644
--- a/app/features/settings/routes/settings.tsx
+++ b/app/features/settings/routes/settings.tsx
@@ -35,7 +35,7 @@ import "./settings.global.css";
export { action, loader };
export const handle: SendouRouteHandle = {
- i18n: ["settings"],
+ i18n: ["settings", "user"],
breadcrumb: () => ({
imgPath: navIconUrl("settings"),
href: SETTINGS_PAGE,
diff --git a/app/features/top-search/XRankPlacementRepository.server.test.ts b/app/features/top-search/XRankPlacementRepository.server.test.ts
index 8e79380d9..0a600cb93 100644
--- a/app/features/top-search/XRankPlacementRepository.server.test.ts
+++ b/app/features/top-search/XRankPlacementRepository.server.test.ts
@@ -84,8 +84,37 @@ describe("refreshAllPeakXp", () => {
.orderBy("id", "asc")
.execute();
- expect(players[0].peakXp).toBe(2700);
- expect(players[1].peakXp).toBe(3000);
+ expect(players[0].peakXp).toEqual({
+ overall: 2700,
+ tentatek: 2700,
+ takoroka: null,
+ });
+ expect(players[1].peakXp).toEqual({
+ overall: 3000,
+ tentatek: 3000,
+ takoroka: null,
+ });
+ });
+
+ test("splits peakXp by division (region)", async () => {
+ const playerId = await createSplatoonPlayer("player1");
+
+ await createXRankPlacement({ playerId, power: 2700, region: "WEST" });
+ await createXRankPlacement({ playerId, power: 2900, region: "JPN" });
+
+ await XRankPlacementRepository.refreshAllPeakXp();
+
+ const player = await db
+ .selectFrom("SplatoonPlayer")
+ .select("peakXp")
+ .where("id", "=", playerId)
+ .executeTakeFirstOrThrow();
+
+ expect(player.peakXp).toEqual({
+ overall: 2900,
+ tentatek: 2700,
+ takoroka: 2900,
+ });
});
test("sets peakXp to null for player with no placements", async () => {
diff --git a/app/features/top-search/XRankPlacementRepository.server.ts b/app/features/top-search/XRankPlacementRepository.server.ts
index c5d357a44..a38c8e2ec 100644
--- a/app/features/top-search/XRankPlacementRepository.server.ts
+++ b/app/features/top-search/XRankPlacementRepository.server.ts
@@ -13,6 +13,25 @@ export function unlinkPlayerByUserId(userId: number) {
.execute();
}
+/**
+ * SQLite expression extracting a Splatoon player's overall peak XP from the denormalized `peakXp`
+ * JSON column (see {@link refreshAllPeakXp}). `"SplatoonPlayer"` must be in scope at the call site.
+ */
+export function peakXpOverallSql() {
+ return sql`"SplatoonPlayer"."peakXp" ->> '$.overall'`;
+}
+
+/** Whether the user has a linked Splatoon player (i.e. has claimed their X Rank results). */
+export async function isPlayerLinkedByUserId(userId: number): Promise {
+ const player = await db
+ .selectFrom("SplatoonPlayer")
+ .select("SplatoonPlayer.id")
+ .where("SplatoonPlayer.userId", "=", userId)
+ .executeTakeFirst();
+
+ return Boolean(player);
+}
+
function xRankPlacementsQueryBase() {
return db
.selectFrom("XRankPlacement")
@@ -132,12 +151,23 @@ export type FindPlacement = InferResult<
export async function refreshAllPeakXp() {
await db
.updateTable("SplatoonPlayer")
- .set((eb) => ({
- peakXp: eb
- .selectFrom("XRankPlacement")
- .select((eb) => eb.fn.max("XRankPlacement.power").as("peakXp"))
- .whereRef("XRankPlacement.playerId", "=", "SplatoonPlayer.id"),
- }))
+ .set({
+ // denormalized PeakXP json: overall + per-division peaks
+ // (region WEST = Tentatek, otherwise Takoroka). null when no placements.
+ peakXp: sql`(
+ select iif(
+ max("XRankPlacement"."power") is null,
+ null,
+ json_object(
+ 'overall', max("XRankPlacement"."power"),
+ 'tentatek', max(iif("XRankPlacement"."region" = 'WEST', "XRankPlacement"."power", null)),
+ 'takoroka', max(iif("XRankPlacement"."region" != 'WEST', "XRankPlacement"."power", null))
+ )
+ )
+ from "XRankPlacement"
+ where "XRankPlacement"."playerId" = "SplatoonPlayer"."id"
+ )`,
+ })
.execute();
}
diff --git a/app/features/top-search/components/HowToLinkPopover.module.css b/app/features/top-search/components/HowToLinkPopover.module.css
new file mode 100644
index 000000000..456e86c79
--- /dev/null
+++ b/app/features/top-search/components/HowToLinkPopover.module.css
@@ -0,0 +1,23 @@
+.content {
+ display: flex;
+ flex-direction: column;
+ gap: var(--s-3);
+ white-space: normal;
+ font-size: var(--font-xs);
+}
+
+.list {
+ margin: var(--s-1) 0 0;
+ padding-inline-start: var(--s-4);
+ display: flex;
+ flex-direction: column;
+ gap: var(--s-1);
+}
+
+.example {
+ display: block;
+ padding: var(--s-2);
+ border-radius: var(--radius-box);
+ background-color: var(--color-bg-higher);
+ word-break: break-word;
+}
diff --git a/app/features/top-search/components/HowToLinkPopover.tsx b/app/features/top-search/components/HowToLinkPopover.tsx
new file mode 100644
index 000000000..5cc94dcfa
--- /dev/null
+++ b/app/features/top-search/components/HowToLinkPopover.tsx
@@ -0,0 +1,51 @@
+import { Trans, useTranslation } from "react-i18next";
+import { SendouButton } from "~/components/elements/Button";
+import { SendouPopover } from "~/components/elements/Popover";
+import { SENDOU_INK_DISCORD_URL } from "~/utils/urls";
+import styles from "./HowToLinkPopover.module.css";
+
+const EXAMPLE_PLAYER_URL = "https://sendou.ink/xsearch/player/0";
+
+export function HowToLinkPopover() {
+ const { t } = useTranslation(["common"]);
+
+ return (
+
+ {t("common:xsearch.link.trigger")}
+
+ }
+ >
+
+
+
+ {t("common:xsearch.link.about.title")}
+
+
+ - {t("common:xsearch.link.about.endPower")}
+ - {t("common:xsearch.link.about.notPeak")}
+ - {t("common:xsearch.link.about.wait")}
+
+
+
+
+
+ {t("common:xsearch.link.exampleRequest")} {EXAMPLE_PLAYER_URL}
+
+
+
+ {t("common:xsearch.link.noScreenshots")}
+
+
+
+ );
+}
diff --git a/app/features/top-search/routes/xsearch.player.$id.tsx b/app/features/top-search/routes/xsearch.player.$id.tsx
index a2b01210e..f0f90b72b 100644
--- a/app/features/top-search/routes/xsearch.player.$id.tsx
+++ b/app/features/top-search/routes/xsearch.player.$id.tsx
@@ -15,6 +15,7 @@ import {
userPage,
} from "~/utils/urls";
import { action } from "../actions/xsearch.player.$id.server";
+import { HowToLinkPopover } from "../components/HowToLinkPopover";
import { PlacementsTable } from "../components/Placements";
import { loader } from "../loaders/xsearch.player.$id.server";
@@ -77,19 +78,22 @@ export default function XSearchPlayerPage() {
return (
-
- {hasUserLinked(placementUser) ? (
- {data.names.primary}
- ) : (
- data.names.primary
- )}{" "}
- {t("common:xsearch.placements")}
-
- {data.names.aliases.length > 0 ? (
-
- {t("common:xsearch.aliases")} {data.names.aliases.join(", ")}
-
- ) : null}
+
+
+ {hasUserLinked(placementUser) ? (
+ {data.names.primary}
+ ) : (
+ data.names.primary
+ )}{" "}
+ {t("common:xsearch.placements")}
+
+ {data.names.aliases.length > 0 ? (
+
+ {t("common:xsearch.aliases")} {data.names.aliases.join(", ")}
+
+ ) : null}
+
+ {!hasUserLinked(placementUser) ?
: null}
{isLinkedToCurrentUser ? : null}
diff --git a/app/features/tournament-admin/loaders/to.$id.admin.seeds.server.ts b/app/features/tournament-admin/loaders/to.$id.admin.seeds.server.ts
new file mode 100644
index 000000000..ce833c8c7
--- /dev/null
+++ b/app/features/tournament-admin/loaders/to.$id.admin.seeds.server.ts
@@ -0,0 +1,27 @@
+import type { LoaderFunctionArgs } from "react-router";
+import * as R from "remeda";
+import { tournamentFromDBCached } from "~/features/tournament-bracket/core/Tournament.server";
+import * as UserCardRepository from "~/features/user-card/UserCardRepository.server";
+import { parseParams } from "~/utils/remix.server";
+import { idObject } from "~/utils/zod";
+
+export const loader = async ({ params }: LoaderFunctionArgs) => {
+ const { id: tournamentId } = parseParams({ params, schema: idObject });
+
+ const tournament = await tournamentFromDBCached({
+ tournamentId,
+ user: undefined,
+ });
+
+ const userIds = R.unique(
+ tournament.ctx.teams.flatMap((team) =>
+ team.members.map((member) => member.userId),
+ ),
+ );
+
+ return {
+ ...(await UserCardRepository.userCards({
+ userIds,
+ })),
+ };
+};
diff --git a/app/features/tournament-admin/routes/to.$id.admin.seeds.module.css b/app/features/tournament-admin/routes/to.$id.admin.seeds.module.css
index 18ccea0b0..414b7d321 100644
--- a/app/features/tournament-admin/routes/to.$id.admin.seeds.module.css
+++ b/app/features/tournament-admin/routes/to.$id.admin.seeds.module.css
@@ -176,15 +176,6 @@
margin-left: var(--s-1);
}
-.plusTier {
- display: inline-flex;
- align-items: center;
- justify-content: center;
- gap: 2px;
- font-size: var(--font-2xs);
- color: var(--color-text-high);
-}
-
.playerRemoved {
text-decoration: line-through;
color: var(--color-text-high);
diff --git a/app/features/tournament-admin/routes/to.$id.admin.seeds.tsx b/app/features/tournament-admin/routes/to.$id.admin.seeds.tsx
index 6c771ad2e..506c24e4e 100644
--- a/app/features/tournament-admin/routes/to.$id.admin.seeds.tsx
+++ b/app/features/tournament-admin/routes/to.$id.admin.seeds.tsx
@@ -18,7 +18,7 @@ import {
import { CSS } from "@dnd-kit/utilities";
import clsx from "clsx";
import * as React from "react";
-import { Link, useFetcher, useNavigation } from "react-router";
+import { useFetcher, useNavigation } from "react-router";
import { Alert } from "~/components/Alert";
import { Avatar } from "~/components/Avatar";
import { SendouButton } from "~/components/elements/Button";
@@ -27,7 +27,6 @@ import {
SendouChipRadioGroup,
} from "~/components/elements/ChipRadio";
import { SendouDialog } from "~/components/elements/Dialog";
-import { Image } from "~/components/Image";
import { InfoPopover } from "~/components/InfoPopover";
import { SubmitButton } from "~/components/SubmitButton";
import { Table } from "~/components/Table";
@@ -37,12 +36,18 @@ import { TOURNAMENT } from "~/features/tournament/tournament-constants";
import * as AbDivisions from "~/features/tournament-bracket/core/AbDivisions";
import type { Tournament } from "~/features/tournament-bracket/core/Tournament";
import type { TournamentDataTeam } from "~/features/tournament-bracket/core/Tournament.server";
+import { UserCard } from "~/features/user-card/components/UserCard";
import invariant from "~/utils/invariant";
-import { navIconUrl, userResultsPage } from "~/utils/urls";
+import type { SendouRouteHandle } from "~/utils/remix.server";
import { ordinalToRoundedSp } from "../../mmr/mmr-utils";
import styles from "./to.$id.admin.seeds.module.css";
export { action } from "../actions/to.$id.admin.seeds.server";
+export { loader } from "../loaders/to.$id.admin.seeds.server";
+
+export const handle: SendouRouteHandle = {
+ i18n: ["user", "q"],
+};
const AB_DIVISION_RADIO_OPTIONS = [
{ value: "unassigned", label: "Unassigned" },
@@ -755,20 +760,9 @@ function RowContents({
[styles.playerNew]: isNew,
})}
>
-
- {member.username}
-
- {member.plusTier ? (
-
-
- {member.plusTier}
-
- ) : null}
+
+ {member.username}
+
{isNew ? (
NEW
) : null}
diff --git a/app/features/tournament-lfg/components/LFGGroupCard.tsx b/app/features/tournament-lfg/components/LFGGroupCard.tsx
index 8d160b9a6..807933c8d 100644
--- a/app/features/tournament-lfg/components/LFGGroupCard.tsx
+++ b/app/features/tournament-lfg/components/LFGGroupCard.tsx
@@ -3,23 +3,28 @@ import { Mic, Star, Trash, Volume2, VolumeX } from "lucide-react";
import * as React from "react";
import { Flipped } from "react-flip-toolkit";
import { useTranslation } from "react-i18next";
-import { Link, useFetcher } from "react-router";
+import { useFetcher } from "react-router";
import { Avatar } from "~/components/Avatar";
import { Divider } from "~/components/Divider";
import { SendouButton } from "~/components/elements/Button";
import { SendouPopover } from "~/components/elements/Popover";
import { FormWithConfirm } from "~/components/FormWithConfirm";
import { Image, WeaponImage } from "~/components/Image";
+import { NoteAvatar } from "~/components/NoteAvatar";
import { SubmitButton } from "~/components/SubmitButton";
import type { Pronouns } from "~/db/tables";
import { useUser } from "~/features/auth/core/user";
import { IS_Q_LOOKING_MOBILE_BREAKPOINT } from "~/features/sendouq/q-constants";
import { useTournament } from "~/features/tournament/routes/to.$id";
+import {
+ UserCard,
+ useUserCardData,
+} from "~/features/user-card/components/UserCard";
import { SendouForm } from "~/form/SendouForm";
import { useMainContentWidth } from "~/hooks/useMainContentWidth";
import { languagesUnified } from "~/modules/i18n/config";
import type { MainWeaponId } from "~/modules/in-game-lists/types";
-import { navIconUrl, userPage } from "~/utils/urls";
+import { navIconUrl } from "~/utils/urls";
import { updateGroupFormSchema } from "../tournament-lfg-schemas";
import styles from "./LFGGroupCard.module.css";
@@ -194,14 +199,23 @@ function LFGGroupMemberRow({
showActions: boolean;
isOwnGroup: boolean;
}) {
+ const cardData = useUserCardData(member.id);
+
return (
-
-
- {member.username}
-
+
+
+
+
+
+ {member.username}
+
+
{member.pronouns ? (
{member.pronouns.subject}/{member.pronouns.object}
diff --git a/app/features/tournament-lfg/loaders/to.$id.looking.server.ts b/app/features/tournament-lfg/loaders/to.$id.looking.server.ts
index 091246234..641acd0ae 100644
--- a/app/features/tournament-lfg/loaders/to.$id.looking.server.ts
+++ b/app/features/tournament-lfg/loaders/to.$id.looking.server.ts
@@ -1,7 +1,9 @@
import type { LoaderFunctionArgs } from "react-router";
+import * as R from "remeda";
import type { Pronouns } from "~/db/tables";
import { getUser } from "~/features/auth/core/user.server";
import { tournamentFromDBCached } from "~/features/tournament-bracket/core/Tournament.server";
+import * as UserCardRepository from "~/features/user-card/UserCardRepository.server";
import type { MainWeaponId } from "~/modules/in-game-lists/types";
import type { SerializeFrom } from "~/utils/remix";
import { parseParams } from "~/utils/remix.server";
@@ -83,8 +85,16 @@ async function lookingMode({
ownGroup,
});
+ const cardUserIds = R.unique([
+ ...groups.flatMap((group) => group.members.map((member) => member.id)),
+ ...(ownTeam?.members ?? []).map((member) => member.id),
+ ]);
+
return {
mode: "looking" as const,
+ ...(await UserCardRepository.userCards({
+ userIds: cardUserIds,
+ })),
groups: otherGroups,
ownGroup,
ownTeam,
@@ -130,6 +140,9 @@ async function subsMode({
return {
mode: "subs" as const,
+ ...(await UserCardRepository.userCards({
+ userIds: subs.map((sub) => sub.userId),
+ })),
subs,
hasOwnSubPost: subs.some((sub) => sub.userId === user?.id),
tournamentId,
diff --git a/app/features/tournament-lfg/routes/to.$id.looking.tsx b/app/features/tournament-lfg/routes/to.$id.looking.tsx
index 8791a61a0..aea6d7d9f 100644
--- a/app/features/tournament-lfg/routes/to.$id.looking.tsx
+++ b/app/features/tournament-lfg/routes/to.$id.looking.tsx
@@ -3,7 +3,7 @@ import { Mic, Trash } from "lucide-react";
import * as React from "react";
import { Flipper } from "react-flip-toolkit";
import { useTranslation } from "react-i18next";
-import { Link, useFetcher, useLoaderData } from "react-router";
+import { useFetcher, useLoaderData } from "react-router";
import { Avatar } from "~/components/Avatar";
import { SendouButton } from "~/components/elements/Button";
import { SendouDialog } from "~/components/elements/Dialog";
@@ -16,16 +16,20 @@ import {
} from "~/components/elements/Tabs";
import { FormWithConfirm } from "~/components/FormWithConfirm";
import { WeaponImage } from "~/components/Image";
+import { NoteAvatar } from "~/components/NoteAvatar";
import { Placeholder } from "~/components/Placeholder";
import { SubmitButton } from "~/components/SubmitButton";
import { useUser } from "~/features/auth/core/user";
import { IS_Q_LOOKING_MOBILE_BREAKPOINT } from "~/features/sendouq/q-constants";
import { useTournament } from "~/features/tournament/routes/to.$id";
+import {
+ UserCard,
+ useUserCardData,
+} from "~/features/user-card/components/UserCard";
import { SendouForm } from "~/form/SendouForm";
import { useHydrated } from "~/hooks/useHydrated";
import { useMainContentWidth } from "~/hooks/useMainContentWidth";
import type { SendouRouteHandle } from "~/utils/remix.server";
-import { userPage } from "~/utils/urls";
import { LFGGroupCard } from "../components/LFGGroupCard";
import {
type LookingLoaderData,
@@ -43,7 +47,7 @@ export { loader };
import styles from "./to.$id.looking.module.css";
export const handle: SendouRouteHandle = {
- i18n: ["q", "tournament", "forms", "common"],
+ i18n: ["q", "tournament", "forms", "common", "user"],
};
export default function TournamentLFGShell() {
@@ -285,6 +289,7 @@ function SubCard({ sub }: { sub: SubEntry }) {
const { t } = useTranslation(["common", "tournament"]);
const user = useUser();
const tournament = useTournament();
+ const cardData = useUserCardData(sub.userId);
const infos = [
@@ -322,10 +327,18 @@ function SubCard({ sub }: { sub: SubEntry }) {
return (
-
-
- {sub.username}
-
+
+
+
+ {sub.username}
+
+
{infos}
{sub.weapons ? (
diff --git a/app/features/tournament-match/loaders/to.$id.matches.$mid.server.ts b/app/features/tournament-match/loaders/to.$id.matches.$mid.server.ts
index 8f11b732e..c94ae4db0 100644
--- a/app/features/tournament-match/loaders/to.$id.matches.$mid.server.ts
+++ b/app/features/tournament-match/loaders/to.$id.matches.$mid.server.ts
@@ -11,6 +11,7 @@ import * as PickBan from "~/features/tournament-bracket/core/PickBan";
import { tournamentFromDBCached } from "~/features/tournament-bracket/core/Tournament.server";
import { matchPageParamsSchema } from "~/features/tournament-bracket/tournament-bracket-schemas.server";
import { tournamentTeamToActiveRosterUserIds } from "~/features/tournament-bracket/tournament-bracket-utils";
+import * as UserCardRepository from "~/features/user-card/UserCardRepository.server";
import * as UserRepository from "~/features/user-page/UserRepository.server";
import { Status } from "~/modules/brackets-model";
import { cache, IN_MILLISECONDS, ttl } from "~/utils/cache.server";
@@ -218,6 +219,12 @@ export const loader = async ({ params }: LoaderFunctionArgs) => {
!isLeagueRoundLocked(tournament, match.roundId);
return {
+ ...(await UserCardRepository.userCards({
+ userIds: match.players.map((p) => p.id),
+ include: {
+ friendCode: isParticipant || isSiteStaff || isTournamentStaff,
+ },
+ })),
match: hasPermsToSeeChat ? match : { ...match, chatCode: undefined },
results,
reportedWeapons,
diff --git a/app/features/tournament-match/routes/to.$id.matches.$mid.tsx b/app/features/tournament-match/routes/to.$id.matches.$mid.tsx
index 92329cb78..b3ade903b 100644
--- a/app/features/tournament-match/routes/to.$id.matches.$mid.tsx
+++ b/app/features/tournament-match/routes/to.$id.matches.$mid.tsx
@@ -15,7 +15,7 @@ import { tournamentMatchWebsocketRoom } from "../tournament-match-utils";
export { action, loader };
export const handle: SendouRouteHandle = {
- i18n: ["q"],
+ i18n: ["q", "user"],
};
export default function TournamentMatchPage() {
diff --git a/app/features/tournament/TournamentRepository.server.ts b/app/features/tournament/TournamentRepository.server.ts
index de27605bf..4f01164f8 100644
--- a/app/features/tournament/TournamentRepository.server.ts
+++ b/app/features/tournament/TournamentRepository.server.ts
@@ -421,6 +421,39 @@ export async function findChildTournaments(parentTournamentId: number) {
}));
}
+/** Child division tournaments of a league sign-up, with their name and finalized status. */
+export function findChildTournamentsForDivCalc(parentTournamentId: number) {
+ return db
+ .selectFrom("Tournament")
+ .innerJoin("CalendarEvent", "Tournament.id", "CalendarEvent.tournamentId")
+ .select([
+ "Tournament.id as tournamentId",
+ "CalendarEvent.name",
+ "Tournament.isFinalized",
+ ])
+ .where("Tournament.parentTournamentId", "=", parentTournamentId)
+ .execute();
+}
+
+/**
+ * User ids eligible for a LUTI division placement in the given tournament: they have a result, were
+ * on a team that did not drop out, and played at least one match.
+ */
+export function findLeagueDivParticipantUserIds(tournamentId: number) {
+ return db
+ .selectFrom("TournamentResult")
+ .innerJoin(
+ "TournamentTeam",
+ "TournamentTeam.id",
+ "TournamentResult.tournamentTeamId",
+ )
+ .select("TournamentResult.userId")
+ .distinct()
+ .where("TournamentResult.tournamentId", "=", tournamentId)
+ .where("TournamentTeam.droppedOut", "=", 0)
+ .execute();
+}
+
export async function findTOSetMapPoolById(tournamentId: number) {
return (
await db
diff --git a/app/features/user-card/UserCardRepository.server.test.ts b/app/features/user-card/UserCardRepository.server.test.ts
new file mode 100644
index 000000000..4e34aafea
--- /dev/null
+++ b/app/features/user-card/UserCardRepository.server.test.ts
@@ -0,0 +1,161 @@
+import { afterEach, beforeEach, describe, expect, it } from "vitest";
+import { db } from "~/db/sql";
+import { dbInsertUsers, dbReset, withNoUser, withUserId } from "~/utils/Test";
+import * as UserCardRepository from "./UserCardRepository.server";
+
+describe("UserCardRepository.userCards", () => {
+ beforeEach(async () => {
+ await dbInsertUsers(2);
+ });
+
+ afterEach(() => {
+ dbReset();
+ });
+
+ it("returns an empty map when given no user ids", async () => {
+ const { userCards } = await withNoUser(() =>
+ UserCardRepository.userCards({
+ userIds: [],
+ }),
+ );
+
+ expect(userCards.size).toBe(0);
+ });
+
+ it("keys cards by user id and builds the stats array from db fields", async () => {
+ await db
+ .updateTable("User")
+ .set({
+ div: "1",
+ unverifiedPeakXP: JSON.stringify({
+ overall: 3000,
+ takoroka: 3000,
+ tentatek: null,
+ }),
+ })
+ .where("id", "=", 1)
+ .execute();
+ await db.insertInto("PlusTier").values({ userId: 1, tier: 2 }).execute();
+
+ const { userCards } = await withNoUser(() =>
+ UserCardRepository.userCards({
+ userIds: [1, 2],
+ }),
+ );
+
+ expect(userCards.size).toBe(2);
+
+ const card = userCards.get(1);
+ expect(card?.id).toBe(1);
+ expect(card?.freeAgentPostId).toBeNull();
+
+ const statTypes = card?.stats.map((stat) => stat.type) ?? [];
+ expect(statTypes).toContain("XP");
+ expect(statTypes).toContain("DIV");
+ expect(statTypes).toContain("PLUS");
+
+ expect(card?.stats.find((stat) => stat.type === "XP")).toMatchObject({
+ type: "XP",
+ values: [{ isVerified: false, region: "JPN", points: 3000 }],
+ });
+ expect(card?.stats.find((stat) => stat.type === "DIV")).toMatchObject({
+ type: "DIV",
+ value: "1",
+ });
+ expect(card?.stats.find((stat) => stat.type === "PLUS")).toMatchObject({
+ type: "PLUS",
+ value: 2,
+ });
+
+ // user 2 has none of the optional fields -> no stats
+ expect(userCards.get(2)?.stats).toHaveLength(0);
+ });
+
+ it("persists edited card fields and surfaces hidden stats", async () => {
+ await db.insertInto("PlusTier").values({ userId: 1, tier: 2 }).execute();
+
+ await withUserId(1, () =>
+ UserCardRepository.updateOwnCard({
+ shortBio: "hello",
+ bannerPresetImg: "#ff4655",
+ bannerImgId: null,
+ unverifiedPeakXP: { overall: 2500, takoroka: null, tentatek: 2500 },
+ hiddenCardStats: ["XP"],
+ }),
+ );
+
+ const { userCards } = await withUserId(1, () =>
+ UserCardRepository.userCards({
+ userIds: [1],
+ }),
+ );
+ const card = userCards.get(1);
+
+ expect(card?.shortBio).toBe("hello");
+ expect(card?.banner).toMatchObject({ type: "COLOR", hexCode: "#ff4655" });
+ // the hidden stat is filtered out of `stats` at query time
+ expect(card?.stats.find((stat) => stat.type === "XP")).toBeUndefined();
+ expect(card?.stats.find((stat) => stat.type === "PLUS")).toMatchObject({
+ type: "PLUS",
+ value: 2,
+ });
+
+ const extras = await UserCardRepository.cardEditExtras(1);
+ expect(extras.hiddenCardStats).toEqual(["XP"]);
+ });
+
+ it("keeps hidden stats in `stats` when includeHiddenStats is set", async () => {
+ await db.insertInto("PlusTier").values({ userId: 1, tier: 2 }).execute();
+
+ await withUserId(1, () =>
+ UserCardRepository.updateOwnCard({
+ shortBio: null,
+ bannerPresetImg: null,
+ bannerImgId: null,
+ unverifiedPeakXP: { overall: 2500, takoroka: null, tentatek: 2500 },
+ hiddenCardStats: ["XP"],
+ }),
+ );
+
+ const { userCards } = await withUserId(1, () =>
+ UserCardRepository.userCards({
+ userIds: [1],
+ includeHiddenStats: true,
+ }),
+ );
+ const card = userCards.get(1);
+
+ expect(card?.stats.find((stat) => stat.type === "XP")).toMatchObject({
+ type: "XP",
+ values: [{ isVerified: false, region: "WEST", points: 2500 }],
+ });
+ });
+
+ it("produces a URL banner when an uploaded banner image is set", async () => {
+ const image = await db
+ .insertInto("UnvalidatedUserSubmittedImage")
+ .values({ url: "banner.webp", submitterUserId: 1, validatedAt: 1 })
+ .returning("id")
+ .executeTakeFirstOrThrow();
+
+ await withUserId(1, () =>
+ UserCardRepository.updateOwnCard({
+ shortBio: null,
+ bannerPresetImg: null,
+ bannerImgId: image.id,
+ unverifiedPeakXP: null,
+ hiddenCardStats: [],
+ }),
+ );
+
+ const { userCards } = await withUserId(1, () =>
+ UserCardRepository.userCards({
+ userIds: [1],
+ }),
+ );
+
+ const banner = userCards.get(1)?.banner;
+ expect(banner?.type).toBe("URL");
+ expect(banner).toHaveProperty("url");
+ });
+});
diff --git a/app/features/user-card/UserCardRepository.server.ts b/app/features/user-card/UserCardRepository.server.ts
new file mode 100644
index 000000000..d0b3dde0b
--- /dev/null
+++ b/app/features/user-card/UserCardRepository.server.ts
@@ -0,0 +1,563 @@
+import { sub } from "date-fns";
+import type { Expression, ExpressionBuilder } from "kysely";
+import { sql } from "kysely";
+import { jsonBuildObject, jsonObjectFrom } from "kysely/helpers/sqlite";
+import { db } from "~/db/sql";
+import type {
+ CustomTheme,
+ HideableUserCardStat,
+ PeakXP,
+ Tables,
+ XRankPlacementRegion,
+} from "~/db/tables";
+import { actorId, actorIdOrNull } from "~/features/auth/core/user.server";
+import { cachedFullUserLeaderboard } from "~/features/leaderboards/core/leaderboards.server";
+import { LFG } from "~/features/lfg/lfg-constants";
+import * as Seasons from "~/features/mmr/core/Seasons";
+import { TIERS } from "~/features/mmr/mmr-constants";
+import type { TieredSkill } from "~/features/mmr/tiered.server";
+import { userSkills } from "~/features/mmr/tiered.server";
+import type { StageId } from "~/modules/in-game-lists/types";
+import { dateToDatabaseTimestamp } from "~/utils/dates";
+import {
+ commonUserObjectFields,
+ concatUserSubmittedImagePrefix,
+} from "~/utils/kysely.server";
+import { PRESET_COLORS } from "../tier-list-maker/tier-list-maker-constants";
+import type {
+ UserCardData,
+ UserCardStat,
+ UserCardStatXPValue,
+} from "./user-card-types";
+
+/**
+ * Loads `UserCardData` for many users at once, keyed by user id. The single batched DB query (see
+ * {@link userCardDataJsonObject}) is merged with the in-memory SEASON caches (tier from
+ * `userSkills`, leaderboard placement from `cachedFullUserLeaderboard`) in this app-layer enrich
+ * pass, producing the fully-formed `stats` array each card renders. The acting user viewing the
+ * cards (resolved from request context via `actorIdOrNull()`, or `null` when anonymous) scopes the
+ * per-viewer `privateNote`.
+ *
+ * Designed to be spread into a route loader (`{ ...(await userCards(...)) }`) so the `UserCard`
+ * component can resolve its own data from the route tree by id.
+ */
+export async function userCards({
+ userIds,
+ include,
+ includeHiddenStats = false,
+}: {
+ userIds: Array
;
+ /** Opt-in fields skipped from the query by default; defaults to `false` each. */
+ include?: { friendCode?: boolean };
+ /**
+ * Keep stats the user has hidden in the resolved `stats` array. Off by default so hidden stat
+ * values never reach a viewer; the edit page opts in to render (and un-hide) its own toggles.
+ */
+ includeHiddenStats?: boolean;
+}): Promise<{ userCards: Map }> {
+ if (userIds.length === 0) return { userCards: new Map() };
+
+ const viewerId = actorIdOrNull();
+
+ // a user's card surfaces the better of their last two finished seasons (see bestSeasonResult)
+ const [rows, seasonResults] = await Promise.all([
+ db
+ .selectFrom("User")
+ .select((eb) =>
+ userCardDataJsonObject(eb, { viewerId, include }).as("cardData"),
+ )
+ .where("User.id", "in", userIds)
+ .execute(),
+ Promise.all(
+ Seasons.allFinished()
+ .slice(0, 2)
+ .map((season) => seasonResult(season, userIds)),
+ ),
+ ]);
+
+ const userCards = new Map();
+ for (const { cardData } of rows) {
+ userCards.set(
+ cardData.id,
+ enrichUserCardData(
+ cardData,
+ bestSeasonResult(cardData.id, seasonResults),
+ includeHiddenStats,
+ ),
+ );
+ }
+
+ return { userCards };
+}
+
+/**
+ * Raw card fields the edit form needs that are not part of {@link UserCardData}: the uploaded banner
+ * image (id + preview url, for the image field's default value), the self-reported peak XP, and the
+ * hidden stat types (to pre-check the visibility toggles).
+ */
+export async function cardEditExtras(userId: number) {
+ const row = await db
+ .selectFrom("User")
+ .select((eb) => [
+ "User.bannerImgId",
+ "User.unverifiedPeakXP",
+ "User.hiddenCardStats",
+ bannerImageUrl(eb).as("bannerImageUrl"),
+ ])
+ .where("User.id", "=", userId)
+ .executeTakeFirst();
+
+ return {
+ bannerImgId: row?.bannerImgId ?? null,
+ bannerImageUrl: row?.bannerImageUrl ?? null,
+ unverifiedPeakXP: row?.unverifiedPeakXP ?? null,
+ hiddenCardStats: row?.hiddenCardStats ?? [],
+ };
+}
+
+/** Updates the editable user card fields of the acting user (their own card). */
+export function updateOwnCard(args: {
+ shortBio: string | null;
+ bannerPresetImg: string | null;
+ bannerImgId: number | null;
+ unverifiedPeakXP: PeakXP | null;
+ hiddenCardStats: Array;
+}) {
+ const userId = actorId();
+ return db.transaction().execute(async (trx) => {
+ // a removed or replaced uploaded banner is no longer referenced by anything,
+ // so its submitted image row is cleaned up (mirrors custom avatar handling)
+ const current = await trx
+ .selectFrom("User")
+ .select("User.bannerImgId")
+ .where("id", "=", userId)
+ .executeTakeFirst();
+ if (current?.bannerImgId && current.bannerImgId !== args.bannerImgId) {
+ await trx
+ .deleteFrom("UnvalidatedUserSubmittedImage")
+ .where("id", "=", current.bannerImgId)
+ .where("UnvalidatedUserSubmittedImage.submitterUserId", "=", userId)
+ .execute();
+ }
+
+ await trx
+ .updateTable("User")
+ .set({
+ shortBio: args.shortBio,
+ bannerPresetImg: args.bannerPresetImg,
+ bannerImgId: args.bannerImgId,
+ unverifiedPeakXP: args.unverifiedPeakXP
+ ? JSON.stringify(args.unverifiedPeakXP)
+ : null,
+ hiddenCardStats:
+ args.hiddenCardStats.length > 0
+ ? JSON.stringify(args.hiddenCardStats)
+ : null,
+ })
+ .where("id", "=", userId)
+ .execute();
+ });
+}
+
+/** SQLite `case` expression mapping `User.id % PRESET_COLORS.length` to a preset banner color. */
+const BANNER_PRESET_COLOR_CASE = `case "User"."id" % ${PRESET_COLORS.length}\n${PRESET_COLORS.map(
+ (color, index) => `when ${index} then '${color}'`,
+).join("\n")}\nend`;
+
+/**
+ * Kysely expression building the JSON object for all DB-resident `UserCard` fields of a single user.
+ * Designed to be composed both standalone (one user) and inside a batched list query (see
+ * {@link userCards}). `"User"` must be in scope at the call site.
+ *
+ * SEASON stats (tier + leaderboard placement) are NOT included here — they live in the in-memory
+ * `userSkills`/leaderboard caches and are merged in an app-layer enrich pass. `banner` is returned as
+ * loosely-typed fields (narrow to the discriminated union there). `friendCode` is opt-in via
+ * `include.friendCode` (defaults to off, resolving to `null`) so callers that never surface it skip
+ * the extra correlated subquery.
+ */
+function userCardDataJsonObject(
+ eb: ExpressionBuilder,
+ {
+ viewerId,
+ include,
+ }: {
+ viewerId: number | null;
+ include?: { friendCode?: boolean };
+ },
+) {
+ return jsonBuildObject({
+ ...commonUserObjectFields(eb),
+ shortBio: eb.ref("User.shortBio"),
+ div: eb.ref("User.div"),
+ customTheme: sql`IIF(COALESCE("User"."patronTier", 0) >= 2, "User"."customTheme", null)`,
+ hiddenCardStats: eb.ref("User.hiddenCardStats"),
+ banner: bannerJson(eb),
+ friendCode: include?.friendCode
+ ? friendCodeScalar(eb)
+ : sql`null`,
+ privateNote: privateNoteJson(eb, viewerId),
+ freeAgentPostId: freeAgentPostIdScalar(eb),
+ plusTier: plusTierScalar(eb),
+ xpVerified: xpVerifiedJson(eb),
+ xpUnverified: xpUnverifiedJson(),
+ });
+}
+
+type RawUserCardData =
+ ReturnType extends Expression
+ ? T
+ : never;
+
+/**
+ * Loosely-typed banner. A supporter-uploaded image (`User.bannerImgId`) takes precedence and yields
+ * a `URL` banner; otherwise it is pulled from the `User.bannerPresetImg` column ("hex code or stage
+ * id") where a numeric value is a stage id (`STAGE`) and anything else a `COLOR` hex code. When both
+ * are null (no explicit choice) a preset color is derived from the user id. Narrow to the
+ * `{ URL | COLOR | STAGE }` union in the enrich pass.
+ */
+function bannerJson(eb: ExpressionBuilder) {
+ return jsonBuildObject({
+ type: sql<"URL" | "COLOR" | "STAGE">`
+ case
+ when "User"."bannerImgId" is not null then 'URL'
+ when "User"."bannerPresetImg" GLOB '[0-9]*' then 'STAGE'
+ else 'COLOR'
+ end`,
+ url: bannerImageUrl(eb),
+ hexCode: sql`
+ case
+ when "User"."bannerPresetImg" is null then (${sql.raw(BANNER_PRESET_COLOR_CASE)})
+ when "User"."bannerPresetImg" GLOB '[0-9]*' then null
+ else "User"."bannerPresetImg"
+ end`,
+ stageId: sql<
+ number | null
+ >`iif("User"."bannerPresetImg" GLOB '[0-9]*', CAST("User"."bannerPresetImg" AS INTEGER), null)`,
+ });
+}
+
+/** Full URL of the supporter-uploaded banner image (resolved from `User.bannerImgId`), or null. */
+function bannerImageUrl(eb: ExpressionBuilder) {
+ return concatUserSubmittedImagePrefix(
+ eb
+ .selectFrom("UserSubmittedImage")
+ .select("UserSubmittedImage.url")
+ .whereRef("UserSubmittedImage.id", "=", "User.bannerImgId")
+ .$asScalar(),
+ ).$castTo();
+}
+
+function friendCodeScalar(eb: ExpressionBuilder) {
+ return eb
+ .selectFrom("UserFriendCode")
+ .select("UserFriendCode.friendCode")
+ .whereRef("UserFriendCode.userId", "=", "User.id")
+ .orderBy("UserFriendCode.createdAt", "desc")
+ .limit(1)
+ .$asScalar();
+}
+
+function privateNoteJson(
+ eb: ExpressionBuilder,
+ viewerId: number | null,
+) {
+ if (viewerId === null) {
+ return sql | null>`null`;
+ }
+
+ return jsonObjectFrom(
+ eb
+ .selectFrom("PrivateUserNote")
+ .select([
+ "PrivateUserNote.text",
+ "PrivateUserNote.sentiment",
+ "PrivateUserNote.updatedAt",
+ ])
+ .where("PrivateUserNote.authorId", "=", viewerId)
+ .whereRef("PrivateUserNote.targetId", "=", "User.id"),
+ );
+}
+
+/**
+ * Id of the user's most recent non-expired "looking for team" LFG post, which marks them as a free
+ * agent. `null` when they have no such post. Mirrors the LFG page's freshness cutoff so the id always
+ * points at a post that is still listed there.
+ */
+function freeAgentPostIdScalar(eb: ExpressionBuilder) {
+ return eb
+ .selectFrom("LFGPost")
+ .select("LFGPost.id")
+ .whereRef("LFGPost.authorId", "=", "User.id")
+ .where("LFGPost.type", "=", "PLAYER_FOR_TEAM")
+ .where(
+ "LFGPost.updatedAt",
+ ">",
+ dateToDatabaseTimestamp(
+ sub(new Date(), { days: LFG.POST_FRESHNESS_DAYS }),
+ ),
+ )
+ .orderBy("LFGPost.updatedAt", "desc")
+ .limit(1)
+ .$asScalar();
+}
+
+function plusTierScalar(eb: ExpressionBuilder) {
+ return eb
+ .selectFrom("PlusTier")
+ .select("PlusTier.tier")
+ .whereRef("PlusTier.userId", "=", "User.id")
+ .$asScalar();
+}
+
+/** Single highest X Rank power placement (verified XP). */
+function xpVerifiedJson(eb: ExpressionBuilder) {
+ return jsonObjectFrom(
+ eb
+ .selectFrom("XRankPlacement")
+ .innerJoin(
+ "SplatoonPlayer",
+ "SplatoonPlayer.id",
+ "XRankPlacement.playerId",
+ )
+ .whereRef("SplatoonPlayer.userId", "=", "User.id")
+ .select([
+ sql`"XRankPlacement"."power"`.as("points"),
+ "XRankPlacement.region",
+ ])
+ .orderBy("XRankPlacement.power", "desc")
+ .limit(1),
+ );
+}
+
+/**
+ * Self-reported peak XP from the `User.unverifiedPeakXP` column. Has exactly one of `tentatek` /
+ * `takoroka` defined, which decides the region (`tentatek` = `WEST`, `takoroka` = `JPN`); `points`
+ * is that region's value.
+ */
+function xpUnverifiedJson() {
+ return sql<{ points: number; region: XRankPlacementRegion } | null>`
+ iif(
+ "User"."unverifiedPeakXP" is null,
+ null,
+ json_object(
+ 'points', "User"."unverifiedPeakXP" ->> '$.overall',
+ 'region', iif("User"."unverifiedPeakXP" ->> '$.tentatek' is not null, 'WEST', 'JPN')
+ )
+ )
+ `;
+}
+
+type SeasonResult = {
+ skills: Record;
+ placementsByUserId: Map;
+};
+
+/**
+ * Resolves one finished season's data for the requested users. `userSkills` is a synchronous
+ * in-memory cache, so we read tiers first and only fetch the (DB-backed) leaderboard when at least
+ * one requested user reached Leviathan+ that season—placements are surfaced for that rank only, so
+ * the common case of regular users never touches the leaderboard cache at all.
+ */
+async function seasonResult(
+ season: number,
+ userIds: Array,
+): Promise {
+ const skills = userSkills(season).userSkills;
+
+ const anyLeviathanPlus = userIds.some((id) => {
+ const skill = skills[id];
+ return (
+ skill !== undefined && !skill.approximate && isLeviathanPlus(skill.tier)
+ );
+ });
+
+ const placementsByUserId = anyLeviathanPlus
+ ? await finishedSeasonPlacements(season)
+ : new Map();
+
+ return { skills, placementsByUserId };
+}
+
+const placementsBySeason = new Map>();
+
+/**
+ * Leaderboard placements of a finished season, keyed by user id. Cached for the process lifetime:
+ * a finished season's leaderboard is immutable, matching how `userSkills` already holds finished
+ * seasons' tiers permanently. This keeps cards off `cachedFullUserLeaderboard`'s TTL, whose
+ * synchronous rebuild would otherwise stall the first Leviathan+ card render after a quiet period.
+ */
+async function finishedSeasonPlacements(
+ season: number,
+): Promise