From 0e0aa94d0eb3846d8246e7a131c550d57e44fc4e Mon Sep 17 00:00:00 2001 From: Kalle <38327916+Sendouc@users.noreply.github.com> Date: Sun, 6 Sep 2026 07:40:56 +0300 Subject: [PATCH] New widgets --- app/db/seed/dev/misc.ts | 7 +- app/db/seed/dev/users.ts | 24 ++- app/db/seed/factories/UserFactory.ts | 7 +- app/db/tables.ts | 2 + .../LiveStreamRepository.server.ts | 15 ++ .../MatchProfileRepository.server.ts | 9 ++ app/features/scrims/scrims-utils.test.ts | 13 ++ app/features/scrims/scrims-utils.ts | 8 + .../tournament/TournamentRepository.server.ts | 3 +- .../user-page/UserRepository.server.ts | 19 ++- .../user-page/components/Widget.module.css | 79 ++++++++++ app/features/user-page/components/Widget.tsx | 144 +++++++++++++++++- .../components/WidgetSettingsForm.tsx | 9 ++ .../user-page/core/widgets/countdown.test.ts | 38 +++++ .../user-page/core/widgets/countdown.ts | 25 +++ .../core/widgets/portfolio-loaders.server.ts | 28 ++++ .../user-page/core/widgets/portfolio.ts | 18 +++ .../core/widgets/widget-form-schemas.ts | 25 +++ app/features/user-page/user-page-constants.ts | 2 + app/routines/computeLutiDivs.ts | 17 ++- changelog/2026-09-05-new-profile-widgets.md | 10 ++ locales/da/forms.json | 1 + locales/da/user.json | 18 +++ locales/de/forms.json | 1 + locales/de/user.json | 18 +++ locales/en/forms.json | 1 + locales/en/user.json | 18 +++ locales/es-ES/forms.json | 1 + locales/es-ES/user.json | 19 +++ locales/es-US/forms.json | 1 + locales/es-US/user.json | 19 +++ locales/fr-CA/forms.json | 1 + locales/fr-CA/user.json | 19 +++ locales/fr-EU/forms.json | 1 + locales/fr-EU/user.json | 19 +++ locales/he/forms.json | 1 + locales/he/user.json | 19 +++ locales/it/forms.json | 1 + locales/it/user.json | 19 +++ locales/ja/forms.json | 1 + locales/ja/user.json | 16 ++ locales/ko/forms.json | 1 + locales/ko/user.json | 16 ++ locales/nl/forms.json | 1 + locales/nl/user.json | 18 +++ locales/pl/forms.json | 1 + locales/pl/user.json | 20 +++ locales/pt-BR/forms.json | 1 + locales/pt-BR/user.json | 19 +++ locales/ru/forms.json | 1 + locales/ru/user.json | 20 +++ locales/zh/forms.json | 1 + locales/zh/user.json | 16 ++ migrations/20260905192242-user-div-season.ts | 16 ++ scripts/benchmark-db/cases.ts | 11 ++ 55 files changed, 822 insertions(+), 16 deletions(-) create mode 100644 app/features/user-page/core/widgets/countdown.test.ts create mode 100644 app/features/user-page/core/widgets/countdown.ts create mode 100644 changelog/2026-09-05-new-profile-widgets.md create mode 100644 migrations/20260905192242-user-div-season.ts diff --git a/app/db/seed/dev/misc.ts b/app/db/seed/dev/misc.ts index f1616e166..a50ccf35b 100644 --- a/app/db/seed/dev/misc.ts +++ b/app/db/seed/dev/misc.ts @@ -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) }; diff --git a/app/db/seed/dev/users.ts b/app/db/seed/dev/users.ts index cded296e8..a695ba73b 100644 --- a/app/db/seed/dev/users.ts +++ b/app/db/seed/dev/users.ts @@ -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 { { 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[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(), diff --git a/app/db/seed/factories/UserFactory.ts b/app/db/seed/factories/UserFactory.ts index b1bcf6483..2d8ff2de1 100644 --- a/app/db/seed/factories/UserFactory.ts +++ b/app/db/seed/factories/UserFactory.ts @@ -45,6 +45,8 @@ type Options = { ban?: Omit[0], "userId">; /** Division the user played their last season in. */ div?: NonNullable; + /** LUTI season `div` is from, only applied together with it. */ + divSeason?: number; /** User card fields, submitted as the user themselves. */ card?: Partial; /** 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) { diff --git a/app/db/tables.ts b/app/db/tables.ts index 616beaf10..ab96f2c28 100644 --- a/app/db/tables.ts +++ b/app/db/tables.ts @@ -999,6 +999,8 @@ export interface User { hiddenCardStats: JSONColumnTypeNullable>; /** Div in the latest finished LUTI (e.g. "2" or "X"). Must have been in a team that did not drop and the user played at least one match (got result as well) */ div: string | null; + /** 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; /** Division the user card's XP is taken from. `null` when the user has not picked one, showing their highest XP across both. */ diff --git a/app/features/live-streams/LiveStreamRepository.server.ts b/app/features/live-streams/LiveStreamRepository.server.ts index 56deb8265..87deb20a9 100644 --- a/app/features/live-streams/LiveStreamRepository.server.ts +++ b/app/features/live-streams/LiveStreamRepository.server.ts @@ -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") diff --git a/app/features/match-profile/MatchProfileRepository.server.ts b/app/features/match-profile/MatchProfileRepository.server.ts index 521168bca..95f5c7cdc 100644 --- a/app/features/match-profile/MatchProfileRepository.server.ts +++ b/app/features/match-profile/MatchProfileRepository.server.ts @@ -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 diff --git a/app/features/scrims/scrims-utils.test.ts b/app/features/scrims/scrims-utils.test.ts index 5e05ee41a..adbb0a4f3 100644 --- a/app/features/scrims/scrims-utils.test.ts +++ b/app/features/scrims/scrims-utils.test.ts @@ -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"); diff --git a/app/features/scrims/scrims-utils.ts b/app/features/scrims/scrims-utils.ts index 7880f838a..ffd9cbf4c 100644 --- a/app/features/scrims/scrims-utils.ts +++ b/app/features/scrims/scrims-utils.ts @@ -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; diff --git a/app/features/tournament/TournamentRepository.server.ts b/app/features/tournament/TournamentRepository.server.ts index 6c694d44e..7249a92dd 100644 --- a/app/features/tournament/TournamentRepository.server.ts +++ b/app/features/tournament/TournamentRepository.server.ts @@ -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, }; diff --git a/app/features/user-page/UserRepository.server.ts b/app/features/user-page/UserRepository.server.ts index f36d5648a..b2037b346 100644 --- a/app/features/user-page/UserRepository.server.ts +++ b/app/features/user-page/UserRepository.server.ts @@ -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(); } diff --git a/app/features/user-page/components/Widget.module.css b/app/features/user-page/components/Widget.module.css index f2924d6b9..287cc8dd9 100644 --- a/app/features/user-page/components/Widget.module.css +++ b/app/features/user-page/components/Widget.module.css @@ -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; +} diff --git a/app/features/user-page/components/Widget.tsx b/app/features/user-page/components/Widget.tsx index aef226bc7..4122e9349 100644 --- a/app/features/user-page/components/Widget.tsx +++ b/app/features/user-page/components/Widget.tsx @@ -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 : ( ); + case "luti-div": + if (!widget.data) return null; + return ( + + ); + case "map-mode-preferences": + return widget.data.length === 0 ? null : ( + + ); + case "live-stream": + if (!widget.data) return null; + return ; + case "countdown": + return ( + + ); + case "markdown": + return widget.data.content ? ( +
+ {widget.data.content} +
+ ) : null; default: assertUnreachable(widget); } @@ -976,3 +1011,110 @@ function GameBadgesDisplay({ badgeIds }: { badgeIds: string[] }) { ); } + +function MapModePreferencesWidget({ + rows, +}: { + rows: Extract["data"]; +}) { + return ( +
+ {rows.map((row) => ( +
+ + + +
+ {row.stages.map((stageId) => ( + + ))} +
+
+ ))} +
+ ); +} + +function LiveStreamWidget({ + stream, +}: { + stream: NonNullable["data"]>; +}) { + const { t } = useTranslation(["user"]); + + return ( + + +
+ + {t("user:widget.live-stream.live")} + + + {t("user:widget.live-stream.viewers", { + count: stream.viewerCount, + })} + + {stream.twitch} +
+
+ ); +} + +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 ( +
+ {title ?
{title}
: null} + {remaining ? ( +
+ {(remaining.days > 0 + ? COUNTDOWN_UNITS_FAR + : COUNTDOWN_UNITS_NEAR + ).map((unit) => ( +
+
{remaining[unit]}
+
+ {t(`user:widget.countdown.${unit}` as const)} +
+
+ ))} +
+ ) : ( + + )} +
+ ); +} diff --git a/app/features/user-page/components/WidgetSettingsForm.tsx b/app/features/user-page/components/WidgetSettingsForm.tsx index e438299e1..83c32f805 100644 --- a/app/features/user-page/components/WidgetSettingsForm.tsx +++ b/app/features/user-page/components/WidgetSettingsForm.tsx @@ -93,6 +93,15 @@ function WidgetFormFields({ widgetId }: { widgetId: string }) { return ; case "tier-list": return ; + case "countdown": + return ( +
+ + +
+ ); + case "markdown": + return ; case "badges-owned": return ; case "game-badges": diff --git a/app/features/user-page/core/widgets/countdown.test.ts b/app/features/user-page/core/widgets/countdown.test.ts new file mode 100644 index 000000000..3c8dd05ea --- /dev/null +++ b/app/features/user-page/core/widgets/countdown.test.ts @@ -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(); + }); +}); diff --git a/app/features/user-page/core/widgets/countdown.ts b/app/features/user-page/core/widgets/countdown.ts new file mode 100644 index 000000000..ce13262e4 --- /dev/null +++ b/app/features/user-page/core/widgets/countdown.ts @@ -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, + }; +} diff --git a/app/features/user-page/core/widgets/portfolio-loaders.server.ts b/app/features/user-page/core/widgets/portfolio-loaders.server.ts index 9b971cda6..ad7be0431 100644 --- a/app/features/user-page/core/widgets/portfolio-loaders.server.ts +++ b/app/features/user-page/core/widgets/portfolio-loaders.server.ts @@ -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( diff --git a/app/features/user-page/core/widgets/portfolio.ts b/app/features/user-page/core/widgets/portfolio.ts index b11de1684..fedf150aa 100644 --- a/app/features/user-page/core/widgets/portfolio.ts +++ b/app/features/user-page/core/widgets/portfolio.ts @@ -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: [ diff --git a/app/features/user-page/core/widgets/widget-form-schemas.ts b/app/features/user-page/core/widgets/widget-form-schemas.ts index fc1577d3c..cb01aeb22 100644 --- a/app/features/user-page/core/widgets/widget-form-schemas.ts +++ b/app/features/user-page/core/widgets/widget-form-schemas.ts @@ -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 = { bio: bioSchema, "bio-md": bioMdSchema, @@ -169,6 +192,8 @@ const WIDGET_FORM_SCHEMAS: Record = { "badges-owned": badgesOwnedSchema, "game-badges": gameBadgesSchema, "game-badges-small": gameBadgesSmallSchema, + countdown: countdownSchema, + markdown: markdownSchema, }; export function getWidgetFormSchema(widgetId: string) { diff --git a/app/features/user-page/user-page-constants.ts b/app/features/user-page/user-page-constants.ts index 6dd3a6ac8..cfefdff23 100644 --- a/app/features/user-page/user-page-constants.ts +++ b/app/features/user-page/user-page-constants.ts @@ -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; diff --git a/app/routines/computeLutiDivs.ts b/app/routines/computeLutiDivs.ts index 0e86e8b1b..d6e61a01f 100644 --- a/app/routines/computeLutiDivs.ts +++ b/app/routines/computeLutiDivs.ts @@ -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(); 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[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); diff --git a/changelog/2026-09-05-new-profile-widgets.md b/changelog/2026-09-05-new-profile-widgets.md new file mode 100644 index 000000000..9692c9388 --- /dev/null +++ b/changelog/2026-09-05-new-profile-widgets.md @@ -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 diff --git a/locales/da/forms.json b/locales/da/forms.json index 37167e632..01163de73 100644 --- a/locales/da/forms.json +++ b/locales/da/forms.json @@ -263,6 +263,7 @@ "options.controller.grip": "", "options.controller.handheld": "", "labels.tierListUrl": "", + "labels.title": "", "labels.plusTier": "", "labels.comment": "", "errors.plusAlreadySuggested": "", diff --git a/locales/da/user.json b/locales/da/user.json index 4efe88f39..e75deb222 100644 --- a/locales/da/user.json +++ b/locales/da/user.json @@ -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": "", diff --git a/locales/de/forms.json b/locales/de/forms.json index 49fdc281c..0e32843d4 100644 --- a/locales/de/forms.json +++ b/locales/de/forms.json @@ -263,6 +263,7 @@ "options.controller.grip": "", "options.controller.handheld": "", "labels.tierListUrl": "", + "labels.title": "", "labels.plusTier": "", "labels.comment": "", "errors.plusAlreadySuggested": "", diff --git a/locales/de/user.json b/locales/de/user.json index 8c2aff299..7a0393b8f 100644 --- a/locales/de/user.json +++ b/locales/de/user.json @@ -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": "", diff --git a/locales/en/forms.json b/locales/en/forms.json index 0d2148df3..8de244af9 100644 --- a/locales/en/forms.json +++ b/locales/en/forms.json @@ -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", diff --git a/locales/en/user.json b/locales/en/user.json index ede043a0c..b39490ee3 100644 --- a/locales/en/user.json +++ b/locales/en/user.json @@ -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", diff --git a/locales/es-ES/forms.json b/locales/es-ES/forms.json index ea5b90e41..91f330cfe 100644 --- a/locales/es-ES/forms.json +++ b/locales/es-ES/forms.json @@ -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", diff --git a/locales/es-ES/user.json b/locales/es-ES/user.json index e8401a690..d9ff2ebc3 100644 --- a/locales/es-ES/user.json +++ b/locales/es-ES/user.json @@ -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", diff --git a/locales/es-US/forms.json b/locales/es-US/forms.json index 361f40969..93965159c 100644 --- a/locales/es-US/forms.json +++ b/locales/es-US/forms.json @@ -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", diff --git a/locales/es-US/user.json b/locales/es-US/user.json index 7e2c145fd..ca51790f2 100644 --- a/locales/es-US/user.json +++ b/locales/es-US/user.json @@ -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", diff --git a/locales/fr-CA/forms.json b/locales/fr-CA/forms.json index 7de8dfabe..1cc8cd84a 100644 --- a/locales/fr-CA/forms.json +++ b/locales/fr-CA/forms.json @@ -263,6 +263,7 @@ "options.controller.grip": "", "options.controller.handheld": "", "labels.tierListUrl": "", + "labels.title": "", "labels.plusTier": "", "labels.comment": "", "errors.plusAlreadySuggested": "", diff --git a/locales/fr-CA/user.json b/locales/fr-CA/user.json index 31c0d5a00..874b18d9e 100644 --- a/locales/fr-CA/user.json +++ b/locales/fr-CA/user.json @@ -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": "", diff --git a/locales/fr-EU/forms.json b/locales/fr-EU/forms.json index 12c46aa23..471eae0e4 100644 --- a/locales/fr-EU/forms.json +++ b/locales/fr-EU/forms.json @@ -263,6 +263,7 @@ "options.controller.grip": "", "options.controller.handheld": "", "labels.tierListUrl": "", + "labels.title": "", "labels.plusTier": "", "labels.comment": "Commentaire", "errors.plusAlreadySuggested": "", diff --git a/locales/fr-EU/user.json b/locales/fr-EU/user.json index 7e8b5d80c..dc6e154e2 100644 --- a/locales/fr-EU/user.json +++ b/locales/fr-EU/user.json @@ -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": "", diff --git a/locales/he/forms.json b/locales/he/forms.json index 0d5fb611c..600fa1ff9 100644 --- a/locales/he/forms.json +++ b/locales/he/forms.json @@ -263,6 +263,7 @@ "options.controller.grip": "", "options.controller.handheld": "", "labels.tierListUrl": "", + "labels.title": "", "labels.plusTier": "", "labels.comment": "", "errors.plusAlreadySuggested": "", diff --git a/locales/he/user.json b/locales/he/user.json index bd0033830..94c63e5fd 100644 --- a/locales/he/user.json +++ b/locales/he/user.json @@ -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": "", diff --git a/locales/it/forms.json b/locales/it/forms.json index 964600ab1..6c700ea2e 100644 --- a/locales/it/forms.json +++ b/locales/it/forms.json @@ -263,6 +263,7 @@ "options.controller.grip": "", "options.controller.handheld": "", "labels.tierListUrl": "", + "labels.title": "", "labels.plusTier": "", "labels.comment": "Commento", "errors.plusAlreadySuggested": "", diff --git a/locales/it/user.json b/locales/it/user.json index c5585cd62..efe9d5e9b 100644 --- a/locales/it/user.json +++ b/locales/it/user.json @@ -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": "", diff --git a/locales/ja/forms.json b/locales/ja/forms.json index b4e12cbab..25afe6422 100644 --- a/locales/ja/forms.json +++ b/locales/ja/forms.json @@ -263,6 +263,7 @@ "options.controller.grip": "", "options.controller.handheld": "", "labels.tierListUrl": "", + "labels.title": "", "labels.plusTier": "", "labels.comment": "コメント", "errors.plusAlreadySuggested": "", diff --git a/locales/ja/user.json b/locales/ja/user.json index 55c76dd4e..a84d1fe9b 100644 --- a/locales/ja/user.json +++ b/locales/ja/user.json @@ -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": "", diff --git a/locales/ko/forms.json b/locales/ko/forms.json index 3e4e85657..7beb17198 100644 --- a/locales/ko/forms.json +++ b/locales/ko/forms.json @@ -263,6 +263,7 @@ "options.controller.grip": "", "options.controller.handheld": "", "labels.tierListUrl": "", + "labels.title": "", "labels.plusTier": "", "labels.comment": "", "errors.plusAlreadySuggested": "", diff --git a/locales/ko/user.json b/locales/ko/user.json index b23a78e6f..4499f0926 100644 --- a/locales/ko/user.json +++ b/locales/ko/user.json @@ -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": "", diff --git a/locales/nl/forms.json b/locales/nl/forms.json index 77d94543d..7a7b5743f 100644 --- a/locales/nl/forms.json +++ b/locales/nl/forms.json @@ -263,6 +263,7 @@ "options.controller.grip": "", "options.controller.handheld": "", "labels.tierListUrl": "", + "labels.title": "", "labels.plusTier": "", "labels.comment": "", "errors.plusAlreadySuggested": "", diff --git a/locales/nl/user.json b/locales/nl/user.json index 8675460be..c287b4808 100644 --- a/locales/nl/user.json +++ b/locales/nl/user.json @@ -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": "", diff --git a/locales/pl/forms.json b/locales/pl/forms.json index 0d37ff976..c9cf10566 100644 --- a/locales/pl/forms.json +++ b/locales/pl/forms.json @@ -263,6 +263,7 @@ "options.controller.grip": "", "options.controller.handheld": "", "labels.tierListUrl": "", + "labels.title": "", "labels.plusTier": "", "labels.comment": "", "errors.plusAlreadySuggested": "", diff --git a/locales/pl/user.json b/locales/pl/user.json index 62cf59c25..eee6cfa71 100644 --- a/locales/pl/user.json +++ b/locales/pl/user.json @@ -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": "", diff --git a/locales/pt-BR/forms.json b/locales/pt-BR/forms.json index 42c844d00..edd8d4774 100644 --- a/locales/pt-BR/forms.json +++ b/locales/pt-BR/forms.json @@ -263,6 +263,7 @@ "options.controller.grip": "", "options.controller.handheld": "", "labels.tierListUrl": "", + "labels.title": "", "labels.plusTier": "", "labels.comment": "Comentário", "errors.plusAlreadySuggested": "", diff --git a/locales/pt-BR/user.json b/locales/pt-BR/user.json index 53c307289..02ed6dcba 100644 --- a/locales/pt-BR/user.json +++ b/locales/pt-BR/user.json @@ -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": "", diff --git a/locales/ru/forms.json b/locales/ru/forms.json index b71e67ba7..6b252db9d 100644 --- a/locales/ru/forms.json +++ b/locales/ru/forms.json @@ -263,6 +263,7 @@ "options.controller.grip": "", "options.controller.handheld": "", "labels.tierListUrl": "", + "labels.title": "", "labels.plusTier": "", "labels.comment": "Комментарий", "errors.plusAlreadySuggested": "", diff --git a/locales/ru/user.json b/locales/ru/user.json index 2c4c3d547..e567f77f5 100644 --- a/locales/ru/user.json +++ b/locales/ru/user.json @@ -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": "", diff --git a/locales/zh/forms.json b/locales/zh/forms.json index f293cc720..3317bfbf1 100644 --- a/locales/zh/forms.json +++ b/locales/zh/forms.json @@ -263,6 +263,7 @@ "options.controller.grip": "Joy-Con 握把", "options.controller.handheld": "手提模式", "labels.tierListUrl": "自制强度榜 URL", + "labels.title": "", "labels.plusTier": "级别", "labels.comment": "评论", "errors.plusAlreadySuggested": "此用户已被推荐过", diff --git a/locales/zh/user.json b/locales/zh/user.json index 23eb80384..f19d3af37 100644 --- a/locales/zh/user.json +++ b/locales/zh/user.json @@ -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": "组别", diff --git a/migrations/20260905192242-user-div-season.ts b/migrations/20260905192242-user-div-season.ts new file mode 100644 index 000000000..116fa1ccf --- /dev/null +++ b/migrations/20260905192242-user-div-season.ts @@ -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): Promise { + await db.schema + .alterTable("User") + .addColumn("divSeason", "integer") + .execute(); + + await sql`update "User" set "divSeason" = 17 where "div" is not null`.execute( + db, + ); +} diff --git a/scripts/benchmark-db/cases.ts b/scripts/benchmark-db/cases.ts index a5caa6100..ae6377ff2 100644 --- a/scripts/benchmark-db/cases.ts +++ b/scripts/benchmark-db/cases.ts @@ -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), );