New widgets

This commit is contained in:
Kalle
2026-09-06 07:40:56 +03:00
parent 296bf558ef
commit 0e0aa94d0e
55 changed files with 822 additions and 16 deletions

View File

@@ -48,9 +48,10 @@ export async function seedMisc({
await seedNotifications(users, tournaments);
await seedUserReports(users, sendouq);
await LiveStreamFactory.replaceAll(
users.showcaseIds.slice(0, STREAM_COUNT).map((userId) => ({ userId })),
);
await LiveStreamFactory.replaceAll([
{ userId: users.nzapId, twitch: "nzap_stream" },
...users.showcaseIds.slice(0, STREAM_COUNT).map((userId) => ({ userId })),
]);
await SplatoonRotationFactory.replaceAll();
return { adminFriendIds: adminFriendIds(users) };

View File

@@ -1,3 +1,4 @@
import { addDays } from "date-fns";
import type { UserMapModePreferences } from "~/db/tables-json";
import { ADMIN_DISCORD_ID } from "~/features/admin/admin-constants";
import { BANNED_MAPS } from "~/features/match-profile/banned-maps";
@@ -21,6 +22,8 @@ import * as showcaseNames from "../core/showcaseNames";
import * as UserFactory from "../factories/UserFactory";
const SHOWCASE_COUNT = 100;
/** Latest finished LUTI the seeded divisions are from. */
const LUTI_SEASON = 17;
const CROWD_COUNT = 396;
export type SeededUsers = {
@@ -83,6 +86,8 @@ export async function seedUsers(): Promise<SeededUsers> {
{
patronTier: 2,
roles: ["VIDEO_ADDER", "TOURNAMENT_ORGANIZER", "ARTIST"],
div: "2",
divSeason: LUTI_SEASON,
matchProfile: {
mapModePreferences: fakePreferences(),
vc: "YES",
@@ -246,20 +251,30 @@ export function nzapWidgets(): StoredWidget[] {
return [
{ id: "bio-md", settings: { bio: showcaseNames.maxLengthBio() } },
{ id: "teams" },
{ id: "organizations" },
{ id: "patron-since" },
{ id: "live-stream" },
{ id: "luti-div" },
{
id: "countdown",
settings: { title: "Next LAN", date: addDays(new Date(), 42) },
},
{
id: "markdown",
settings: {
content:
"## Looking for\n\n- **Scrims** on weekdays\n- A *support* player\n\nDM me on Discord!",
},
},
{
id: "sens",
settings: { controller: "s2-pro-con", motionSens: 50, stickSens: 5 },
},
{ id: "timezone", settings: { timezone: "Europe/Stockholm" } },
{ id: "social-links" },
{ id: "weapon-pool" },
{ id: "map-mode-preferences" },
{ id: "badges-owned", settings: { favoriteBadgeIds: [] } },
{ id: "trophies-owned" },
{ id: "art", settings: { source: "ALL" } },
{ id: "x-rank-peaks", settings: { division: "both" } },
{ id: "highlighted-results" },
];
}
@@ -283,6 +298,7 @@ function showcaseOptions(): Parameters<typeof UserFactory.create>[1] {
faker.number.float(1) < 0.6
? faker.helpers.arrayElement(LUTI_DIVS)
: undefined,
divSeason: LUTI_SEASON,
card: {
shortBio: faker.number.float(1) < 0.6 ? faker.lorem.sentence() : null,
bannerPresetImg: fakeBannerPresetImg(),

View File

@@ -45,6 +45,8 @@ type Options = {
ban?: Omit<Parameters<typeof AdminRepository.banUser>[0], "userId">;
/** Division the user played their last season in. */
div?: NonNullable<Tables["User"]["div"]>;
/** LUTI season `div` is from, only applied together with it. */
divSeason?: number;
/** User card fields, submitted as the user themselves. */
card?: Partial<CardArgs>;
/** Replaces the user's widgets, i.e. their profile layout, in place of the default one. */
@@ -320,6 +322,7 @@ export async function grant(
matchProfile,
ban,
div,
divSeason,
card,
widgets,
preferences,
@@ -363,7 +366,9 @@ export async function grant(
}
if (div) {
await UserRepository.updateManyDivs([{ userId, div }]);
await UserRepository.updateManyDivs([
{ userId, div, divSeason: divSeason ?? null },
]);
}
if (widgets) {

View File

@@ -999,6 +999,8 @@ export interface User {
hiddenCardStats: JSONColumnTypeNullable<Array<HideableUserCardStat>>;
/** Div in the latest finished LUTI (e.g. "2" or "X"). Must have been in a team that did not drop and the user played at least one match (got result as well) */
div: string | null;
/** LUTI season `div` was earned in. */
divSeason: number | null;
/** Peak XP as indicated by the user. Should have either `takoroka` or `tentatek` key defined but not both. */
unverifiedPeakXP: JSONColumnTypeNullable<PeakXP>;
/** Division the user card's XP is taken from. `null` when the user has not picked one, showing their highest XP across both. */

View File

@@ -25,6 +25,21 @@ export function insertTournamentStreamers(
.execute();
}
/** The user's ongoing Splatoon 3 Twitch stream, `undefined` when they are not live. */
export function findByUserId(userId: number) {
return db
.selectFrom("LiveStream")
.select([
"LiveStream.twitch",
"LiveStream.viewerCount",
"LiveStream.thumbnailUrl",
])
.where("LiveStream.userId", "=", userId)
.where("LiveStream.twitch", "is not", null)
.$narrowType<{ twitch: string }>()
.executeTakeFirst();
}
export function findXRankStreams() {
return db
.selectFrom("LiveStream")

View File

@@ -23,6 +23,15 @@ export function findSettingsByUserId(userId: number) {
.executeTakeFirstOrThrow();
}
export function findMapModePreferencesByUserId(userId: number) {
return db
.selectFrom("User")
.select("User.mapModePreferences")
.where("User.id", "=", userId)
.executeTakeFirstOrThrow()
.then((row) => row.mapModePreferences);
}
/** Match profile weapon pool of one user, with ten-star status. */
export function findWeaponPoolByUserId(userId: number) {
return db

View File

@@ -5,6 +5,7 @@ import {
formatFlexTimeDisplay,
generateTimeOptions,
parseLutiDivFromName,
parseLutiSeasonFromName,
parseMapPoolInput,
postSpan,
requestStarts,
@@ -23,6 +24,18 @@ describe("parseLutiDivFromName", () => {
});
});
describe("parseLutiSeasonFromName", () => {
test.each([
["LUTI: Season 15 - Division 2", 15],
["LUTI Season 17", 17],
["LUTI season 9 Div X", 9],
["LUTI Division 2", null],
["Seasonal Cup 3", null],
])("parses %s as %s", (name, expected) => {
expect(parseLutiSeasonFromName(name)).toBe(expected);
});
});
describe("generateTimeOptions", () => {
test("includes both start and end times", () => {
const start = new Date("2025-01-15T14:15:00");

View File

@@ -70,6 +70,14 @@ export const parseLutiDivFromName = (name: string): LutiDiv | null => {
: null;
};
/** The season number of a LUTI name like "LUTI: Season 15 - Division 2", or `null`. */
export const parseLutiSeasonFromName = (name: string): number | null => {
const match = name.match(/\bseason\s*(\d+)\b/i);
if (!match) return null;
return Number(match[1]);
};
export const serializeLutiDiv = (div: LutiDiv): number => {
if (div === "X") return 0;

View File

@@ -712,7 +712,7 @@ export async function findLatestFinalizedLeagueParticipants(args: {
"CalendarEvent.id",
"CalendarEventDate.eventId",
)
.select(["Tournament.id", "Tournament.settings"])
.select(["Tournament.id", "Tournament.settings", "CalendarEvent.name"])
.where("CalendarEvent.organizationId", "=", args.organizationId)
.where("CalendarEvent.name", "like", `${args.namePrefix}%`)
.where("Tournament.isFinalized", "=", 1)
@@ -742,6 +742,7 @@ export async function findLatestFinalizedLeagueParticipants(args: {
return {
tournamentId: league.id,
name: league.name,
bracketProgression: league.settings.bracketProgression,
participants,
};

View File

@@ -1120,6 +1120,19 @@ export async function findPatronStartedAtByUserId(userId: number) {
)?.patronStartedAt;
}
/** Division and season of the last LUTI the user placed in, `null` when they never have. */
export async function findDivByUserId(userId: number) {
const row = await db
.selectFrom("User")
.select(["User.div", "User.divSeason"])
.where("id", "=", userId)
.executeTakeFirst();
if (!row?.div) return null;
return { div: row.div, divSeason: row.divSeason };
}
export async function findJoinOrderByUserId(userId: number) {
const row = await db
.selectFrom("User")
@@ -1251,15 +1264,15 @@ 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 }>,
updates: Array<{ userId: number; div: string; divSeason: number | null }>,
) {
if (updates.length === 0) return;
return db.transaction().execute(async (trx) => {
for (const { userId, div } of updates) {
for (const { userId, div, divSeason } of updates) {
await trx
.updateTable("User")
.set({ div })
.set({ div, divSeason })
.where("id", "=", userId)
.execute();
}

View File

@@ -348,3 +348,82 @@
background-color: var(--color-bg-higher);
}
}
.mapModePreferences {
display: flex;
flex-direction: column;
gap: var(--s-4);
}
.mapModePreferenceStages {
display: flex;
flex-wrap: wrap;
justify-content: center;
gap: var(--s-1-5);
}
.liveStream {
display: flex;
flex-direction: column;
gap: var(--s-2);
color: var(--color-text);
}
.liveStreamThumbnail {
width: 100%;
aspect-ratio: 16 / 9;
object-fit: cover;
border-radius: var(--radius-field);
}
.liveStreamInfo {
display: flex;
align-items: center;
gap: var(--s-2);
font-size: var(--font-xs);
font-weight: var(--weight-semi);
}
/* same look as the sidebar's live badge */
.liveBadge {
display: flex;
align-items: center;
height: var(--selector-size-xs);
padding: 0 var(--s-1);
border-radius: var(--radius-selector);
background-color: var(--color-text-second);
color: var(--color-text-inverse);
font-size: var(--font-2xs);
font-weight: var(--weight-semi);
text-transform: uppercase;
}
.liveStreamName {
margin-inline-start: auto;
color: var(--color-text-high);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.countdownTitle {
font-size: var(--font-sm);
font-weight: var(--weight-semi);
text-align: center;
}
.countdown {
display: flex;
flex-wrap: wrap;
justify-content: center;
gap: var(--s-2) var(--s-3);
}
.countdownPart {
display: flex;
flex-direction: column;
align-items: center;
gap: var(--s-0-5);
min-width: 3ch;
font-variant-numeric: tabular-nums;
}

View File

@@ -3,9 +3,10 @@ import { Link2 as LinkIcon } from "lucide-react";
import { useTranslation } from "react-i18next";
import { Link } from "react-router";
import { BuildCard } from "~/components/BuildCard";
import { Divider } from "~/components/Divider";
import { SendouButton } from "~/components/elements/Button";
import { SendouPopover } from "~/components/elements/Popover";
import { Image, StageImage, WeaponImage } from "~/components/Image";
import { Image, ModeImage, StageImage, WeaponImage } from "~/components/Image";
import { BskyIcon } from "~/components/icons/Bsky";
import { DiscordIcon } from "~/components/icons/Discord";
import { TwitchIcon } from "~/components/icons/Twitch";
@@ -36,6 +37,7 @@ import type {
ModeShort,
StageId,
} from "~/modules/in-game-lists/types";
import { twitchThumbnailUrlToSrc } from "~/modules/twitch/utils";
import { logger } from "~/utils/logger";
import type { SerializeFrom } from "~/utils/remix";
import { rawSensToString } from "~/utils/strings";
@@ -50,10 +52,12 @@ import {
modeImageUrl,
navIconUrl,
teamPage,
twitchUrl,
userBuildsPage,
userResultsPage,
userVodsPage,
} from "~/utils/urls";
import * as Countdown from "../core/widgets/countdown";
import type { LoadedWidget } from "../core/widgets/types";
import styles from "./Widget.module.css";
@@ -279,6 +283,37 @@ export function Widget({
return widget.data.length === 0 ? null : (
<FriendsWidget friends={widget.data} />
);
case "luti-div":
if (!widget.data) return null;
return (
<BigValue
value={`Div ${widget.data.div}`}
footer={
widget.data.divSeason
? t("user:widget.luti-div.season", {
season: widget.data.divSeason,
})
: undefined
}
/>
);
case "map-mode-preferences":
return widget.data.length === 0 ? null : (
<MapModePreferencesWidget rows={widget.data} />
);
case "live-stream":
if (!widget.data) return null;
return <LiveStreamWidget stream={widget.data} />;
case "countdown":
return (
<CountdownWidget title={widget.data.title} date={widget.data.date} />
);
case "markdown":
return widget.data.content ? (
<article>
<Markdown>{widget.data.content}</Markdown>
</article>
) : null;
default:
assertUnreachable(widget);
}
@@ -976,3 +1011,110 @@ function GameBadgesDisplay({ badgeIds }: { badgeIds: string[] }) {
</div>
);
}
function MapModePreferencesWidget({
rows,
}: {
rows: Extract<LoadedWidget, { id: "map-mode-preferences" }>["data"];
}) {
return (
<div className={styles.mapModePreferences}>
{rows.map((row) => (
<div key={row.mode} className="stack sm">
<Divider>
<ModeImage mode={row.mode} size={32} />
</Divider>
<div className={styles.mapModePreferenceStages}>
{row.stages.map((stageId) => (
<StageImage
key={stageId}
stageId={stageId}
width={80}
className="rounded-sm"
/>
))}
</div>
</div>
))}
</div>
);
}
function LiveStreamWidget({
stream,
}: {
stream: NonNullable<Extract<LoadedWidget, { id: "live-stream" }>["data"]>;
}) {
const { t } = useTranslation(["user"]);
return (
<a
href={twitchUrl(stream.twitch)}
target="_blank"
rel="noreferrer"
className={styles.liveStream}
>
<img
src={twitchThumbnailUrlToSrc(stream.thumbnailUrl)}
alt=""
className={styles.liveStreamThumbnail}
loading="lazy"
/>
<div className={styles.liveStreamInfo}>
<span className={styles.liveBadge}>
{t("user:widget.live-stream.live")}
</span>
<span>
{t("user:widget.live-stream.viewers", {
count: stream.viewerCount,
})}
</span>
<span className={styles.liveStreamName}>{stream.twitch}</span>
</div>
</a>
);
}
const COUNTDOWN_UNITS_FAR = ["days", "hours", "minutes"] as const;
const COUNTDOWN_UNITS_NEAR = ["hours", "minutes", "seconds"] as const;
/** Comes out of the JSON column as an ISO string even though the settings type says `Date`. */
function CountdownWidget({
title,
date,
}: {
title: string;
date: Date | string;
}) {
const { t } = useTranslation(["user"]);
const now = useAutoRerender("second");
const target = new Date(date);
const remaining = Countdown.remainingUntil(now, target);
return (
<div className="stack sm items-center">
{title ? <div className={styles.countdownTitle}>{title}</div> : null}
{remaining ? (
<div className={styles.countdown}>
{(remaining.days > 0
? COUNTDOWN_UNITS_FAR
: COUNTDOWN_UNITS_NEAR
).map((unit) => (
<div key={unit} className={styles.countdownPart}>
<div className={styles.widgetValueMain}>{remaining[unit]}</div>
<div className={styles.widgetValueFooter}>
{t(`user:widget.countdown.${unit}` as const)}
</div>
</div>
))}
</div>
) : (
<LocaleTime
date={target}
options={{ day: "numeric", month: "numeric", year: "numeric" }}
className={styles.widgetValueMain}
/>
)}
</div>
);
}

View File

@@ -93,6 +93,15 @@ function WidgetFormFields({ widgetId }: { widgetId: string }) {
return <FormField name="links" />;
case "tier-list":
return <FormField name="searchParams" />;
case "countdown":
return (
<div className="stack md">
<FormField name="title" />
<FormField name="date" />
</div>
);
case "markdown":
return <FormField name="content" />;
case "badges-owned":
return <FavoriteBadgesField />;
case "game-badges":

View File

@@ -0,0 +1,38 @@
import { describe, expect, test } from "vitest";
import * as Countdown from "./countdown";
const NOW = new Date("2026-09-05T12:00:00Z");
describe("Countdown.remainingUntil", () => {
test.each([
{
why: "one full day",
target: "2026-09-06T12:00:00Z",
expected: { days: 1, hours: 0, minutes: 0, seconds: 0 },
},
{
why: "mixed units",
target: "2026-09-07T14:30:15Z",
expected: { days: 2, hours: 2, minutes: 30, seconds: 15 },
},
{
why: "under a minute",
target: "2026-09-05T12:00:45Z",
expected: { days: 0, hours: 0, minutes: 0, seconds: 45 },
},
{
why: "more than a year stays in days",
target: "2027-09-05T12:00:00Z",
expected: { days: 365, hours: 0, minutes: 0, seconds: 0 },
},
])("splits the remaining time ($why)", ({ target, expected }) => {
expect(Countdown.remainingUntil(NOW, new Date(target))).toEqual(expected);
});
test.each([
{ why: "same instant", target: "2026-09-05T12:00:00Z" },
{ why: "in the past", target: "2026-09-01T00:00:00Z" },
])("returns null once the target has passed ($why)", ({ target }) => {
expect(Countdown.remainingUntil(NOW, new Date(target))).toBeNull();
});
});

View File

@@ -0,0 +1,25 @@
import { differenceInSeconds } from "date-fns";
const SECONDS_IN_MINUTE = 60;
const SECONDS_IN_HOUR = 60 * SECONDS_IN_MINUTE;
const SECONDS_IN_DAY = 24 * SECONDS_IN_HOUR;
export interface CountdownParts {
days: number;
hours: number;
minutes: number;
seconds: number;
}
/** Time left from `now` until `target` split into whole days, hours, minutes and seconds. `null` once the target has passed. */
export function remainingUntil(now: Date, target: Date): CountdownParts | null {
const totalSeconds = differenceInSeconds(target, now);
if (totalSeconds <= 0) return null;
return {
days: Math.floor(totalSeconds / SECONDS_IN_DAY),
hours: Math.floor((totalSeconds % SECONDS_IN_DAY) / SECONDS_IN_HOUR),
minutes: Math.floor((totalSeconds % SECONDS_IN_HOUR) / SECONDS_IN_MINUTE),
seconds: totalSeconds % SECONDS_IN_MINUTE,
};
}

View File

@@ -5,6 +5,8 @@ import * as BuildRepository from "~/features/builds/BuildRepository.server";
import * as FriendRepository from "~/features/friends/FriendRepository.server";
import * as LeaderboardRepository from "~/features/leaderboards/LeaderboardRepository.server";
import * as LFGRepository from "~/features/lfg/LFGRepository.server";
import * as LiveStreamRepository from "~/features/live-streams/LiveStreamRepository.server";
import { BANNED_MAPS } from "~/features/match-profile/banned-maps";
import * as MatchProfileRepository from "~/features/match-profile/MatchProfileRepository.server";
import { ordinalToSp } from "~/features/mmr/mmr-utils";
import { userSkills as _userSkills } from "~/features/mmr/tiered.server";
@@ -15,6 +17,7 @@ import * as TrophyRepository from "~/features/trophies/TrophyRepository.server";
import { canAccessTrophies } from "~/features/trophies/trophies-utils";
import * as UserRepository from "~/features/user-page/UserRepository.server";
import * as VodRepository from "~/features/vods/VodRepository.server";
import { modesShort } from "~/modules/in-game-lists/modes";
import { weaponCategories } from "~/modules/in-game-lists/weapon-ids";
import type { ExtractWidgetSettings } from "./types";
import { cachedUserSQLeaderboardTopData } from "./utils.server";
@@ -314,6 +317,31 @@ export const WIDGET_LOADERS = {
friends: async (userId: number) => {
return FriendRepository.findFriendsByUserId(userId);
},
"luti-div": async (userId: number) => {
return UserRepository.findDivByUserId(userId);
},
"map-mode-preferences": async (userId: number) => {
const preferences =
await MatchProfileRepository.findMapModePreferencesByUserId(userId);
if (!preferences) return [];
return modesShort.flatMap((mode) => {
const preference = preferences.modes.find(
(m) => m.mode === mode,
)?.preference;
if (preference === "AVOID") return [];
const stages = (
preferences.pool.find((p) => p.mode === mode)?.stages ?? []
).filter((stageId) => !BANNED_MAPS[mode].includes(stageId));
if (stages.length === 0) return [];
return { mode, stages };
});
},
"live-stream": async (userId: number) => {
return LiveStreamRepository.findByUserId(userId);
},
};
async function getTop500WeaponsByCategory(

View File

@@ -8,10 +8,12 @@ import {
badgesOwnedSchema,
bioMdSchema,
bioSchema,
countdownSchema,
favoriteStageSchema,
gameBadgesSchema,
gameBadgesSmallSchema,
linksSchema,
markdownSchema,
peakXpUnverifiedSchema,
peakXpWeaponSchema,
sensSchema,
@@ -78,6 +80,22 @@ export const ALL_WIDGETS = {
schema: tierListSchema,
defaultSettings: { searchParams: "" },
}),
defineWidget({ id: "luti-div", slot: "side" }),
defineWidget({ id: "map-mode-preferences", slot: "main" }),
defineWidget({ id: "live-stream", slot: "side", supporterOnly: true }),
defineWidget({
id: "countdown",
slot: "side",
schema: countdownSchema,
defaultSettings: { title: "", date: new Date() },
}),
defineWidget({
id: "markdown",
slot: "side",
supporterOnly: true,
schema: markdownSchema,
defaultSettings: { content: "" },
}),
],
trophies: [defineWidget({ id: "trophies-owned", slot: "main" })],
badges: [

View File

@@ -1,3 +1,4 @@
import { addYears } from "date-fns";
import * as v from "valibot";
import { ART_SOURCES } from "~/features/art/art-types";
import { BADGE } from "~/features/badges/badges-constants";
@@ -6,6 +7,7 @@ import {
array,
badges,
customField,
datetime,
numberField,
select,
selectDynamic,
@@ -154,6 +156,27 @@ export const gameBadgesSmallSchema = v.object({
),
});
const COUNTDOWN_MAX_YEARS_AHEAD = 10;
export const countdownSchema = v.object({
title: textField({
label: "labels.title",
maxLength: USER.COUNTDOWN_TITLE_MAX_LENGTH,
}),
date: datetime({
label: "labels.date",
max: () => addYears(new Date(), COUNTDOWN_MAX_YEARS_AHEAD),
}),
});
export const markdownSchema = v.object({
content: textArea({
label: "labels.text",
bottomText: "bottomTexts.bioMarkdown",
maxLength: USER.MARKDOWN_WIDGET_MAX_LENGTH,
}),
});
const WIDGET_FORM_SCHEMAS: Record<string, FormObjectSchema> = {
bio: bioSchema,
"bio-md": bioMdSchema,
@@ -169,6 +192,8 @@ const WIDGET_FORM_SCHEMAS: Record<string, FormObjectSchema> = {
"badges-owned": badgesOwnedSchema,
"game-badges": gameBadgesSchema,
"game-badges-small": gameBadgesSmallSchema,
countdown: countdownSchema,
markdown: markdownSchema,
};
export function getWidgetFormSchema(widgetId: string) {

View File

@@ -14,6 +14,8 @@ export const USER = {
MAX_SIDE_WIDGETS_SUPPORTER: 7,
GAME_BADGES_MAX: 8,
GAME_BADGES_SMALL_MAX: 4,
COUNTDOWN_TITLE_MAX_LENGTH: 50,
MARKDOWN_WIDGET_MAX_LENGTH: 2000,
};
export const SPL2_JOIN_ORDER_CUTOFF = 13_589;

View File

@@ -1,4 +1,7 @@
import { parseLutiDivFromName } from "../features/scrims/scrims-utils";
import {
parseLutiDivFromName,
parseLutiSeasonFromName,
} from "../features/scrims/scrims-utils";
import * as TournamentRepository from "../features/tournament/TournamentRepository.server";
import { LUTI_ORGANIZATION_ID } from "../features/tournament-organization/tournament-organization-constants";
import * as UserRepository from "../features/user-page/UserRepository.server";
@@ -22,6 +25,14 @@ export const ComputeLutiDivsRoutine = new Routine({
});
if (!league) return;
const divSeason = parseLutiSeasonFromName(league.name);
if (!divSeason) {
logger.warn(
`ComputeLutiDivs: could not parse season from league name "${league.name}", skipping the run`,
);
return;
}
const divByBracketIdx = new Map<number, string | null>();
const divOfBracket = (bracketIdx: number) => {
if (!divByBracketIdx.has(bracketIdx)) {
@@ -38,12 +49,12 @@ export const ComputeLutiDivsRoutine = new Routine({
return divByBracketIdx.get(bracketIdx)!;
};
const updates: Array<{ userId: number; div: string }> = [];
const updates: Parameters<typeof UserRepository.updateManyDivs>[0] = [];
for (const participant of league.participants) {
const div = divOfBracket(participant.startingBracketIdx ?? 0);
if (!div) continue;
updates.push({ userId: participant.userId, div });
updates.push({ userId: participant.userId, div, divSeason });
}
await UserRepository.updateManyDivs(updates);

View File

@@ -0,0 +1,10 @@
---
type: feature
---
New profile widgets
- LUTI Division: show your division from the latest LUTI season
- Map & Mode Preferences: show the modes and your map pool from your match profile
- Countdown: count down to a date & event of your choosing
- Live Stream (Supporter): show your Twitch stream while you are live streaming Splatoon 3
- Text Block (Supporter): a side widget for anything you want to write with markdown formatting

View File

@@ -263,6 +263,7 @@
"options.controller.grip": "",
"options.controller.handheld": "",
"labels.tierListUrl": "",
"labels.title": "",
"labels.plusTier": "",
"labels.comment": "",
"errors.plusAlreadySuggested": "",

View File

@@ -47,6 +47,19 @@
"widget.game-badges-small": "",
"widget.friends": "",
"widget.tier-list": "",
"widget.luti-div": "",
"widget.luti-div.season": "",
"widget.map-mode-preferences": "",
"widget.live-stream": "",
"widget.live-stream.live": "",
"widget.live-stream.viewers_one": "",
"widget.live-stream.viewers_other": "",
"widget.countdown": "",
"widget.countdown.days": "",
"widget.countdown.hours": "",
"widget.countdown.minutes": "",
"widget.countdown.seconds": "",
"widget.markdown": "",
"widget.link.all": "",
"widgets.edit": "",
"widgets.editProfile": "",
@@ -120,6 +133,11 @@
"widgets.description.game-badges-small": "",
"widgets.description.friends": "",
"widgets.description.tier-list": "",
"widgets.description.luti-div": "",
"widgets.description.map-mode-preferences": "",
"widgets.description.live-stream": "",
"widgets.description.countdown": "",
"widgets.description.markdown": "",
"widgets.forms.bio": "",
"widgets.forms.bio.markdownSupport": "",
"widgets.forms.division": "",

View File

@@ -263,6 +263,7 @@
"options.controller.grip": "",
"options.controller.handheld": "",
"labels.tierListUrl": "",
"labels.title": "",
"labels.plusTier": "",
"labels.comment": "",
"errors.plusAlreadySuggested": "",

View File

@@ -47,6 +47,19 @@
"widget.game-badges-small": "",
"widget.friends": "",
"widget.tier-list": "",
"widget.luti-div": "",
"widget.luti-div.season": "",
"widget.map-mode-preferences": "",
"widget.live-stream": "",
"widget.live-stream.live": "",
"widget.live-stream.viewers_one": "",
"widget.live-stream.viewers_other": "",
"widget.countdown": "",
"widget.countdown.days": "",
"widget.countdown.hours": "",
"widget.countdown.minutes": "",
"widget.countdown.seconds": "",
"widget.markdown": "",
"widget.link.all": "",
"widgets.edit": "",
"widgets.editProfile": "",
@@ -120,6 +133,11 @@
"widgets.description.game-badges-small": "",
"widgets.description.friends": "",
"widgets.description.tier-list": "",
"widgets.description.luti-div": "",
"widgets.description.map-mode-preferences": "",
"widgets.description.live-stream": "",
"widgets.description.countdown": "",
"widgets.description.markdown": "",
"widgets.forms.bio": "",
"widgets.forms.bio.markdownSupport": "",
"widgets.forms.division": "",

View File

@@ -263,6 +263,7 @@
"options.controller.grip": "Joy-Con Grip",
"options.controller.handheld": "Handheld",
"labels.tierListUrl": "Tier List URL",
"labels.title": "Title",
"labels.plusTier": "Tier",
"labels.comment": "Comment",
"errors.plusAlreadySuggested": "This user has already been suggested",

View File

@@ -47,6 +47,19 @@
"widget.game-badges-small": "Game Badges",
"widget.friends": "Friends",
"widget.tier-list": "Featured Tier List",
"widget.luti-div": "LUTI Division",
"widget.luti-div.season": "Season {{season}}",
"widget.map-mode-preferences": "Map & Mode Preferences",
"widget.live-stream": "Live Stream",
"widget.live-stream.live": "Live",
"widget.live-stream.viewers_one": "{{count}} viewer",
"widget.live-stream.viewers_other": "{{count}} viewers",
"widget.countdown": "Countdown",
"widget.countdown.days": "days",
"widget.countdown.hours": "hours",
"widget.countdown.minutes": "minutes",
"widget.countdown.seconds": "seconds",
"widget.markdown": "Text Block",
"widget.link.all": "All",
"widgets.edit": "Edit Widgets",
"widgets.editProfile": "Edit Profile",
@@ -120,6 +133,11 @@
"widgets.description.game-badges-small": "Display up to {{max}} in-game badges on your profile",
"widgets.description.friends": "Display your friends list",
"widgets.description.tier-list": "Display a tier list you have made",
"widgets.description.luti-div": "Show your division from the most recently finished LUTI season",
"widgets.description.map-mode-preferences": "Show the modes and map pool from your match profile",
"widgets.description.live-stream": "Show your Twitch stream while you are live streaming Splatoon 3",
"widgets.description.countdown": "Count down to a date and event of your choosing",
"widgets.description.markdown": "Write anything you want with markdown formatting",
"widgets.forms.bio": "Bio",
"widgets.forms.bio.markdownSupport": "Supports Markdown",
"widgets.forms.division": "Division",

View File

@@ -263,6 +263,7 @@
"options.controller.grip": "Soporte para Joy-Con",
"options.controller.handheld": "Modo portátil",
"labels.tierListUrl": "URL de Tier List",
"labels.title": "",
"labels.plusTier": "Tier",
"labels.comment": "Comentario",
"errors.plusAlreadySuggested": "Este usuario ya ha sido sugerido",

View File

@@ -47,6 +47,20 @@
"widget.game-badges-small": "Chapas del juego",
"widget.friends": "Amigos",
"widget.tier-list": "Tier List destacada",
"widget.luti-div": "",
"widget.luti-div.season": "",
"widget.map-mode-preferences": "",
"widget.live-stream": "",
"widget.live-stream.live": "",
"widget.live-stream.viewers_one": "",
"widget.live-stream.viewers_many": "",
"widget.live-stream.viewers_other": "",
"widget.countdown": "",
"widget.countdown.days": "",
"widget.countdown.hours": "",
"widget.countdown.minutes": "",
"widget.countdown.seconds": "",
"widget.markdown": "",
"widget.link.all": "Todos",
"widgets.edit": "Editar widgets",
"widgets.editProfile": "Editar perfil",
@@ -120,6 +134,11 @@
"widgets.description.game-badges-small": "Muestra hasta {{max}} chapas del juego en tu perfil",
"widgets.description.friends": "Muestra tu lista de amigos",
"widgets.description.tier-list": "Muestra una Tier List que hayas creado",
"widgets.description.luti-div": "",
"widgets.description.map-mode-preferences": "",
"widgets.description.live-stream": "",
"widgets.description.countdown": "",
"widgets.description.markdown": "",
"widgets.forms.bio": "Biografía",
"widgets.forms.bio.markdownSupport": "Compatible con Markdown",
"widgets.forms.division": "División",

View File

@@ -263,6 +263,7 @@
"options.controller.grip": "Soporte para Joy-Con",
"options.controller.handheld": "Modo portátil",
"labels.tierListUrl": "URL de Tier List",
"labels.title": "",
"labels.plusTier": "Tier",
"labels.comment": "Comentario",
"errors.plusAlreadySuggested": "Este usuario ya ha sido sugerido",

View File

@@ -47,6 +47,20 @@
"widget.game-badges-small": "Insignias del juego",
"widget.friends": "Amigos",
"widget.tier-list": "Tier List destacada",
"widget.luti-div": "",
"widget.luti-div.season": "",
"widget.map-mode-preferences": "",
"widget.live-stream": "",
"widget.live-stream.live": "",
"widget.live-stream.viewers_one": "",
"widget.live-stream.viewers_many": "",
"widget.live-stream.viewers_other": "",
"widget.countdown": "",
"widget.countdown.days": "",
"widget.countdown.hours": "",
"widget.countdown.minutes": "",
"widget.countdown.seconds": "",
"widget.markdown": "",
"widget.link.all": "Todos",
"widgets.edit": "Editar widgets",
"widgets.editProfile": "Editar perfil",
@@ -120,6 +134,11 @@
"widgets.description.game-badges-small": "Muestra hasta {{max}} insignias del juego en tu perfil",
"widgets.description.friends": "Muestra tu lista de amigos",
"widgets.description.tier-list": "Muestra una Tier List que hayas creado",
"widgets.description.luti-div": "",
"widgets.description.map-mode-preferences": "",
"widgets.description.live-stream": "",
"widgets.description.countdown": "",
"widgets.description.markdown": "",
"widgets.forms.bio": "Biografía",
"widgets.forms.bio.markdownSupport": "Compatible con Markdown",
"widgets.forms.division": "División",

View File

@@ -263,6 +263,7 @@
"options.controller.grip": "",
"options.controller.handheld": "",
"labels.tierListUrl": "",
"labels.title": "",
"labels.plusTier": "",
"labels.comment": "",
"errors.plusAlreadySuggested": "",

View File

@@ -47,6 +47,20 @@
"widget.game-badges-small": "",
"widget.friends": "",
"widget.tier-list": "",
"widget.luti-div": "",
"widget.luti-div.season": "",
"widget.map-mode-preferences": "",
"widget.live-stream": "",
"widget.live-stream.live": "",
"widget.live-stream.viewers_one": "",
"widget.live-stream.viewers_many": "",
"widget.live-stream.viewers_other": "",
"widget.countdown": "",
"widget.countdown.days": "",
"widget.countdown.hours": "",
"widget.countdown.minutes": "",
"widget.countdown.seconds": "",
"widget.markdown": "",
"widget.link.all": "",
"widgets.edit": "",
"widgets.editProfile": "",
@@ -120,6 +134,11 @@
"widgets.description.game-badges-small": "",
"widgets.description.friends": "",
"widgets.description.tier-list": "",
"widgets.description.luti-div": "",
"widgets.description.map-mode-preferences": "",
"widgets.description.live-stream": "",
"widgets.description.countdown": "",
"widgets.description.markdown": "",
"widgets.forms.bio": "",
"widgets.forms.bio.markdownSupport": "",
"widgets.forms.division": "",

View File

@@ -263,6 +263,7 @@
"options.controller.grip": "",
"options.controller.handheld": "",
"labels.tierListUrl": "",
"labels.title": "",
"labels.plusTier": "",
"labels.comment": "Commentaire",
"errors.plusAlreadySuggested": "",

View File

@@ -47,6 +47,20 @@
"widget.game-badges-small": "",
"widget.friends": "",
"widget.tier-list": "",
"widget.luti-div": "",
"widget.luti-div.season": "",
"widget.map-mode-preferences": "",
"widget.live-stream": "",
"widget.live-stream.live": "",
"widget.live-stream.viewers_one": "",
"widget.live-stream.viewers_many": "",
"widget.live-stream.viewers_other": "",
"widget.countdown": "",
"widget.countdown.days": "",
"widget.countdown.hours": "",
"widget.countdown.minutes": "",
"widget.countdown.seconds": "",
"widget.markdown": "",
"widget.link.all": "",
"widgets.edit": "",
"widgets.editProfile": "",
@@ -120,6 +134,11 @@
"widgets.description.game-badges-small": "",
"widgets.description.friends": "",
"widgets.description.tier-list": "",
"widgets.description.luti-div": "",
"widgets.description.map-mode-preferences": "",
"widgets.description.live-stream": "",
"widgets.description.countdown": "",
"widgets.description.markdown": "",
"widgets.forms.bio": "",
"widgets.forms.bio.markdownSupport": "",
"widgets.forms.division": "",

View File

@@ -263,6 +263,7 @@
"options.controller.grip": "",
"options.controller.handheld": "",
"labels.tierListUrl": "",
"labels.title": "",
"labels.plusTier": "",
"labels.comment": "",
"errors.plusAlreadySuggested": "",

View File

@@ -47,6 +47,20 @@
"widget.game-badges-small": "",
"widget.friends": "",
"widget.tier-list": "",
"widget.luti-div": "",
"widget.luti-div.season": "",
"widget.map-mode-preferences": "",
"widget.live-stream": "",
"widget.live-stream.live": "",
"widget.live-stream.viewers_one": "",
"widget.live-stream.viewers_two": "",
"widget.live-stream.viewers_other": "",
"widget.countdown": "",
"widget.countdown.days": "",
"widget.countdown.hours": "",
"widget.countdown.minutes": "",
"widget.countdown.seconds": "",
"widget.markdown": "",
"widget.link.all": "",
"widgets.edit": "",
"widgets.editProfile": "",
@@ -120,6 +134,11 @@
"widgets.description.game-badges-small": "",
"widgets.description.friends": "",
"widgets.description.tier-list": "",
"widgets.description.luti-div": "",
"widgets.description.map-mode-preferences": "",
"widgets.description.live-stream": "",
"widgets.description.countdown": "",
"widgets.description.markdown": "",
"widgets.forms.bio": "",
"widgets.forms.bio.markdownSupport": "",
"widgets.forms.division": "",

View File

@@ -263,6 +263,7 @@
"options.controller.grip": "",
"options.controller.handheld": "",
"labels.tierListUrl": "",
"labels.title": "",
"labels.plusTier": "",
"labels.comment": "Commento",
"errors.plusAlreadySuggested": "",

View File

@@ -47,6 +47,20 @@
"widget.game-badges-small": "",
"widget.friends": "",
"widget.tier-list": "",
"widget.luti-div": "",
"widget.luti-div.season": "",
"widget.map-mode-preferences": "",
"widget.live-stream": "",
"widget.live-stream.live": "",
"widget.live-stream.viewers_one": "",
"widget.live-stream.viewers_many": "",
"widget.live-stream.viewers_other": "",
"widget.countdown": "",
"widget.countdown.days": "",
"widget.countdown.hours": "",
"widget.countdown.minutes": "",
"widget.countdown.seconds": "",
"widget.markdown": "",
"widget.link.all": "",
"widgets.edit": "",
"widgets.editProfile": "",
@@ -120,6 +134,11 @@
"widgets.description.game-badges-small": "",
"widgets.description.friends": "",
"widgets.description.tier-list": "",
"widgets.description.luti-div": "",
"widgets.description.map-mode-preferences": "",
"widgets.description.live-stream": "",
"widgets.description.countdown": "",
"widgets.description.markdown": "",
"widgets.forms.bio": "",
"widgets.forms.bio.markdownSupport": "",
"widgets.forms.division": "",

View File

@@ -263,6 +263,7 @@
"options.controller.grip": "",
"options.controller.handheld": "",
"labels.tierListUrl": "",
"labels.title": "",
"labels.plusTier": "",
"labels.comment": "コメント",
"errors.plusAlreadySuggested": "",

View File

@@ -47,6 +47,17 @@
"widget.game-badges-small": "",
"widget.friends": "",
"widget.tier-list": "",
"widget.luti-div": "",
"widget.luti-div.season": "",
"widget.map-mode-preferences": "",
"widget.live-stream": "",
"widget.live-stream.live": "",
"widget.countdown": "",
"widget.countdown.days": "",
"widget.countdown.hours": "",
"widget.countdown.minutes": "",
"widget.countdown.seconds": "",
"widget.markdown": "",
"widget.link.all": "",
"widgets.edit": "",
"widgets.editProfile": "",
@@ -120,6 +131,11 @@
"widgets.description.game-badges-small": "",
"widgets.description.friends": "",
"widgets.description.tier-list": "",
"widgets.description.luti-div": "",
"widgets.description.map-mode-preferences": "",
"widgets.description.live-stream": "",
"widgets.description.countdown": "",
"widgets.description.markdown": "",
"widgets.forms.bio": "",
"widgets.forms.bio.markdownSupport": "",
"widgets.forms.division": "",

View File

@@ -263,6 +263,7 @@
"options.controller.grip": "",
"options.controller.handheld": "",
"labels.tierListUrl": "",
"labels.title": "",
"labels.plusTier": "",
"labels.comment": "",
"errors.plusAlreadySuggested": "",

View File

@@ -47,6 +47,17 @@
"widget.game-badges-small": "",
"widget.friends": "",
"widget.tier-list": "",
"widget.luti-div": "",
"widget.luti-div.season": "",
"widget.map-mode-preferences": "",
"widget.live-stream": "",
"widget.live-stream.live": "",
"widget.countdown": "",
"widget.countdown.days": "",
"widget.countdown.hours": "",
"widget.countdown.minutes": "",
"widget.countdown.seconds": "",
"widget.markdown": "",
"widget.link.all": "",
"widgets.edit": "",
"widgets.editProfile": "",
@@ -120,6 +131,11 @@
"widgets.description.game-badges-small": "",
"widgets.description.friends": "",
"widgets.description.tier-list": "",
"widgets.description.luti-div": "",
"widgets.description.map-mode-preferences": "",
"widgets.description.live-stream": "",
"widgets.description.countdown": "",
"widgets.description.markdown": "",
"widgets.forms.bio": "",
"widgets.forms.bio.markdownSupport": "",
"widgets.forms.division": "",

View File

@@ -263,6 +263,7 @@
"options.controller.grip": "",
"options.controller.handheld": "",
"labels.tierListUrl": "",
"labels.title": "",
"labels.plusTier": "",
"labels.comment": "",
"errors.plusAlreadySuggested": "",

View File

@@ -47,6 +47,19 @@
"widget.game-badges-small": "",
"widget.friends": "",
"widget.tier-list": "",
"widget.luti-div": "",
"widget.luti-div.season": "",
"widget.map-mode-preferences": "",
"widget.live-stream": "",
"widget.live-stream.live": "",
"widget.live-stream.viewers_one": "",
"widget.live-stream.viewers_other": "",
"widget.countdown": "",
"widget.countdown.days": "",
"widget.countdown.hours": "",
"widget.countdown.minutes": "",
"widget.countdown.seconds": "",
"widget.markdown": "",
"widget.link.all": "",
"widgets.edit": "",
"widgets.editProfile": "",
@@ -120,6 +133,11 @@
"widgets.description.game-badges-small": "",
"widgets.description.friends": "",
"widgets.description.tier-list": "",
"widgets.description.luti-div": "",
"widgets.description.map-mode-preferences": "",
"widgets.description.live-stream": "",
"widgets.description.countdown": "",
"widgets.description.markdown": "",
"widgets.forms.bio": "",
"widgets.forms.bio.markdownSupport": "",
"widgets.forms.division": "",

View File

@@ -263,6 +263,7 @@
"options.controller.grip": "",
"options.controller.handheld": "",
"labels.tierListUrl": "",
"labels.title": "",
"labels.plusTier": "",
"labels.comment": "",
"errors.plusAlreadySuggested": "",

View File

@@ -47,6 +47,21 @@
"widget.game-badges-small": "",
"widget.friends": "",
"widget.tier-list": "",
"widget.luti-div": "",
"widget.luti-div.season": "",
"widget.map-mode-preferences": "",
"widget.live-stream": "",
"widget.live-stream.live": "",
"widget.live-stream.viewers_one": "",
"widget.live-stream.viewers_few": "",
"widget.live-stream.viewers_many": "",
"widget.live-stream.viewers_other": "",
"widget.countdown": "",
"widget.countdown.days": "",
"widget.countdown.hours": "",
"widget.countdown.minutes": "",
"widget.countdown.seconds": "",
"widget.markdown": "",
"widget.link.all": "",
"widgets.edit": "",
"widgets.editProfile": "",
@@ -120,6 +135,11 @@
"widgets.description.game-badges-small": "",
"widgets.description.friends": "",
"widgets.description.tier-list": "",
"widgets.description.luti-div": "",
"widgets.description.map-mode-preferences": "",
"widgets.description.live-stream": "",
"widgets.description.countdown": "",
"widgets.description.markdown": "",
"widgets.forms.bio": "",
"widgets.forms.bio.markdownSupport": "",
"widgets.forms.division": "",

View File

@@ -263,6 +263,7 @@
"options.controller.grip": "",
"options.controller.handheld": "",
"labels.tierListUrl": "",
"labels.title": "",
"labels.plusTier": "",
"labels.comment": "Comentário",
"errors.plusAlreadySuggested": "",

View File

@@ -47,6 +47,20 @@
"widget.game-badges-small": "",
"widget.friends": "",
"widget.tier-list": "",
"widget.luti-div": "",
"widget.luti-div.season": "",
"widget.map-mode-preferences": "",
"widget.live-stream": "",
"widget.live-stream.live": "",
"widget.live-stream.viewers_one": "",
"widget.live-stream.viewers_many": "",
"widget.live-stream.viewers_other": "",
"widget.countdown": "",
"widget.countdown.days": "",
"widget.countdown.hours": "",
"widget.countdown.minutes": "",
"widget.countdown.seconds": "",
"widget.markdown": "",
"widget.link.all": "",
"widgets.edit": "",
"widgets.editProfile": "",
@@ -120,6 +134,11 @@
"widgets.description.game-badges-small": "",
"widgets.description.friends": "",
"widgets.description.tier-list": "",
"widgets.description.luti-div": "",
"widgets.description.map-mode-preferences": "",
"widgets.description.live-stream": "",
"widgets.description.countdown": "",
"widgets.description.markdown": "",
"widgets.forms.bio": "",
"widgets.forms.bio.markdownSupport": "",
"widgets.forms.division": "",

View File

@@ -263,6 +263,7 @@
"options.controller.grip": "",
"options.controller.handheld": "",
"labels.tierListUrl": "",
"labels.title": "",
"labels.plusTier": "",
"labels.comment": "Комментарий",
"errors.plusAlreadySuggested": "",

View File

@@ -47,6 +47,21 @@
"widget.game-badges-small": "",
"widget.friends": "",
"widget.tier-list": "",
"widget.luti-div": "",
"widget.luti-div.season": "",
"widget.map-mode-preferences": "",
"widget.live-stream": "",
"widget.live-stream.live": "",
"widget.live-stream.viewers_one": "",
"widget.live-stream.viewers_few": "",
"widget.live-stream.viewers_many": "",
"widget.live-stream.viewers_other": "",
"widget.countdown": "",
"widget.countdown.days": "",
"widget.countdown.hours": "",
"widget.countdown.minutes": "",
"widget.countdown.seconds": "",
"widget.markdown": "",
"widget.link.all": "",
"widgets.edit": "",
"widgets.editProfile": "",
@@ -120,6 +135,11 @@
"widgets.description.game-badges-small": "",
"widgets.description.friends": "",
"widgets.description.tier-list": "",
"widgets.description.luti-div": "",
"widgets.description.map-mode-preferences": "",
"widgets.description.live-stream": "",
"widgets.description.countdown": "",
"widgets.description.markdown": "",
"widgets.forms.bio": "",
"widgets.forms.bio.markdownSupport": "",
"widgets.forms.division": "",

View File

@@ -263,6 +263,7 @@
"options.controller.grip": "Joy-Con 握把",
"options.controller.handheld": "手提模式",
"labels.tierListUrl": "自制强度榜 URL",
"labels.title": "",
"labels.plusTier": "级别",
"labels.comment": "评论",
"errors.plusAlreadySuggested": "此用户已被推荐过",

View File

@@ -47,6 +47,17 @@
"widget.game-badges-small": "游戏内徽章",
"widget.friends": "好友",
"widget.tier-list": "推荐自制强度榜",
"widget.luti-div": "",
"widget.luti-div.season": "",
"widget.map-mode-preferences": "",
"widget.live-stream": "",
"widget.live-stream.live": "",
"widget.countdown": "",
"widget.countdown.days": "",
"widget.countdown.hours": "",
"widget.countdown.minutes": "",
"widget.countdown.seconds": "",
"widget.markdown": "",
"widget.link.all": "全部",
"widgets.edit": "编辑小组件",
"widgets.editProfile": "编辑个人资料",
@@ -120,6 +131,11 @@
"widgets.description.game-badges-small": "在个人资料中最多展示 {{max}} 个游戏内徽章",
"widgets.description.friends": "展示您的好友列表",
"widgets.description.tier-list": "展示您制作的自制强度榜",
"widgets.description.luti-div": "",
"widgets.description.map-mode-preferences": "",
"widgets.description.live-stream": "",
"widgets.description.countdown": "",
"widgets.description.markdown": "",
"widgets.forms.bio": "个人简介",
"widgets.forms.bio.markdownSupport": "支持 Markdown 语法",
"widgets.forms.division": "组别",

View File

@@ -0,0 +1,16 @@
import { type Kysely, sql } from "kysely";
/**
* LUTI season the user's `div` was earned in, so the profile can say which season it is from.
* Filled by the div routine from now on; every division stored so far came from season 17.
*/
export async function up(db: Kysely<any>): Promise<void> {
await db.schema
.alterTable("User")
.addColumn("divSeason", "integer")
.execute();
await sql`update "User" set "divSeason" = 17 where "div" is not null`.execute(
db,
);
}

View File

@@ -433,6 +433,9 @@ export function buildCases(fx: Fixtures): {
LFGRepository.findByAuthorUserId(authorId),
);
add("LiveStreamRepository.findByUserId", fx.heavyUser, (user) =>
LiveStreamRepository.findByUserId(user.id),
);
addStatic("LiveStreamRepository.findXRankStreams", () =>
LiveStreamRepository.findXRankStreams(),
);
@@ -440,6 +443,11 @@ export function buildCases(fx: Fixtures): {
add("MatchProfileRepository.findSettingsByUserId", fx.heavyUser, (user) =>
MatchProfileRepository.findSettingsByUserId(user.id),
);
add(
"MatchProfileRepository.findMapModePreferencesByUserId",
fx.heavyUser,
(user) => MatchProfileRepository.findMapModePreferencesByUserId(user.id),
);
add("MatchProfileRepository.findWeaponPoolByUserId", fx.heavyUser, (user) =>
MatchProfileRepository.findWeaponPoolByUserId(user.id),
);
@@ -1385,6 +1393,9 @@ export function buildCases(fx: Fixtures): {
add("UserRepository.findPatronStartedAtByUserId", fx.heavyUser, (user) =>
UserRepository.findPatronStartedAtByUserId(user.id),
);
add("UserRepository.findDivByUserId", fx.heavyUser, (user) =>
UserRepository.findDivByUserId(user.id),
);
add("UserRepository.findJoinOrderByUserId", fx.heavyUser, (user) =>
UserRepository.findJoinOrderByUserId(user.id),
);