mirror of
https://github.com/Sendouc/sendou.ink.git
synced 2026-09-13 06:36:49 -05:00
Data loading progress
This commit is contained in:
@@ -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) {
|
||||
|
||||
@@ -500,12 +500,21 @@ export interface SeedingSkill {
|
||||
type: "RANKED" | "UNRANKED";
|
||||
}
|
||||
|
||||
interface PeakXP {
|
||||
/** Peak XP across all divisions */
|
||||
overall: number;
|
||||
/** Peak XP (Takoroka division) */
|
||||
takoroka: number | null;
|
||||
/** Peak XP (Tentatek division) */
|
||||
tentatek: number | null;
|
||||
}
|
||||
|
||||
export interface SplatoonPlayer {
|
||||
id: GeneratedAlways<number>;
|
||||
splId: string;
|
||||
userId: number | null;
|
||||
/** Players best XP across both divisions. Denormalized for performance. */
|
||||
peakXp: number | null;
|
||||
peakXp: JSONColumnTypeNullable<PeakXP>;
|
||||
}
|
||||
|
||||
export interface TaggedArt {
|
||||
@@ -1091,7 +1100,10 @@ export interface User {
|
||||
/** 1 = permabanned, timestamp = ban active till then */
|
||||
banned: Generated<number | null>;
|
||||
bannedReason: string | null;
|
||||
/** Shown on old user profile and Plus Voting */
|
||||
bio: string | null;
|
||||
/** Shown on user card */
|
||||
shortBio: string | null;
|
||||
commissionsOpen: Generated<number | null>;
|
||||
commissionsOpenedAt: number | null;
|
||||
commissionText: string | null;
|
||||
@@ -1136,8 +1148,13 @@ export interface User {
|
||||
/** User creation date. Can be null because we did not always save this. */
|
||||
createdAt: number | null;
|
||||
joinOrder: number | null;
|
||||
/** Last message used when creating a tournament sub post */
|
||||
lastSubMessage: string | null;
|
||||
// xxx: add bannerImgId
|
||||
/** User card banner default selection, hex code or stage id. Note: supporters can also upload banner (stored in UserSubmittedImage) */
|
||||
bannerPresetImg: JSONColumnTypeNullable<string | StageId>;
|
||||
/** Div in the latest finished LUTI (e.g. "2" or "X"). Must have been in a team that did not drop and the user played at least one match (got result as well) */
|
||||
div: string | null;
|
||||
/** Peak XP as indicated by the user. Should have either `takoroka` or `tentatek` key defined but not both. */
|
||||
unverifiedPeakXP: JSONColumnTypeNullable<PeakXP>;
|
||||
}
|
||||
|
||||
/** Represents User joined with PlusTier table */
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { sql } from "kysely";
|
||||
import { jsonArrayFrom } from "kysely/helpers/sqlite";
|
||||
import type { LoaderFunctionArgs } from "react-router";
|
||||
import { z } from "zod";
|
||||
@@ -43,7 +44,9 @@ export const loader = async ({ params }: LoaderFunctionArgs) => {
|
||||
.whereRef("UserWeapon.userId", "=", "User.id")
|
||||
.orderBy("UserWeapon.order", "asc"),
|
||||
).as("weapons"),
|
||||
"SplatoonPlayer.peakXp",
|
||||
sql<number | null>`"SplatoonPlayer"."peakXp" ->> '$.overall'`.as(
|
||||
"peakXp",
|
||||
),
|
||||
jsonArrayFrom(
|
||||
eb
|
||||
.selectFrom("TeamMemberWithSecondary")
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { ExpressionBuilder, NotNull } from "kysely";
|
||||
import { type ExpressionBuilder, type NotNull, sql } from "kysely";
|
||||
import { jsonArrayFrom, jsonObjectFrom } from "kysely/helpers/sqlite";
|
||||
import { db } from "~/db/sql";
|
||||
import type { DB } from "~/db/tables";
|
||||
@@ -232,7 +232,12 @@ export async function syncXPBadges() {
|
||||
|
||||
const userTopXPowers = await trx
|
||||
.selectFrom("SplatoonPlayer")
|
||||
.select(["userId", "peakXp"])
|
||||
.select([
|
||||
"userId",
|
||||
sql<number | null>`"SplatoonPlayer"."peakXp" ->> '$.overall'`.as(
|
||||
"peakXp",
|
||||
),
|
||||
])
|
||||
.where("userId", "is not", null)
|
||||
.where("peakXp", "is not", null)
|
||||
.$narrowType<{ userId: NotNull; peakXp: NotNull }>()
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { sql } from "kysely";
|
||||
import { db } from "~/db/sql";
|
||||
import type { Tables, TablesInsertable } from "~/db/tables";
|
||||
import { commonUserSelect } from "~/utils/kysely.server";
|
||||
@@ -35,14 +36,14 @@ export function findXRankStreams() {
|
||||
.innerJoin("User", "User.twitch", "LiveStream.twitch")
|
||||
.innerJoin("SplatoonPlayer", "SplatoonPlayer.userId", "User.id")
|
||||
.where(
|
||||
"SplatoonPlayer.peakXp",
|
||||
sql<number>`"SplatoonPlayer"."peakXp" ->> '$.overall'`,
|
||||
">=",
|
||||
StreamRanking.minXpForStreamToBeShown(),
|
||||
)
|
||||
.where("LiveStream.twitch", "is not", null)
|
||||
.select((eb) => [
|
||||
...commonUserSelect(eb),
|
||||
"SplatoonPlayer.peakXp",
|
||||
sql<number>`"SplatoonPlayer"."peakXp" ->> '$.overall'`.as("peakXp"),
|
||||
"LiveStream.viewerCount",
|
||||
"LiveStream.thumbnailUrl",
|
||||
"LiveStream.twitch as twitchUsername",
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import clsx from "clsx";
|
||||
import type { SqlBool } from "kysely";
|
||||
import { Mic, PenSquare, Star, Trash, Volume2, VolumeX } from "lucide-react";
|
||||
import { Mic, PenSquare, Star, Volume2, VolumeX } from "lucide-react";
|
||||
import * as React from "react";
|
||||
import { Flipped } from "react-flip-toolkit";
|
||||
import { useTranslation } from "react-i18next";
|
||||
@@ -8,15 +8,14 @@ import { Link, useFetcher } from "react-router";
|
||||
import { Avatar } from "~/components/Avatar";
|
||||
import { LinkButton, SendouButton } from "~/components/elements/Button";
|
||||
import { SendouPopover } from "~/components/elements/Popover";
|
||||
import { FormWithConfirm } from "~/components/FormWithConfirm";
|
||||
import { Image, ModeImage, TierImage, WeaponImage } from "~/components/Image";
|
||||
import { LocaleTime } from "~/components/LocaleTime";
|
||||
import { SubmitButton } from "~/components/SubmitButton";
|
||||
import type { ParsedMemento } from "~/db/tables";
|
||||
import { useUser } from "~/features/auth/core/user";
|
||||
import { MATCHES_COUNT_NEEDED_FOR_LEADERBOARD } from "~/features/leaderboards/leaderboards-constants";
|
||||
import { ordinalToRoundedSp } from "~/features/mmr/mmr-utils";
|
||||
import type { TieredSkill } from "~/features/mmr/tiered.server";
|
||||
import { UserCard } from "~/features/user-card/components/UserCard";
|
||||
import { languagesUnified } from "~/modules/i18n/config";
|
||||
import { SPLATTERCOLOR_SCREEN_ID } from "~/modules/in-game-lists/weapon-ids";
|
||||
import { inGameNameWithoutDiscriminator } from "~/utils/strings";
|
||||
@@ -26,7 +25,6 @@ import {
|
||||
specialWeaponImageUrl,
|
||||
TIERS_PAGE,
|
||||
tierImageUrl,
|
||||
userPage,
|
||||
} from "~/utils/urls";
|
||||
import type {
|
||||
SQGroup,
|
||||
@@ -37,11 +35,7 @@ import { FULL_GROUP_SIZE, SENDOUQ } from "../q-constants";
|
||||
import { resolveFutureMatchModes } from "../q-utils";
|
||||
import styles from "./GroupCard.module.css";
|
||||
|
||||
const SENTIMENT_STYLES = {
|
||||
POSITIVE: styles.avatarPositive,
|
||||
NEUTRAL: styles.avatarNeutral,
|
||||
NEGATIVE: styles.avatarNegative,
|
||||
} as const;
|
||||
// xxx: red cross to indicate negative note left?
|
||||
|
||||
export function GroupCard({
|
||||
group,
|
||||
@@ -51,7 +45,6 @@ export function GroupCard({
|
||||
hideWeapons = false,
|
||||
hideNote: _hidenote = false,
|
||||
showAddNote,
|
||||
showNote = false,
|
||||
ownGroup,
|
||||
layout = "desktop",
|
||||
}: {
|
||||
@@ -62,7 +55,6 @@ export function GroupCard({
|
||||
hideWeapons?: SqlBool;
|
||||
hideNote?: boolean;
|
||||
showAddNote?: SqlBool;
|
||||
showNote?: boolean;
|
||||
ownGroup?: SQOwnGroup;
|
||||
layout?: "mobile" | "desktop";
|
||||
}) {
|
||||
@@ -104,7 +96,6 @@ export function GroupCard({
|
||||
hideWeapons={hideWeapons}
|
||||
hideNote={hideNote}
|
||||
enableKicking={enableKicking}
|
||||
showNote={showNote}
|
||||
showAddNote={showAddNote && member.id !== user?.id}
|
||||
/>
|
||||
);
|
||||
@@ -264,7 +255,6 @@ function GroupMember({
|
||||
hideNote,
|
||||
enableKicking,
|
||||
showAddNote,
|
||||
showNote,
|
||||
}: {
|
||||
member: SQGroupMember;
|
||||
showActions: boolean;
|
||||
@@ -274,7 +264,6 @@ function GroupMember({
|
||||
hideNote?: boolean;
|
||||
enableKicking?: boolean;
|
||||
showAddNote?: SqlBool;
|
||||
showNote?: boolean;
|
||||
}) {
|
||||
const { t } = useTranslation(["q", "user"]);
|
||||
const user = useUser();
|
||||
@@ -283,60 +272,23 @@ function GroupMember({
|
||||
<div className="stack xxs" data-testid="sendouq-group-card-member">
|
||||
<div className={styles.member}>
|
||||
<div className="text-main-forced stack xs horizontal items-center">
|
||||
{showNote && member.privateNote ? (
|
||||
<SendouPopover
|
||||
trigger={
|
||||
<SendouButton variant="minimal">
|
||||
<Avatar
|
||||
user={member}
|
||||
size="xs"
|
||||
className={clsx(
|
||||
styles.avatar,
|
||||
SENTIMENT_STYLES[member.privateNote.sentiment],
|
||||
)}
|
||||
/>
|
||||
</SendouButton>
|
||||
}
|
||||
>
|
||||
{member.privateNote.text}
|
||||
<div
|
||||
className={clsx(
|
||||
"stack sm horizontal justify-between items-center",
|
||||
{ "mt-2": member.privateNote.text },
|
||||
<UserCard userId={member.id}>
|
||||
<span className="stack xs horizontal items-center">
|
||||
<Avatar user={member} size="xs" />
|
||||
<span className={styles.name}>
|
||||
{member.inGameName ? (
|
||||
<>
|
||||
<span className="text-lighter font-bold text-xxxs">
|
||||
{t("user:ign.short")}:
|
||||
</span>{" "}
|
||||
{inGameNameWithoutDiscriminator(member.inGameName)}
|
||||
</>
|
||||
) : (
|
||||
member.username
|
||||
)}
|
||||
>
|
||||
<LocaleTime
|
||||
date={member.privateNote.updatedAt}
|
||||
options={{
|
||||
hour: "numeric",
|
||||
minute: "numeric",
|
||||
day: "numeric",
|
||||
month: "numeric",
|
||||
year: "numeric",
|
||||
}}
|
||||
className="text-xxs text-lighter"
|
||||
/>
|
||||
<DeletePrivateNoteForm
|
||||
name={member.username}
|
||||
targetId={member.id}
|
||||
/>
|
||||
</div>
|
||||
</SendouPopover>
|
||||
) : (
|
||||
<Avatar user={member} size="xs" />
|
||||
)}
|
||||
<Link to={userPage(member)} className={styles.name}>
|
||||
{member.inGameName ? (
|
||||
<>
|
||||
<span className="text-lighter font-bold text-xxxs">
|
||||
{t("user:ign.short")}:
|
||||
</span>{" "}
|
||||
{inGameNameWithoutDiscriminator(member.inGameName)}
|
||||
</>
|
||||
) : (
|
||||
member.username
|
||||
)}
|
||||
</Link>
|
||||
</span>
|
||||
</span>
|
||||
</UserCard>
|
||||
{member.pronouns ? (
|
||||
<span className="text-lighter ml-1 text-xxxs">
|
||||
{member.pronouns.subject}/{member.pronouns.object}
|
||||
@@ -525,30 +477,6 @@ function AddPrivateNoteForm({
|
||||
);
|
||||
}
|
||||
|
||||
function DeletePrivateNoteForm({
|
||||
targetId,
|
||||
name,
|
||||
}: {
|
||||
targetId: number;
|
||||
name: string;
|
||||
}) {
|
||||
const { t } = useTranslation(["q"]);
|
||||
|
||||
return (
|
||||
<FormWithConfirm
|
||||
dialogHeading={t("q:privateNote.delete.header", { name })}
|
||||
fields={[
|
||||
["targetId", targetId],
|
||||
["_action", "DELETE_PRIVATE_USER_NOTE"],
|
||||
]}
|
||||
>
|
||||
<SubmitButton variant="minimal-destructive" size="small" type="submit">
|
||||
<Trash className="small-icon" />
|
||||
</SubmitButton>
|
||||
</FormWithConfirm>
|
||||
);
|
||||
}
|
||||
|
||||
function GroupSkillDifference({
|
||||
skillDifference,
|
||||
}: {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -76,6 +76,8 @@ export default function QLookingShell() {
|
||||
return <QLookingPage />;
|
||||
}
|
||||
|
||||
// xxx: update prompt to fill the profile to include UserCard stuff
|
||||
|
||||
function QLookingPage() {
|
||||
const { t } = useTranslation(["q"]);
|
||||
const user = useUser();
|
||||
@@ -258,7 +260,6 @@ function Groups() {
|
||||
key={group.id}
|
||||
group={group}
|
||||
action="UNLIKE"
|
||||
showNote
|
||||
ownGroup={data.ownGroup}
|
||||
layout={layout}
|
||||
/>
|
||||
@@ -272,7 +273,7 @@ function Groups() {
|
||||
<ColumnHeader isMobile={isMobile}>
|
||||
{t("q:looking.columns.myGroup")}
|
||||
</ColumnHeader>
|
||||
<GroupCard group={data.ownGroup} showNote ownGroup={data.ownGroup} />
|
||||
<GroupCard group={data.ownGroup} ownGroup={data.ownGroup} />
|
||||
{data.ownGroup.inviteCode ? (
|
||||
<MemberAdder
|
||||
inviteCode={data.ownGroup.inviteCode}
|
||||
@@ -360,7 +361,6 @@ function Groups() {
|
||||
? "UNLIKE"
|
||||
: "LIKE"
|
||||
}
|
||||
showNote
|
||||
ownGroup={data.ownGroup}
|
||||
layout={layout}
|
||||
/>
|
||||
@@ -388,7 +388,6 @@ function Groups() {
|
||||
key={group.id}
|
||||
group={group}
|
||||
action={action()}
|
||||
showNote
|
||||
ownGroup={data.ownGroup}
|
||||
layout={layout}
|
||||
/>
|
||||
@@ -426,7 +425,6 @@ function Groups() {
|
||||
key={group.id}
|
||||
group={group}
|
||||
action={action()}
|
||||
showNote
|
||||
ownGroup={data.ownGroup}
|
||||
layout={layout}
|
||||
/>
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -132,12 +132,23 @@ export type FindPlacement = InferResult<
|
||||
export async function refreshAllPeakXp() {
|
||||
await db
|
||||
.updateTable("SplatoonPlayer")
|
||||
.set((eb) => ({
|
||||
peakXp: eb
|
||||
.selectFrom("XRankPlacement")
|
||||
.select((eb) => eb.fn.max("XRankPlacement.power").as("peakXp"))
|
||||
.whereRef("XRankPlacement.playerId", "=", "SplatoonPlayer.id"),
|
||||
}))
|
||||
.set({
|
||||
// denormalized PeakXP json: overall + per-division peaks
|
||||
// (region WEST = Tentatek, otherwise Takoroka). null when no placements.
|
||||
peakXp: sql<string | null>`(
|
||||
select iif(
|
||||
max("XRankPlacement"."power") is null,
|
||||
null,
|
||||
json_object(
|
||||
'overall', max("XRankPlacement"."power"),
|
||||
'tentatek', max(iif("XRankPlacement"."region" = 'WEST', "XRankPlacement"."power", null)),
|
||||
'takoroka', max(iif("XRankPlacement"."region" != 'WEST', "XRankPlacement"."power", null))
|
||||
)
|
||||
)
|
||||
from "XRankPlacement"
|
||||
where "XRankPlacement"."playerId" = "SplatoonPlayer"."id"
|
||||
)`,
|
||||
})
|
||||
.execute();
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
71
app/features/user-card/UserCardRepository.server.test.ts
Normal file
71
app/features/user-card/UserCardRepository.server.test.ts
Normal file
@@ -0,0 +1,71 @@
|
||||
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||
import { db } from "~/db/sql";
|
||||
import { dbInsertUsers, dbReset } from "~/utils/Test";
|
||||
import * as UserCardRepository from "./UserCardRepository.server";
|
||||
|
||||
describe("UserCardRepository.userCards", () => {
|
||||
beforeEach(async () => {
|
||||
await dbInsertUsers(2);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
dbReset();
|
||||
});
|
||||
|
||||
it("returns an empty map when given no user ids", async () => {
|
||||
const { userCards } = await UserCardRepository.userCards({
|
||||
userIds: [],
|
||||
viewerId: null,
|
||||
});
|
||||
|
||||
expect(userCards.size).toBe(0);
|
||||
});
|
||||
|
||||
it("keys cards by user id and builds the stats array from db fields", async () => {
|
||||
await db
|
||||
.updateTable("User")
|
||||
.set({
|
||||
div: "1",
|
||||
unverifiedPeakXP: JSON.stringify({
|
||||
overall: 3000,
|
||||
takoroka: 3000,
|
||||
tentatek: null,
|
||||
}),
|
||||
})
|
||||
.where("id", "=", 1)
|
||||
.execute();
|
||||
await db.insertInto("PlusTier").values({ userId: 1, tier: 2 }).execute();
|
||||
|
||||
const { userCards } = await UserCardRepository.userCards({
|
||||
userIds: [1, 2],
|
||||
viewerId: null,
|
||||
});
|
||||
|
||||
expect(userCards.size).toBe(2);
|
||||
|
||||
const card = userCards.get(1);
|
||||
expect(card?.id).toBe(1);
|
||||
expect(card?.isFreeAgent).toBe(false);
|
||||
|
||||
const statTypes = card?.stats.map((stat) => stat.type) ?? [];
|
||||
expect(statTypes).toContain("XP");
|
||||
expect(statTypes).toContain("DIV");
|
||||
expect(statTypes).toContain("PLUS");
|
||||
|
||||
expect(card?.stats.find((stat) => stat.type === "XP")).toMatchObject({
|
||||
type: "XP",
|
||||
values: [{ isVerified: false, div: "TAKOROKA", points: 3000 }],
|
||||
});
|
||||
expect(card?.stats.find((stat) => stat.type === "DIV")).toMatchObject({
|
||||
type: "DIV",
|
||||
value: "1",
|
||||
});
|
||||
expect(card?.stats.find((stat) => stat.type === "PLUS")).toMatchObject({
|
||||
type: "PLUS",
|
||||
value: 2,
|
||||
});
|
||||
|
||||
// user 2 has none of the optional fields -> no stats
|
||||
expect(userCards.get(2)?.stats).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
320
app/features/user-card/UserCardRepository.server.ts
Normal file
320
app/features/user-card/UserCardRepository.server.ts
Normal file
@@ -0,0 +1,320 @@
|
||||
import type { Expression, ExpressionBuilder } from "kysely";
|
||||
import { sql } from "kysely";
|
||||
import { jsonBuildObject, jsonObjectFrom } from "kysely/helpers/sqlite";
|
||||
import { db } from "~/db/sql";
|
||||
import type { Tables } from "~/db/tables";
|
||||
import { cachedFullUserLeaderboard } from "~/features/leaderboards/core/leaderboards.server";
|
||||
import * as Seasons from "~/features/mmr/core/Seasons";
|
||||
import type { TieredSkill } from "~/features/mmr/tiered.server";
|
||||
import { userSkills } from "~/features/mmr/tiered.server";
|
||||
import type { StageId } from "~/modules/in-game-lists/types";
|
||||
import { commonUserObjectFields } from "~/utils/kysely.server";
|
||||
import { PRESET_COLORS } from "../tier-list-maker/tier-list-maker-constants";
|
||||
import type {
|
||||
UserCardData,
|
||||
UserCardStat,
|
||||
UserCardStatXPValue,
|
||||
XPDivision,
|
||||
} from "./user-card-types";
|
||||
|
||||
/**
|
||||
* Loads `UserCardData` for many users at once, keyed by user id. The single batched DB query (see
|
||||
* {@link userCardDataJsonObject}) is merged with the in-memory SEASON caches (tier from
|
||||
* `userSkills`, leaderboard placement from `cachedFullUserLeaderboard`) in this app-layer enrich
|
||||
* pass, producing the fully-formed `stats` array each card renders. `viewerId` is the logged-in
|
||||
* user viewing the cards (or `null`), used to resolve `isFriend`, `mutualFriends` and `privateNote`.
|
||||
*
|
||||
* Designed to be spread into a route loader (`{ ...(await userCards(...)) }`) so the `UserCard`
|
||||
* component can resolve its own data from the route tree by id.
|
||||
*/
|
||||
export async function userCards({
|
||||
userIds,
|
||||
viewerId,
|
||||
include,
|
||||
}: {
|
||||
userIds: Array<number>;
|
||||
viewerId: number | null;
|
||||
/** Opt-in fields skipped from the query by default; defaults to `false` each. */
|
||||
include?: { friendCode?: boolean };
|
||||
}): Promise<{ userCards: Map<number, UserCardData> }> {
|
||||
if (userIds.length === 0) return { userCards: new Map() };
|
||||
|
||||
const rows = await db
|
||||
.selectFrom("User")
|
||||
.select((eb) =>
|
||||
userCardDataJsonObject(eb, { viewerId, include }).as("cardData"),
|
||||
)
|
||||
.where("User.id", "in", userIds)
|
||||
.execute();
|
||||
|
||||
// xxx: this should check last two and pick better
|
||||
const season = Seasons.currentOrPrevious()?.nth ?? null;
|
||||
const seasonSkills: Record<string, TieredSkill> =
|
||||
season !== null ? userSkills(season).userSkills : {};
|
||||
const seasonTopByUserId =
|
||||
season !== null
|
||||
? new Map(
|
||||
(await cachedFullUserLeaderboard(season)).map((entry) => [
|
||||
entry.id,
|
||||
entry.placementRank,
|
||||
]),
|
||||
)
|
||||
: new Map<number, number>();
|
||||
|
||||
const userCards = new Map<number, UserCardData>();
|
||||
for (const { cardData } of rows) {
|
||||
userCards.set(
|
||||
cardData.id,
|
||||
enrichUserCardData(cardData, {
|
||||
seasonSkill: seasonSkills[cardData.id],
|
||||
// xxx: only needed for leviathan+, maybe lazy load the leaderboard too
|
||||
seasonTop: seasonTopByUserId.get(cardData.id) ?? null,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
return { userCards };
|
||||
}
|
||||
|
||||
/** SQLite `case` expression mapping `User.id % PRESET_COLORS.length` to a preset banner color. */
|
||||
const BANNER_PRESET_COLOR_CASE = `case "User"."id" % ${PRESET_COLORS.length}\n${PRESET_COLORS.map(
|
||||
(color, index) => `when ${index} then '${color}'`,
|
||||
).join("\n")}\nend`;
|
||||
|
||||
/**
|
||||
* Kysely expression building the JSON object for all DB-resident `UserCard` fields of a single user.
|
||||
* Designed to be composed both standalone (one user) and inside a batched list query (see
|
||||
* {@link userCards}). `"User"` must be in scope at the call site.
|
||||
*
|
||||
* SEASON stats (tier + leaderboard placement) are NOT included here — they live in the in-memory
|
||||
* `userSkills`/leaderboard caches and are merged in an app-layer enrich pass. `banner` is returned as
|
||||
* loosely-typed fields (narrow to the discriminated union there). `friendCode` is opt-in via
|
||||
* `include.friendCode` (defaults to off, resolving to `null`) so callers that never surface it skip
|
||||
* the extra correlated subquery.
|
||||
*/
|
||||
function userCardDataJsonObject(
|
||||
eb: ExpressionBuilder<Tables, "User">,
|
||||
{
|
||||
viewerId,
|
||||
include,
|
||||
}: {
|
||||
viewerId: number | null;
|
||||
include?: { friendCode?: boolean };
|
||||
},
|
||||
) {
|
||||
return jsonBuildObject({
|
||||
...commonUserObjectFields(eb),
|
||||
shortBio: eb.ref("User.shortBio"),
|
||||
div: eb.ref("User.div"),
|
||||
customTheme: eb.ref("User.customTheme"),
|
||||
banner: bannerJson(),
|
||||
friendCode: include?.friendCode
|
||||
? friendCodeScalar(eb)
|
||||
: sql<string | null>`null`,
|
||||
privateNote: privateNoteJson(eb, viewerId),
|
||||
plusTier: plusTierScalar(eb),
|
||||
xpVerified: xpVerifiedJson(eb),
|
||||
xpUnverified: xpUnverifiedJson(),
|
||||
});
|
||||
}
|
||||
|
||||
type RawUserCardData =
|
||||
ReturnType<typeof userCardDataJsonObject> extends Expression<infer T>
|
||||
? T
|
||||
: never;
|
||||
|
||||
/**
|
||||
* Loosely-typed banner pulled from the `User.bannerPresetImg` column ("hex code or stage id"). A
|
||||
* numeric value is a stage id (`STAGE`), anything else is a `COLOR` hex code. When the column is
|
||||
* null (no explicit choice) a preset color is derived from the user id. Narrow to the
|
||||
* `{ COLOR | STAGE }` union in the enrich pass. (Supporter-uploaded URL banners are not yet backed
|
||||
* by a column, so no `URL` variant is produced here.)
|
||||
*/
|
||||
function bannerJson() {
|
||||
return jsonBuildObject({
|
||||
type: sql<
|
||||
"COLOR" | "STAGE"
|
||||
>`iif("User"."bannerPresetImg" GLOB '[0-9]*', 'STAGE', 'COLOR')`,
|
||||
hexCode: sql<string | null>`
|
||||
case
|
||||
when "User"."bannerPresetImg" is null then (${sql.raw(BANNER_PRESET_COLOR_CASE)})
|
||||
when "User"."bannerPresetImg" GLOB '[0-9]*' then null
|
||||
else "User"."bannerPresetImg"
|
||||
end`,
|
||||
stageId: sql<
|
||||
number | null
|
||||
>`iif("User"."bannerPresetImg" GLOB '[0-9]*', "User"."bannerPresetImg", null)`,
|
||||
});
|
||||
}
|
||||
|
||||
function friendCodeScalar(eb: ExpressionBuilder<Tables, "User">) {
|
||||
return eb
|
||||
.selectFrom("UserFriendCode")
|
||||
.select("UserFriendCode.friendCode")
|
||||
.whereRef("UserFriendCode.userId", "=", "User.id")
|
||||
.orderBy("UserFriendCode.createdAt", "desc")
|
||||
.limit(1)
|
||||
.$asScalar();
|
||||
}
|
||||
|
||||
function privateNoteJson(
|
||||
eb: ExpressionBuilder<Tables, "User">,
|
||||
viewerId: number | null,
|
||||
) {
|
||||
if (viewerId === null) {
|
||||
return sql<Pick<
|
||||
Tables["PrivateUserNote"],
|
||||
"text" | "sentiment"
|
||||
> | null>`null`;
|
||||
}
|
||||
|
||||
return jsonObjectFrom(
|
||||
eb
|
||||
.selectFrom("PrivateUserNote")
|
||||
.select(["PrivateUserNote.text", "PrivateUserNote.sentiment"])
|
||||
.where("PrivateUserNote.authorId", "=", viewerId)
|
||||
.whereRef("PrivateUserNote.targetId", "=", "User.id"),
|
||||
);
|
||||
}
|
||||
|
||||
function plusTierScalar(eb: ExpressionBuilder<Tables, "User">) {
|
||||
return eb
|
||||
.selectFrom("PlusTier")
|
||||
.select("PlusTier.tier")
|
||||
.whereRef("PlusTier.userId", "=", "User.id")
|
||||
.$asScalar();
|
||||
}
|
||||
|
||||
/** Single highest X Rank power placement (verified XP). `WEST` region = Tentatek, otherwise Takoroka. */
|
||||
function xpVerifiedJson(eb: ExpressionBuilder<Tables, "User">) {
|
||||
return jsonObjectFrom(
|
||||
eb
|
||||
.selectFrom("XRankPlacement")
|
||||
.innerJoin(
|
||||
"SplatoonPlayer",
|
||||
"SplatoonPlayer.id",
|
||||
"XRankPlacement.playerId",
|
||||
)
|
||||
.whereRef("SplatoonPlayer.userId", "=", "User.id")
|
||||
.select([
|
||||
sql<number>`"XRankPlacement"."power"`.as("points"),
|
||||
sql<
|
||||
"TENTATEK" | "TAKOROKA"
|
||||
>`iif("XRankPlacement"."region" = 'WEST', 'TENTATEK', 'TAKOROKA')`.as(
|
||||
"div",
|
||||
),
|
||||
])
|
||||
.orderBy("XRankPlacement.power", "desc")
|
||||
.limit(1),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Self-reported peak XP from the `User.unverifiedPeakXP` column. Has exactly one of `tentatek` /
|
||||
* `takoroka` defined, which decides the division; `points` is that division's value.
|
||||
*/
|
||||
function xpUnverifiedJson() {
|
||||
return sql<{ points: number; div: "TENTATEK" | "TAKOROKA" } | null>`
|
||||
iif(
|
||||
"User"."unverifiedPeakXP" is null,
|
||||
null,
|
||||
json_object(
|
||||
'points', "User"."unverifiedPeakXP" ->> '$.overall',
|
||||
'div', iif("User"."unverifiedPeakXP" ->> '$.tentatek' is not null, 'TENTATEK', 'TAKOROKA')
|
||||
)
|
||||
)
|
||||
`;
|
||||
}
|
||||
|
||||
function enrichUserCardData(
|
||||
cardData: RawUserCardData,
|
||||
{
|
||||
seasonSkill,
|
||||
seasonTop,
|
||||
}: { seasonSkill: TieredSkill | undefined; seasonTop: number | null },
|
||||
): UserCardData {
|
||||
return {
|
||||
id: cardData.id,
|
||||
username: cardData.username,
|
||||
discordId: cardData.discordId,
|
||||
discordAvatar: cardData.discordAvatar,
|
||||
customUrl: cardData.customUrl,
|
||||
customAvatarUrl: cardData.customAvatarUrl,
|
||||
shortBio: cardData.shortBio,
|
||||
customTheme: cardData.customTheme,
|
||||
banner: enrichBanner(cardData.banner),
|
||||
friendCode: cardData.friendCode,
|
||||
// TODO: derive from LFG free agent posts
|
||||
isFreeAgent: false,
|
||||
privateNote: cardData.privateNote ?? { text: null, sentiment: "NEUTRAL" },
|
||||
stats: userCardStats({
|
||||
div: cardData.div,
|
||||
plusTier: cardData.plusTier,
|
||||
xpVerified: cardData.xpVerified,
|
||||
xpUnverified: cardData.xpUnverified,
|
||||
seasonSkill,
|
||||
seasonTop,
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
function enrichBanner(
|
||||
banner: RawUserCardData["banner"],
|
||||
): UserCardData["banner"] {
|
||||
if (banner.type === "STAGE") {
|
||||
return { type: "STAGE", stageId: banner.stageId as StageId };
|
||||
}
|
||||
|
||||
return { type: "COLOR", hexCode: banner.hexCode ?? "" };
|
||||
}
|
||||
|
||||
function userCardStats({
|
||||
div,
|
||||
plusTier,
|
||||
xpVerified,
|
||||
xpUnverified,
|
||||
seasonSkill,
|
||||
seasonTop,
|
||||
}: {
|
||||
div: string | null;
|
||||
plusTier: number | null;
|
||||
xpVerified: { points: number; div: XPDivision } | null;
|
||||
xpUnverified: { points: number; div: XPDivision } | null;
|
||||
seasonSkill: TieredSkill | undefined;
|
||||
seasonTop: number | null;
|
||||
}): Array<UserCardStat> {
|
||||
const stats: Array<UserCardStat> = [];
|
||||
|
||||
const xpValues: Array<UserCardStatXPValue> = [];
|
||||
if (xpUnverified) {
|
||||
xpValues.push({
|
||||
isVerified: false,
|
||||
div: xpUnverified.div,
|
||||
points: xpUnverified.points,
|
||||
});
|
||||
}
|
||||
if (xpVerified) {
|
||||
xpValues.push({
|
||||
isVerified: true,
|
||||
div: xpVerified.div,
|
||||
points: xpVerified.points,
|
||||
});
|
||||
}
|
||||
if (xpValues.length > 0) {
|
||||
stats.push({ type: "XP", values: xpValues });
|
||||
}
|
||||
|
||||
if (seasonSkill && !seasonSkill.approximate) {
|
||||
stats.push({ type: "SEASON", value: seasonSkill.tier, top: seasonTop });
|
||||
}
|
||||
|
||||
if (typeof plusTier === "number") {
|
||||
stats.push({ type: "PLUS", value: plusTier });
|
||||
}
|
||||
|
||||
if (div) {
|
||||
stats.push({ type: "DIV", value: div });
|
||||
}
|
||||
|
||||
return stats;
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import clsx from "clsx";
|
||||
import { BadgeCheck, NotebookPen, UserPlus } from "lucide-react";
|
||||
import { BadgeCheck, Megaphone, NotebookPen, UserPlus } from "lucide-react";
|
||||
import * as React from "react";
|
||||
import { Popover } from "react-aria-components";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useFetcher, useMatches } from "react-router";
|
||||
import { Avatar } from "~/components/Avatar";
|
||||
import { LinkButton, SendouButton } from "~/components/elements/Button";
|
||||
import { Image, TierImage } from "~/components/Image";
|
||||
@@ -12,12 +13,16 @@ import type { BrandId } from "~/modules/in-game-lists/types";
|
||||
import { assertUnreachable } from "~/utils/types";
|
||||
import {
|
||||
brandImageUrl,
|
||||
LFG_PAGE,
|
||||
navIconUrl,
|
||||
stageBannerImageUrl,
|
||||
userCardFriendshipPage,
|
||||
userPage,
|
||||
} from "~/utils/urls";
|
||||
import type { UserCardFriendshipLoaderData } from "../routes/user-card.$id.friendship";
|
||||
import type {
|
||||
UserCardData,
|
||||
UserCardFriendship,
|
||||
UserCardStat,
|
||||
XPDivision,
|
||||
} from "../user-card-types";
|
||||
@@ -38,20 +43,49 @@ const STAT_ORDER: Record<UserCardStat["type"], number> = {
|
||||
};
|
||||
|
||||
/**
|
||||
* xxx: docs here
|
||||
* Hover/focus wrapper that opens a popover with the user's card. Card data is resolved from the
|
||||
* route tree by `userId` (a parent loader spreads `{ userCards }` from `UserCardRepository.userCards`);
|
||||
* pass `data` directly to bypass the lookup (e.g. the components showcase). When no card data exists
|
||||
* for the user, the `children` are rendered plain without a popover.
|
||||
*
|
||||
* Viewer-relative friendship data (`isFriend` + `mutualFriends`) is lazy-loaded from the
|
||||
* `/user-card/:id/friendship` route the first time the card opens.
|
||||
*/
|
||||
export function UserCard({
|
||||
data,
|
||||
userId,
|
||||
data: dataProp,
|
||||
children,
|
||||
}: {
|
||||
data: UserCardData;
|
||||
userId?: number;
|
||||
data?: UserCardData;
|
||||
// xxx: should this be a button or not?
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
const lookedUpData = useUserCardData(userId);
|
||||
const data = dataProp ?? lookedUpData;
|
||||
|
||||
const triggerRef = React.useRef<HTMLSpanElement>(null);
|
||||
const popoverRef = React.useRef<HTMLElement>(null);
|
||||
const openTimeout = React.useRef<ReturnType<typeof setTimeout>>(undefined);
|
||||
const closeTimeout = React.useRef<ReturnType<typeof setTimeout>>(undefined);
|
||||
const lastPointerType =
|
||||
React.useRef<React.PointerEvent["pointerType"]>("mouse");
|
||||
const [isOpen, setIsOpen] = React.useState(false);
|
||||
const [openedByTouch, setOpenedByTouch] = React.useState(false);
|
||||
|
||||
const fetcher = useFetcher<UserCardFriendshipLoaderData>();
|
||||
const friendshipLoadedRef = React.useRef(false);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!isOpen) return;
|
||||
if (friendshipLoadedRef.current) return;
|
||||
if (typeof data?.id !== "number") return;
|
||||
|
||||
friendshipLoadedRef.current = true;
|
||||
fetcher.load(userCardFriendshipPage(data.id));
|
||||
}, [isOpen, data?.id, fetcher.load]);
|
||||
|
||||
const friendship = fetcher.data;
|
||||
|
||||
// xxx: probably not the play
|
||||
React.useEffect(
|
||||
@@ -62,6 +96,24 @@ export function UserCard({
|
||||
[],
|
||||
);
|
||||
|
||||
// a non-modal popover does not close on interact outside; for touch-opened cards we close it
|
||||
// ourselves so the page stays interactive without making the popover modal (which would steal focus)
|
||||
React.useEffect(() => {
|
||||
if (!isOpen || !openedByTouch) return;
|
||||
|
||||
const onPointerDownOutside = (event: PointerEvent) => {
|
||||
const target = event.target as Node;
|
||||
if (triggerRef.current?.contains(target)) return;
|
||||
if (popoverRef.current?.contains(target)) return;
|
||||
setIsOpen(false);
|
||||
setOpenedByTouch(false);
|
||||
};
|
||||
|
||||
document.addEventListener("pointerdown", onPointerDownOutside);
|
||||
return () =>
|
||||
document.removeEventListener("pointerdown", onPointerDownOutside);
|
||||
}, [isOpen, openedByTouch]);
|
||||
|
||||
const scheduleOpen = () => {
|
||||
clearTimeout(closeTimeout.current);
|
||||
openTimeout.current = setTimeout(
|
||||
@@ -90,14 +142,30 @@ export function UserCard({
|
||||
scheduleClose();
|
||||
};
|
||||
|
||||
const onPointerDown = (event: React.PointerEvent) => {
|
||||
lastPointerType.current = event.pointerType;
|
||||
};
|
||||
|
||||
const onClick = (event: React.MouseEvent) => {
|
||||
if (lastPointerType.current === "mouse") return;
|
||||
// on touch/pen open the card instead of activating the child (e.g. following a link)
|
||||
event.preventDefault();
|
||||
setOpenedByTouch(true);
|
||||
setIsOpen((prev) => !prev);
|
||||
};
|
||||
|
||||
if (!data) return <>{children}</>;
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* biome-ignore lint/a11y/noStaticElementInteractions: hover/focus wrapper delegating to the interactive child trigger; the card opens on hover/focus */}
|
||||
{/* biome-ignore lint/a11y/noStaticElementInteractions: hover/focus/tap wrapper delegating to the interactive child trigger; the card opens on hover/focus (mouse) or tap (touch) */}
|
||||
<span
|
||||
ref={triggerRef}
|
||||
className={styles.triggerWrapper}
|
||||
onPointerEnter={onPointerEnter}
|
||||
onPointerLeave={onPointerLeave}
|
||||
onPointerDown={onPointerDown}
|
||||
onClick={onClick}
|
||||
onFocus={() => setIsOpen(true)}
|
||||
onBlur={(event) => {
|
||||
if (!event.currentTarget.contains(event.relatedTarget)) {
|
||||
@@ -108,15 +176,20 @@ export function UserCard({
|
||||
{children}
|
||||
</span>
|
||||
<Popover
|
||||
ref={popoverRef}
|
||||
triggerRef={triggerRef}
|
||||
isOpen={isOpen}
|
||||
onOpenChange={setIsOpen}
|
||||
onOpenChange={(open) => {
|
||||
setIsOpen(open);
|
||||
if (!open) setOpenedByTouch(false);
|
||||
}}
|
||||
isNonModal
|
||||
placement="bottom"
|
||||
className={styles.popover}
|
||||
>
|
||||
<CardContent
|
||||
data={data}
|
||||
friendship={friendship}
|
||||
onPointerEnter={cancelClose}
|
||||
onPointerLeave={onPointerLeave}
|
||||
/>
|
||||
@@ -125,12 +198,36 @@ export function UserCard({
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves a user's `UserCardData` from any matched route loader that spread `{ userCards }`
|
||||
* (see `UserCardRepository.userCards`). Returns `undefined` when no loader on the current route
|
||||
* tree carries data for the given user.
|
||||
*/
|
||||
function useUserCardData(userId: number | undefined): UserCardData | undefined {
|
||||
const matches = useMatches();
|
||||
|
||||
if (typeof userId !== "number") return undefined;
|
||||
|
||||
for (const match of matches) {
|
||||
const data = match.data as
|
||||
| { userCards?: Map<number, UserCardData> }
|
||||
| undefined;
|
||||
const card = data?.userCards?.get(userId);
|
||||
if (card) return card;
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function CardContent({
|
||||
data,
|
||||
friendship,
|
||||
onPointerEnter,
|
||||
onPointerLeave,
|
||||
}: {
|
||||
data: UserCardData;
|
||||
/** Lazy-loaded; `undefined` while the friendship fetch is in flight. */
|
||||
friendship: UserCardFriendship | undefined;
|
||||
onPointerEnter: () => void;
|
||||
onPointerLeave: (event: React.PointerEvent) => void;
|
||||
}) {
|
||||
@@ -148,8 +245,19 @@ function CardContent({
|
||||
onPointerLeave={onPointerLeave}
|
||||
>
|
||||
<Banner banner={data.banner} />
|
||||
{data.isFreeAgent ? (
|
||||
<LinkButton
|
||||
// xxx: make it scroll to the fa post
|
||||
to={LFG_PAGE}
|
||||
size="miniscule"
|
||||
icon={<Megaphone />}
|
||||
className={styles.freeAgentBadge}
|
||||
>
|
||||
{t("user:card.freeAgent")}
|
||||
</LinkButton>
|
||||
) : null}
|
||||
<div className={styles.iconButtons}>
|
||||
{!data.isFriend ? (
|
||||
{friendship && !friendship.isFriend ? (
|
||||
<SendouButton
|
||||
size="miniscule"
|
||||
shape="circle"
|
||||
@@ -168,9 +276,11 @@ function CardContent({
|
||||
<Avatar user={data} size="md" className={styles.avatar} />
|
||||
<div className={styles.nameGroup}>
|
||||
<h2 className={styles.username}>{data.username}</h2>
|
||||
<Subtitle data={data} />
|
||||
{data.customUrl ? (
|
||||
<div className={styles.subtitle}>{data.customUrl}</div>
|
||||
) : null}
|
||||
{data.friendCode ? (
|
||||
<span className={styles.friendCode}>{data.friendCode}</span>
|
||||
<span className={styles.friendCode}>SW-{data.friendCode}</span>
|
||||
) : (
|
||||
/** reserve space */
|
||||
<span className={styles.friendCode}>{"\u200b"}</span>
|
||||
@@ -187,7 +297,7 @@ function CardContent({
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
<MutualFriends mutualFriends={data.mutualFriends} />
|
||||
<CardMutualFriends friendship={friendship} />
|
||||
{data.shortBio ? <p className={styles.bio}>{data.shortBio}</p> : null}
|
||||
<LinkButton
|
||||
to={userPage(data)}
|
||||
@@ -201,6 +311,31 @@ function CardContent({
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Mutual friends row with reserved height so the card does not shift when the lazy friendship fetch
|
||||
* resolves: empty while loading, "No mutual friends" when there are none, the avatar stack otherwise.
|
||||
*/
|
||||
function CardMutualFriends({
|
||||
friendship,
|
||||
}: {
|
||||
friendship: UserCardFriendship | undefined;
|
||||
}) {
|
||||
const { t } = useTranslation(["user"]);
|
||||
|
||||
return (
|
||||
<div className={styles.mutualFriends}>
|
||||
{friendship === undefined ? null : friendship.mutualFriends.length ===
|
||||
0 ? (
|
||||
<span className={styles.noMutualFriends}>
|
||||
{t("user:card.noMutualFriends")}
|
||||
</span>
|
||||
) : (
|
||||
<MutualFriends mutualFriends={friendship.mutualFriends} />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Banner({ banner }: { banner: UserCardData["banner"] }) {
|
||||
const style = (() => {
|
||||
switch (banner.type) {
|
||||
@@ -220,27 +355,6 @@ function Banner({ banner }: { banner: UserCardData["banner"] }) {
|
||||
return <div className={styles.banner} style={style} />;
|
||||
}
|
||||
|
||||
function Subtitle({ data }: { data: UserCardData }) {
|
||||
const parts: Array<string> = [];
|
||||
|
||||
if (data.customUrl) {
|
||||
parts.push(data.customUrl);
|
||||
}
|
||||
|
||||
if (parts.length === 0) return null;
|
||||
|
||||
return (
|
||||
<div className={styles.subtitle}>
|
||||
{parts.map((part, i) => (
|
||||
<span key={part} className="stack horizontal xs items-center">
|
||||
{i > 0 ? <span>·</span> : null}
|
||||
{part}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Stat({ stat }: { stat: UserCardData["stats"][number] }) {
|
||||
const { t } = useTranslation(["user"]);
|
||||
|
||||
@@ -275,7 +389,7 @@ function Stat({ stat }: { stat: UserCardData["stats"][number] }) {
|
||||
);
|
||||
}
|
||||
case "DIV":
|
||||
return <span className={styles.stat}>{stat.value}</span>;
|
||||
return <span className={styles.stat}>Div {stat.value}</span>;
|
||||
case "PLUS":
|
||||
return (
|
||||
<span className={clsx(styles.stat, styles.plusStat)}>
|
||||
|
||||
36
app/features/user-card/routes/user-card.$id.friendship.ts
Normal file
36
app/features/user-card/routes/user-card.$id.friendship.ts
Normal file
@@ -0,0 +1,36 @@
|
||||
import type { LoaderFunctionArgs } from "react-router";
|
||||
import { getUser } from "~/features/auth/core/user.server";
|
||||
import * as FriendRepository from "~/features/friends/FriendRepository.server";
|
||||
import type { SerializeFrom } from "~/utils/remix";
|
||||
import type { UserCardFriendship } from "../user-card-types";
|
||||
|
||||
export type UserCardFriendshipLoaderData = SerializeFrom<typeof loader>;
|
||||
|
||||
/**
|
||||
* Viewer-relative friendship data for a single user, lazy-loaded by the `UserCard`
|
||||
* popover when it opens (keeps `isFriend` + `mutualFriends` out of the batched card
|
||||
* query). Resolves to empty values when there is no logged-in viewer.
|
||||
*/
|
||||
export const loader = async ({
|
||||
params,
|
||||
}: LoaderFunctionArgs): Promise<UserCardFriendship> => {
|
||||
const viewer = getUser();
|
||||
const targetUserId = Number(params.id);
|
||||
|
||||
if (!viewer || Number.isNaN(targetUserId)) {
|
||||
return { isFriend: false, mutualFriends: [] };
|
||||
}
|
||||
|
||||
const [friendship, mutualFriends] = await Promise.all([
|
||||
FriendRepository.findFriendship({
|
||||
userOneId: viewer.id,
|
||||
userTwoId: targetUserId,
|
||||
}),
|
||||
FriendRepository.findMutualFriends({
|
||||
loggedInUserId: viewer.id,
|
||||
targetUserId,
|
||||
}),
|
||||
]);
|
||||
|
||||
return { isFriend: Boolean(friendship), mutualFriends };
|
||||
};
|
||||
@@ -8,12 +8,21 @@ export interface UserCardData extends CommonUser {
|
||||
shortBio: string | null;
|
||||
customTheme: CustomTheme | null;
|
||||
friendCode: string | null;
|
||||
isFriend: boolean;
|
||||
mutualFriends: Array<CommonUser>;
|
||||
isFreeAgent: boolean;
|
||||
privateNote: Pick<Tables["PrivateUserNote"], "text" | "sentiment">;
|
||||
stats: Array<UserCardStat>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Viewer-relative card fields lazy-loaded when the card opens (see the
|
||||
* `/user-card/:id/friendship` resource route), kept out of the batched `UserCardData`
|
||||
* query because they are only needed for the one card a viewer actually opens.
|
||||
*/
|
||||
export interface UserCardFriendship {
|
||||
isFriend: boolean;
|
||||
mutualFriends: Array<CommonUser>;
|
||||
}
|
||||
|
||||
type UserCarBannerData =
|
||||
| {
|
||||
type: "URL";
|
||||
@@ -50,7 +59,7 @@ export type UserCardStat =
|
||||
// xxx: should live in tables.ts or something?
|
||||
export type XPDivision = "TENTATEK" | "TAKOROKA";
|
||||
|
||||
interface UserCardStatXPValue {
|
||||
export interface UserCardStatXPValue {
|
||||
isVerified: boolean;
|
||||
div: XPDivision;
|
||||
points: number;
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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"),
|
||||
|
||||
56
app/routines/computeLutiDivs.ts
Normal file
56
app/routines/computeLutiDivs.ts
Normal file
@@ -0,0 +1,56 @@
|
||||
import { parseLutiDivFromName } from "../features/scrims/scrims-utils";
|
||||
import * as TournamentRepository from "../features/tournament/TournamentRepository.server";
|
||||
import { LEAGUES } from "../features/tournament/tournament-constants";
|
||||
import * as UserRepository from "../features/user-page/UserRepository.server";
|
||||
import { logger } from "../utils/logger";
|
||||
import { Routine } from "./routine.server";
|
||||
|
||||
/**
|
||||
* Recomputes `User.div` (the user's division in the latest finished LUTI). Looks at the most recent
|
||||
* LUTI season whose division tournaments are all finalized and sets the division for every eligible
|
||||
* participant (on a team that did not drop out and played at least one match). Users not in that
|
||||
* season keep their previous division. Idempotent.
|
||||
*/
|
||||
export const ComputeLutiDivsRoutine = new Routine({
|
||||
name: "ComputeLutiDivs",
|
||||
func: async () => {
|
||||
const children = await latestFinishedLutiDivisions();
|
||||
if (!children) return;
|
||||
|
||||
const updates: Array<{ userId: number; div: string }> = [];
|
||||
for (const child of children) {
|
||||
const div = parseLutiDivFromName(child.name);
|
||||
if (!div) {
|
||||
logger.warn(
|
||||
`ComputeLutiDivs: could not parse division from tournament name "${child.name}"`,
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
const userIds =
|
||||
await TournamentRepository.findLeagueDivParticipantUserIds(
|
||||
child.tournamentId,
|
||||
);
|
||||
for (const { userId } of userIds) {
|
||||
updates.push({ userId, div });
|
||||
}
|
||||
}
|
||||
|
||||
await UserRepository.updateManyDivs(updates);
|
||||
logger.info(`ComputeLutiDivs: updated div for ${updates.length} users`);
|
||||
},
|
||||
});
|
||||
|
||||
async function latestFinishedLutiDivisions() {
|
||||
for (const league of [...(LEAGUES.LUTI ?? [])].reverse()) {
|
||||
const children = await TournamentRepository.findChildTournamentsForDivCalc(
|
||||
league.tournamentId,
|
||||
);
|
||||
if (children.length === 0) continue;
|
||||
if (children.every((child) => child.isFinalized === 1)) {
|
||||
return children;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
@@ -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,
|
||||
];
|
||||
|
||||
|
||||
@@ -55,21 +55,24 @@ const userChatNameHueRaw = sql<
|
||||
|
||||
export const userChatNameHue = userChatNameHueRaw.as("chatNameHue");
|
||||
|
||||
export function commonUserJsonObject(eb: ExpressionBuilder<Tables, "User">) {
|
||||
return jsonBuildObject({
|
||||
/**
|
||||
* The {@link CommonUser} fields as a plain record of Kysely expressions, for spreading into a
|
||||
* hand-built `jsonBuildObject` alongside extra fields. Prefer {@link commonUserJsonObject} when the
|
||||
* common fields are the whole object.
|
||||
*/
|
||||
export function commonUserObjectFields(eb: ExpressionBuilder<Tables, "User">) {
|
||||
return {
|
||||
id: eb.ref("User.id"),
|
||||
username: eb.ref("User.username"),
|
||||
discordId: eb.ref("User.discordId"),
|
||||
discordAvatar: eb.ref("User.discordAvatar"),
|
||||
customUrl: eb.ref("User.customUrl"),
|
||||
customAvatarUrl: concatUserSubmittedImagePrefix(
|
||||
eb
|
||||
.selectFrom("UserSubmittedImage")
|
||||
.select("UserSubmittedImage.url")
|
||||
.whereRef("UserSubmittedImage.id", "=", "User.customAvatarImgId")
|
||||
.$asScalar(),
|
||||
).$castTo<string | null>(),
|
||||
});
|
||||
customAvatarUrl: customAvatarUrl(eb),
|
||||
};
|
||||
}
|
||||
|
||||
export function commonUserJsonObject(eb: ExpressionBuilder<Tables, "User">) {
|
||||
return jsonBuildObject(commonUserObjectFields(eb));
|
||||
}
|
||||
|
||||
const USER_SUBMITTED_IMAGE_ROOT =
|
||||
|
||||
@@ -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"];
|
||||
|
||||
BIN
db-test.sqlite3
BIN
db-test.sqlite3
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
1
knip.ts
1
knip.ts
@@ -4,7 +4,6 @@ const config = {
|
||||
type: true,
|
||||
},
|
||||
tags: ["-lintignore"],
|
||||
ignore: ["scripts/dicts/**"],
|
||||
entry: [
|
||||
"app/features/*/routes/**/*.{ts,tsx}",
|
||||
"migrations/**/*.js",
|
||||
|
||||
@@ -206,5 +206,7 @@
|
||||
"card.viewUserPage": "",
|
||||
"card.sendFriendRequest": "",
|
||||
"card.editPrivateNote": "",
|
||||
"card.xp": ""
|
||||
"card.xp": "",
|
||||
"card.freeAgent": "",
|
||||
"card.noMutualFriends": ""
|
||||
}
|
||||
|
||||
@@ -206,5 +206,7 @@
|
||||
"card.viewUserPage": "",
|
||||
"card.sendFriendRequest": "",
|
||||
"card.editPrivateNote": "",
|
||||
"card.xp": ""
|
||||
"card.xp": "",
|
||||
"card.freeAgent": "",
|
||||
"card.noMutualFriends": ""
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -207,5 +207,7 @@
|
||||
"card.viewUserPage": "",
|
||||
"card.sendFriendRequest": "",
|
||||
"card.editPrivateNote": "",
|
||||
"card.xp": ""
|
||||
"card.xp": "",
|
||||
"card.freeAgent": "",
|
||||
"card.noMutualFriends": ""
|
||||
}
|
||||
|
||||
@@ -207,5 +207,7 @@
|
||||
"card.viewUserPage": "",
|
||||
"card.sendFriendRequest": "",
|
||||
"card.editPrivateNote": "",
|
||||
"card.xp": ""
|
||||
"card.xp": "",
|
||||
"card.freeAgent": "",
|
||||
"card.noMutualFriends": ""
|
||||
}
|
||||
|
||||
@@ -207,5 +207,7 @@
|
||||
"card.viewUserPage": "",
|
||||
"card.sendFriendRequest": "",
|
||||
"card.editPrivateNote": "",
|
||||
"card.xp": ""
|
||||
"card.xp": "",
|
||||
"card.freeAgent": "",
|
||||
"card.noMutualFriends": ""
|
||||
}
|
||||
|
||||
@@ -207,5 +207,7 @@
|
||||
"card.viewUserPage": "",
|
||||
"card.sendFriendRequest": "",
|
||||
"card.editPrivateNote": "",
|
||||
"card.xp": ""
|
||||
"card.xp": "",
|
||||
"card.freeAgent": "",
|
||||
"card.noMutualFriends": ""
|
||||
}
|
||||
|
||||
@@ -207,5 +207,7 @@
|
||||
"card.viewUserPage": "",
|
||||
"card.sendFriendRequest": "",
|
||||
"card.editPrivateNote": "",
|
||||
"card.xp": ""
|
||||
"card.xp": "",
|
||||
"card.freeAgent": "",
|
||||
"card.noMutualFriends": ""
|
||||
}
|
||||
|
||||
@@ -207,5 +207,7 @@
|
||||
"card.viewUserPage": "",
|
||||
"card.sendFriendRequest": "",
|
||||
"card.editPrivateNote": "",
|
||||
"card.xp": ""
|
||||
"card.xp": "",
|
||||
"card.freeAgent": "",
|
||||
"card.noMutualFriends": ""
|
||||
}
|
||||
|
||||
@@ -204,5 +204,7 @@
|
||||
"card.viewUserPage": "",
|
||||
"card.sendFriendRequest": "",
|
||||
"card.editPrivateNote": "",
|
||||
"card.xp": ""
|
||||
"card.xp": "",
|
||||
"card.freeAgent": "",
|
||||
"card.noMutualFriends": ""
|
||||
}
|
||||
|
||||
@@ -204,5 +204,7 @@
|
||||
"card.viewUserPage": "",
|
||||
"card.sendFriendRequest": "",
|
||||
"card.editPrivateNote": "",
|
||||
"card.xp": ""
|
||||
"card.xp": "",
|
||||
"card.freeAgent": "",
|
||||
"card.noMutualFriends": ""
|
||||
}
|
||||
|
||||
@@ -206,5 +206,7 @@
|
||||
"card.viewUserPage": "",
|
||||
"card.sendFriendRequest": "",
|
||||
"card.editPrivateNote": "",
|
||||
"card.xp": ""
|
||||
"card.xp": "",
|
||||
"card.freeAgent": "",
|
||||
"card.noMutualFriends": ""
|
||||
}
|
||||
|
||||
@@ -208,5 +208,7 @@
|
||||
"card.viewUserPage": "",
|
||||
"card.sendFriendRequest": "",
|
||||
"card.editPrivateNote": "",
|
||||
"card.xp": ""
|
||||
"card.xp": "",
|
||||
"card.freeAgent": "",
|
||||
"card.noMutualFriends": ""
|
||||
}
|
||||
|
||||
@@ -207,5 +207,7 @@
|
||||
"card.viewUserPage": "",
|
||||
"card.sendFriendRequest": "",
|
||||
"card.editPrivateNote": "",
|
||||
"card.xp": ""
|
||||
"card.xp": "",
|
||||
"card.freeAgent": "",
|
||||
"card.noMutualFriends": ""
|
||||
}
|
||||
|
||||
@@ -208,5 +208,7 @@
|
||||
"card.viewUserPage": "",
|
||||
"card.sendFriendRequest": "",
|
||||
"card.editPrivateNote": "",
|
||||
"card.xp": ""
|
||||
"card.xp": "",
|
||||
"card.freeAgent": "",
|
||||
"card.noMutualFriends": ""
|
||||
}
|
||||
|
||||
@@ -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": "结束赛事",
|
||||
|
||||
@@ -205,5 +205,7 @@
|
||||
"card.viewUserPage": "",
|
||||
"card.sendFriendRequest": "",
|
||||
"card.editPrivateNote": "",
|
||||
"card.xp": ""
|
||||
"card.xp": "",
|
||||
"card.freeAgent": "",
|
||||
"card.noMutualFriends": ""
|
||||
}
|
||||
|
||||
88
migrations/154-user-card-fields.js
Normal file
88
migrations/154-user-card-fields.js
Normal file
@@ -0,0 +1,88 @@
|
||||
export function up(db) {
|
||||
db.pragma("foreign_keys = OFF");
|
||||
|
||||
db.transaction(() => {
|
||||
db.prepare(`alter table "User" drop column "lastSubMessage"`).run();
|
||||
db.prepare(/* sql */ `alter table "User" add "shortBio" text`).run();
|
||||
db.prepare(/* sql */ `alter table "User" add "div" text`).run();
|
||||
db.prepare(
|
||||
/* sql */ `alter table "User" add "unverifiedPeakXP" text`,
|
||||
).run();
|
||||
// nullable: null means no explicit choice, the card derives a preset color from the user id
|
||||
db.prepare(/* sql */ `alter table "User" add "bannerPresetImg" text`).run();
|
||||
|
||||
// backfill unverifiedPeakXP from the existing "peak-xp-unverified" profile widget
|
||||
db.prepare(
|
||||
/* sql */ `
|
||||
update "User"
|
||||
set "unverifiedPeakXP" = (
|
||||
select json_object(
|
||||
'overall', json_extract(uw."widget", '$.settings.peakXp'),
|
||||
'tentatek', iif(json_extract(uw."widget", '$.settings.division') = 'tentatek', json_extract(uw."widget", '$.settings.peakXp'), null),
|
||||
'takoroka', iif(json_extract(uw."widget", '$.settings.division') = 'takoroka', json_extract(uw."widget", '$.settings.peakXp'), null)
|
||||
)
|
||||
from "UserWidget" uw
|
||||
where uw."userId" = "User"."id"
|
||||
and json_extract(uw."widget", '$.id') = 'peak-xp-unverified'
|
||||
limit 1
|
||||
)
|
||||
where exists (
|
||||
select 1 from "UserWidget" uw
|
||||
where uw."userId" = "User"."id"
|
||||
and json_extract(uw."widget", '$.id') = 'peak-xp-unverified'
|
||||
)
|
||||
`,
|
||||
).run();
|
||||
|
||||
// rebuild SplatoonPlayer to change peakXp from a scalar (real) into the
|
||||
// denormalized PeakXP json shape. per-division peaks are resolved from XRankPlacement
|
||||
// (region 'WEST' = tentatek, otherwise takoroka), matching refreshAllPeakXp.
|
||||
db.prepare(
|
||||
/* sql */ `
|
||||
create table "SplatoonPlayer_new" (
|
||||
"id" integer primary key,
|
||||
"userId" integer unique,
|
||||
"splId" text unique not null,
|
||||
"peakXp" text,
|
||||
foreign key ("userId") references "User"("id") on delete cascade
|
||||
) strict
|
||||
`,
|
||||
).run();
|
||||
|
||||
db.prepare(
|
||||
/* sql */ `
|
||||
insert into "SplatoonPlayer_new" ("id", "userId", "splId", "peakXp")
|
||||
select
|
||||
"id",
|
||||
"userId",
|
||||
"splId",
|
||||
iif("peakXp" is null, null, json_object(
|
||||
'overall', "peakXp",
|
||||
'takoroka', (
|
||||
select max("XRankPlacement"."power") from "XRankPlacement"
|
||||
where "XRankPlacement"."playerId" = "SplatoonPlayer"."id"
|
||||
and "XRankPlacement"."region" <> 'WEST'
|
||||
),
|
||||
'tentatek', (
|
||||
select max("XRankPlacement"."power") from "XRankPlacement"
|
||||
where "XRankPlacement"."playerId" = "SplatoonPlayer"."id"
|
||||
and "XRankPlacement"."region" = 'WEST'
|
||||
)
|
||||
))
|
||||
from "SplatoonPlayer"
|
||||
`,
|
||||
).run();
|
||||
|
||||
db.prepare(/* sql */ `drop table "SplatoonPlayer"`).run();
|
||||
db.prepare(
|
||||
/* sql */ `alter table "SplatoonPlayer_new" rename to "SplatoonPlayer"`,
|
||||
).run();
|
||||
db.prepare(
|
||||
/* sql */ `create index splatoon_player_user_id on "SplatoonPlayer"("userId")`,
|
||||
).run();
|
||||
|
||||
db.pragma("foreign_key_check");
|
||||
})();
|
||||
|
||||
db.pragma("foreign_keys = ON");
|
||||
}
|
||||
Reference in New Issue
Block a user