diff --git a/README.md b/README.md index 4d401eb6a..a0fbe9a2a 100644 --- a/README.md +++ b/README.md @@ -29,7 +29,7 @@ Competitive Splatoon Hub with over 20k registered users. - Sqlite3 - CSS (plain) - E2E tests via Playwright -- Unit tests via uvu +- Unit/integration tests via uvu ## Screenshots @@ -58,6 +58,12 @@ There is a sequence of commands you need to run: See [CONTRIBUTING.md](./CONTRIBUTING.md) for more information. +## Tests + +### `db-test.sqlite3` + +Empty DB with the latest migration run. When creating new migrations they should also be applied+committed to this file (add it in `.env` and then run the migration command as normal). + ### Translations [Translation Progress](https://github.com/Sendouc/sendou.ink/issues/1104) diff --git a/app/components/Button.tsx b/app/components/Button.tsx index 9709391be..2ab049eac 100644 --- a/app/components/Button.tsx +++ b/app/components/Button.tsx @@ -96,7 +96,9 @@ export function LinkButton({ > {icon && React.cloneElement(icon, { - className: clsx("button-icon", { lonely: !children }), + className: clsx("button-icon", { + lonely: !children, + }), })} {children} diff --git a/app/components/NewTabs.tsx b/app/components/NewTabs.tsx index 1c26821c7..e44ec2ba1 100644 --- a/app/components/NewTabs.tsx +++ b/app/components/NewTabs.tsx @@ -1,5 +1,6 @@ import { Tab } from "@headlessui/react"; import clsx from "clsx"; +import * as React from "react"; interface NewTabsProps { tabs: { @@ -17,16 +18,24 @@ interface NewTabsProps { setSelectedIndex?: (index: number) => void; /** Don't take space when no tabs to show? */ disappearing?: boolean; + type?: "divider"; + sticky?: boolean; } -export function NewTabs({ - tabs, - content, - scrolling = true, - selectedIndex, - setSelectedIndex, - disappearing = false, -}: NewTabsProps) { +export function NewTabs(args: NewTabsProps) { + if (args.type === "divider") { + return ; + } + + const { + tabs, + content, + scrolling = true, + selectedIndex, + setSelectedIndex, + disappearing = false, + } = args; + const cantSwitchTabs = tabs.filter((t) => !t.hidden).length <= 1; return ( @@ -36,6 +45,7 @@ export function NewTabs({ "overflow-x-auto": scrolling, invisible: cantSwitchTabs && !disappearing, hidden: cantSwitchTabs && disappearing, + "tab__buttons-container__sticky": args.sticky, })} > {tabs @@ -49,13 +59,68 @@ export function NewTabs({ > {tab.label} {typeof tab.number === "number" && tab.number !== 0 && ( - {tab.number} + {tab.number} )} ); })} - + + {content + .filter((c) => !c.hidden) + .map((c) => { + return {c.element}; + })} + + + ); +} + +function DividerTabs({ + tabs, + content, + scrolling = true, + selectedIndex, + setSelectedIndex, + disappearing = false, +}: NewTabsProps) { + const cantSwitchTabs = tabs.filter((t) => !t.hidden).length <= 1; + + return ( + + + {tabs + .filter((t) => !t.hidden) + .map((tab, i) => { + return ( + + + {tab.label} + {typeof tab.number === "number" && tab.number !== 0 && ( + ({tab.number}) + )} + + {i !== tabs.length - 1 && ( +
+ )} + + ); + })} + + {content .filter((c) => !c.hidden) .map((c) => { diff --git a/app/components/icons/Map.tsx b/app/components/icons/Map.tsx new file mode 100644 index 000000000..44c8d8070 --- /dev/null +++ b/app/components/icons/Map.tsx @@ -0,0 +1,16 @@ +export function MapIcon({ className }: { className?: string }) { + return ( + + + + ); +} diff --git a/app/components/icons/MicrophoneFilled.tsx b/app/components/icons/MicrophoneFilled.tsx new file mode 100644 index 000000000..765ac6e4f --- /dev/null +++ b/app/components/icons/MicrophoneFilled.tsx @@ -0,0 +1,13 @@ +export function MicrophoneFilledIcon({ className }: { className?: string }) { + return ( + + + + + ); +} diff --git a/app/components/icons/Puzzle.tsx b/app/components/icons/Puzzle.tsx new file mode 100644 index 000000000..c50a4e2b6 --- /dev/null +++ b/app/components/icons/Puzzle.tsx @@ -0,0 +1,12 @@ +export function PuzzleIcon({ className }: { className?: string }) { + return ( + + + + ); +} diff --git a/app/components/icons/Scale.tsx b/app/components/icons/Scale.tsx new file mode 100644 index 000000000..00cdca7db --- /dev/null +++ b/app/components/icons/Scale.tsx @@ -0,0 +1,16 @@ +export function ScaleIcon({ className }: { className?: string }) { + return ( + + + + ); +} diff --git a/app/components/icons/SpeakerFilled.tsx b/app/components/icons/SpeakerFilled.tsx new file mode 100644 index 000000000..2be097d29 --- /dev/null +++ b/app/components/icons/SpeakerFilled.tsx @@ -0,0 +1,13 @@ +export function SpeakerFilledIcon({ className }: { className?: string }) { + return ( + + + + + ); +} diff --git a/app/db/seed/index.ts b/app/db/seed/index.ts index 62362caad..57fe8349a 100644 --- a/app/db/seed/index.ts +++ b/app/db/seed/index.ts @@ -4,8 +4,14 @@ import shuffle from "just-shuffle"; import { nanoid } from "nanoid"; import invariant from "tiny-invariant"; import { ADMIN_DISCORD_ID, ADMIN_ID, INVITE_CODE_LENGTH } from "~/constants"; -import { sql } from "~/db/sql"; +import { db, sql } from "~/db/sql"; import allTags from "~/features/calendar/tags.json"; +import { MapPool } from "~/features/map-list-generator/core/map-pool"; +import { + lastCompletedVoting, + nextNonCompletedVoting, + rangeToMonthYear, +} from "~/features/plus-voting/core"; import { createVod } from "~/features/vods/queries/createVod.server"; import type { AbilityType, @@ -22,43 +28,37 @@ import { stageIds, } from "~/modules/in-game-lists"; import { rankedModesShort } from "~/modules/in-game-lists/modes"; -import { MapPool } from "~/features/map-list-generator/core/map-pool"; -import { - lastCompletedVoting, - nextNonCompletedVoting, - rangeToMonthYear, -} from "~/features/plus-voting/core"; import { dateToDatabaseTimestamp } from "~/utils/dates"; import { mySlugify } from "~/utils/urls"; +import type { SeedVariation } from "~/features/api/routes/seed"; import * as BuildRepository from "~/features/builds/BuildRepository.server"; import * as CalendarRepository from "~/features/calendar/CalendarRepository.server"; import * as PlusSuggestionRepository from "~/features/plus-suggestions/PlusSuggestionRepository.server"; import * as PlusVotingRepository from "~/features/plus-voting/PlusVotingRepository.server"; +import * as QRepository from "~/features/sendouq/QRepository.server"; +import * as QMatchRepository from "~/features/sendouq-match/QMatchRepository.server"; +import * as QSettingsRepository from "~/features/sendouq-settings/QSettingsRepository.server"; import { calculateMatchSkills } from "~/features/sendouq/core/skills.server"; import { summarizeMaps, summarizePlayerResults, } from "~/features/sendouq/core/summarizer.server"; -import { MAP_LIST_PREFERENCE_OPTIONS } from "~/features/sendouq/q-constants"; import { winnersArrayToWinner } from "~/features/sendouq/q-utils"; import { addMapResults } from "~/features/sendouq/queries/addMapResults.server"; import { addMember } from "~/features/sendouq/queries/addMember.server"; import { addPlayerResults } from "~/features/sendouq/queries/addPlayerResults.server"; import { addReportedWeapons } from "~/features/sendouq/queries/addReportedWeapons.server"; import { addSkills } from "~/features/sendouq/queries/addSkills.server"; -import { createGroup } from "~/features/sendouq/queries/createGroup.server"; import { createMatch } from "~/features/sendouq/queries/createMatch.server"; import { findMatchById } from "~/features/sendouq/queries/findMatchById.server"; -import { groupForMatch } from "~/features/sendouq/queries/groupForMatch.server"; import { reportScore } from "~/features/sendouq/queries/reportScore.server"; import { setGroupAsInactive } from "~/features/sendouq/queries/setGroupAsInactive.server"; -import { updateVCStatus } from "~/features/sendouq/queries/updateVCStatus.server"; import { TOURNAMENT } from "~/features/tournament/tournament-constants"; import * as UserRepository from "~/features/user-page/UserRepository.server"; import type { TournamentMapListMap } from "~/modules/tournament-map-list-generator"; -import type { SeedVariation } from "~/features/api/routes/seed"; import { nullFilledArray, pickRandomItem } from "~/utils/arrays"; +import type { UserMapModePreferences } from "../tables"; import type { Art, UserSubmittedImage } from "../types"; import { ADMIN_TEST_AVATAR, @@ -81,6 +81,8 @@ const basicSeeds = (variation?: SeedVariation | null) => [ nzapUser, users, userProfiles, + userMapModePreferences, + userQWeaponPool, lastMonthsVoting, syncPlusTiers, lastMonthSuggestions, @@ -229,7 +231,7 @@ async function users() { } } -function userProfiles() { +async function userProfiles() { for (const args of [ { userId: 1, @@ -316,7 +318,7 @@ function userProfiles() { if (Math.random() > 0.9) defaultLanguages.push("it"); if (Math.random() > 0.9) defaultLanguages.push("ja"); - updateVCStatus({ + await QSettingsRepository.updateVoiceChat({ languages: defaultLanguages, userId: id, vc: @@ -327,6 +329,64 @@ function userProfiles() { } } +const randomPreferences = (): UserMapModePreferences => { + return { + modes: modesShort.flatMap((mode) => { + if (Math.random() > 0.5 && mode !== "SZ") return []; + + const criteria = mode === "SZ" ? 0.2 : 0.5; + + return { + mode, + preference: Math.random() > criteria ? "PREFER" : "AVOID", + }; + }), + maps: stageIds.slice(0, 10).flatMap((stageId) => { + return modesShort.flatMap((mode) => { + if (Math.random() > 0.7) return { stageId, mode }; + + return { + stageId, + mode, + preference: Math.random() > 0.3 ? "PREFER" : "AVOID", + }; + }); + }), + }; +}; + +async function userMapModePreferences() { + for (let id = 1; id < 500; id++) { + if (id !== ADMIN_ID && Math.random() < 0.2) continue; // 80% have maps && admin always + + await db + .updateTable("User") + .where("User.id", "=", id) + .set({ + mapModePreferences: JSON.stringify(randomPreferences()), + }) + .execute(); + } +} + +async function userQWeaponPool() { + for (let id = 1; id < 500; id++) { + if (id === 2) continue; // no weapons for N-ZAP + if (Math.random() < 0.2) continue; // 80% have weapons + + const weapons = shuffle([...mainWeaponIds]).slice( + 0, + faker.helpers.arrayElement([1, 2, 3, 4]), + ); + + await db + .updateTable("User") + .set({ qWeaponPool: JSON.stringify(weapons) }) + .where("User.id", "=", id) + .execute(); + } +} + function fakeUser(usedNames: Set) { return () => ({ discordAvatar: null, @@ -1589,36 +1649,16 @@ function commissionsOpen() { } const SENDOU_IN_FULL_GROUP = true; -function groups() { +async function groups() { const users = userIdsInAscendingOrderById() .slice(0, 100) .filter((id) => id !== ADMIN_ID && id !== NZAP_TEST_ID); users.push(NZAP_TEST_ID); for (let i = 0; i < 25; i++) { - const group = createGroup({ - mapListPreference: faker.helpers.arrayElement( - MAP_LIST_PREFERENCE_OPTIONS, - ), + const group = await QRepository.createGroup({ status: "ACTIVE", userId: users.pop()!, - mapPool: new MapPool([ - { mode: "SZ", stageId: 1 }, - { mode: "SZ", stageId: 2 }, - { mode: "SZ", stageId: 3 }, - { mode: "SZ", stageId: 4 }, - { mode: "SZ", stageId: 5 }, - { mode: "SZ", stageId: 6 }, - { mode: "TC", stageId: 7 }, - { mode: "TC", stageId: 8 }, - { mode: "TC", stageId: 15 }, - { mode: "RM", stageId: 10 }, - { mode: "RM", stageId: 11 }, - { mode: "RM", stageId: 16 }, - { mode: "CB", stageId: 13 }, - { mode: "CB", stageId: 14 }, - { mode: "CB", stageId: 17 }, - ]), }); const amountOfAdditionalMembers = () => { @@ -1656,20 +1696,24 @@ const randomMapList = ( groupBravo: number, ): TournamentMapListMap[] => { const szOnly = faker.helpers.arrayElement([true, false]); - const modePattern = shuffle([...rankedModesShort]); + + let modePattern = shuffle([...modesShort]).filter(() => Math.random() > 0.15); + if (modePattern.length === 0) { + modePattern = shuffle([...rankedModesShort]); + } const mapList: TournamentMapListMap[] = []; const stageIdsShuffled = shuffle([...stageIds]); for (let i = 0; i < 7; i++) { - const rankedMode = modePattern.pop()!; + const mode = modePattern.pop()!; mapList.push({ - mode: szOnly ? "SZ" : rankedMode, + mode: szOnly ? "SZ" : mode, stageId: stageIdsShuffled.pop()!, source: i === 6 ? "BOTH" : i % 2 === 0 ? groupAlpha : groupBravo, }); - modePattern.unshift(rankedMode); + modePattern.unshift(mode); } return mapList; @@ -1677,7 +1721,7 @@ const randomMapList = ( const MATCHES_COUNT = 500; -function playedMatches() { +async function playedMatches() { const _groupMembers = (() => { return new Array(50).fill(null).map(() => { const users = shuffle(userIdsInAscendingOrderById().slice(0, 50)); @@ -1694,8 +1738,7 @@ function playedMatches() { }), ); - // mid august 2021 - let matchDate = new Date(Date.UTC(2021, 7, 15, 0, 0, 0, 0)); + let matchDate = new Date(Date.UTC(2023, 9, 15, 0, 0, 0, 0)); for (let i = 0; i < MATCHES_COUNT; i++) { const groupMembers = shuffle([..._groupMembers]); const groupAlphaMembers = groupMembers.pop()!; @@ -1717,10 +1760,7 @@ function playedMatches() { // -> create groups for (let i = 0; i < 2; i++) { const users = i === 0 ? [...groupAlphaMembers] : [...groupBravoMembers]; - const group = createGroup({ - // these should not matter here - mapListPreference: "NO_PREFERENCE", - mapPool: new MapPool([]), + const group = await QRepository.createGroup({ status: "ACTIVE", userId: users.pop()!, }); @@ -1791,11 +1831,15 @@ function playedMatches() { winnerGroupId: winner === "ALPHA" ? groupAlpha : groupBravo, }); const members = [ - ...groupForMatch(match.alphaGroupId)!.members.map((m) => ({ + ...(await QMatchRepository.findGroupById({ + groupId: match.alphaGroupId, + }))!.members.map((m) => ({ ...m, groupId: match.alphaGroupId, })), - ...groupForMatch(match.bravoGroupId)!.members.map((m) => ({ + ...(await QMatchRepository.findGroupById({ + groupId: match.alphaGroupId, + }))!.members.map((m) => ({ ...m, groupId: match.bravoGroupId, })), diff --git a/app/db/tables.ts b/app/db/tables.ts index af6396c72..b4096f1aa 100644 --- a/app/db/tables.ts +++ b/app/db/tables.ts @@ -5,12 +5,14 @@ import type { Selectable, SqlBool, } from "kysely"; +import type { TieredSkill } from "~/features/mmr/tiered.server"; import type { Ability, MainWeaponId, ModeShort, StageId, } from "~/modules/in-game-lists"; +import type { GroupSkillDifference, UserSkillDifference } from "./types"; export type Generated = T extends ColumnType ? ColumnType @@ -174,8 +176,7 @@ export interface Group { id: GeneratedAlways; inviteCode: string; latestActionAt: Generated; - mapListPreference: string; - status: string; + status: "PREPARING" | "ACTIVE" | "INACTIVE"; teamId: number | null; } @@ -185,13 +186,35 @@ export interface GroupLike { targetGroupId: number; } +export type ParsedMemento = { + users: Record< + number, + { + plusTier?: PlusTier["tier"]; + skill?: TieredSkill | "CALCULATING"; + skillDifference?: UserSkillDifference; + } + >; + groups: Record< + number, + { + tier?: TieredSkill["tier"]; + skillDifference?: GroupSkillDifference; + } + >; + modePreferences?: Partial< + Record> + >; + mapPreferences?: Array<{ userId: number; preference?: Preference }[]>; +}; + export interface GroupMatch { alphaGroupId: number; bravoGroupId: number; chatCode: string | null; createdAt: Generated; id: GeneratedAlways; - memento: string | null; + memento: ColumnType; reportedAt: number | null; reportedByUserId: number | null; } @@ -210,10 +233,18 @@ export interface GroupMember { createdAt: Generated; groupId: number; note: string | null; - role: string; + role: "OWNER" | "MANAGER" | "REGULAR"; userId: number; } +export interface PrivateUserNote { + authorId: number; + targetId: number; + text: string | null; + sentiment: "POSITIVE" | "NEUTRAL" | "NEGATIVE"; + updatedAt: Generated; +} + export interface LogInLink { code: string; expiresAt: number; @@ -222,7 +253,6 @@ export interface LogInLink { export interface MapPoolMap { calendarEventId: number | null; - groupId: number | null; mode: ModeShort; stageId: StageId; tieBreakerCalendarEventId: number | null; @@ -467,6 +497,20 @@ export interface UnvalidatedVideo { youtubeId: string; } +// missing means "neutral" +export type Preference = "AVOID" | "PREFER"; +export interface UserMapModePreferences { + modes: Array<{ + mode: ModeShort; + preference: Preference; + }>; + maps: Array<{ + stageId: StageId; + mode: ModeShort; + preference?: Preference; + }>; +} + export interface User { banned: Generated; bio: string | null; @@ -494,8 +538,14 @@ export interface User { stickSens: number | null; twitch: string | null; twitter: string | null; - vc: Generated; + vc: Generated<"YES" | "NO" | "LISTEN_ONLY">; youtubeId: string | null; + mapModePreferences: ColumnType< + UserMapModePreferences | null, + string | null, + string | null + >; + qWeaponPool: ColumnType; plusSkippedForSeasonNth: number | null; } @@ -589,6 +639,7 @@ export interface DB { GroupMatch: GroupMatch; GroupMatchMap: GroupMatchMap; GroupMember: GroupMember; + PrivateUserNote: PrivateUserNote; LogInLink: LogInLink; MapPoolMap: MapPoolMap; MapResult: MapResult; diff --git a/app/db/types.ts b/app/db/types.ts index b7d06adb7..554aca35f 100644 --- a/app/db/types.ts +++ b/app/db/types.ts @@ -199,7 +199,6 @@ export interface MapPoolMap { calendarEventId: number | null; // Part of tournament's map pool tournamentTeamId: number | null; // Part of team's map pool tieBreakerCalendarEventId: number | null; // Part of the tournament's tiebreaker pool - groupId: number | null; // Part of SendouQ group's map pool stageId: StageId; mode: ModeShort; } @@ -521,12 +520,6 @@ export interface Group { teamId: number | null; createdAt: number; latestActionAt: number; - mapListPreference: - | "SZ_ONLY" - | "ALL_MODES_ONLY" - | "PREFER_SZ" - | "PREFER_ALL_MODES" - | "NO_PREFERENCE"; inviteCode: string; chatCode: string | null; status: "PREPARING" | "ACTIVE" | "INACTIVE"; diff --git a/app/features/chat/components/Chat.tsx b/app/features/chat/components/Chat.tsx index 62dd14eb5..b61312784 100644 --- a/app/features/chat/components/Chat.tsx +++ b/app/features/chat/components/Chat.tsx @@ -14,6 +14,7 @@ import type { ChatMessage } from "../chat-types"; import { MESSAGE_MAX_LENGTH } from "../chat-constants"; import { messageTypeToSound, soundEnabled } from "../chat-utils"; import { soundPath } from "~/utils/urls"; +import { useTranslation } from "~/hooks/useTranslation"; type ChatUser = Pick & { chatNameColor: string | null; @@ -33,34 +34,6 @@ export interface ChatProps { revalidates?: boolean; } -const systemMessageText = (msg: ChatMessage) => { - const name = () => { - if (!msg.context) return ""; - return msg.context.name; - }; - - switch (msg.type) { - case "SCORE_REPORTED": { - return `${name()} reported score`; - } - case "SCORE_CONFIRMED": { - return `${name()} confirmed score. Match is now locked`; - } - case "CANCEL_REPORTED": { - return `${name()} requested canceling the match`; - } - case "CANCEL_CONFIRMED": { - return `${name()} confirmed canceling the match. Match is now locked`; - } - case "USER_LEFT": { - return `${name()} left the group`; - } - default: { - return null; - } - } -}; - export function ConnectedChat(props: ChatProps) { const chat = useChat(props); @@ -79,6 +52,7 @@ export function Chat({ disabled, missingUserName, }: ChatProps & { chat: ReturnType }) { + const { t } = useTranslation(["common"]); const messagesContainerRef = React.useRef(null); const inputRef = React.useRef(null); const { @@ -120,6 +94,34 @@ export function Chat({ const sendingMessagesDisabled = disabled || !connected; + const systemMessageText = (msg: ChatMessage) => { + const name = () => { + if (!msg.context) return ""; + return msg.context.name; + }; + + switch (msg.type) { + case "SCORE_REPORTED": { + return t("common:chat.systemMsg.scoreReported", { name: name() }); + } + case "SCORE_CONFIRMED": { + return t("common:chat.systemMsg.scoreConfirmed", { name: name() }); + } + case "CANCEL_REPORTED": { + return t("common:chat.systemMsg.cancelReported", { name: name() }); + } + case "CANCEL_CONFIRMED": { + return t("common:chat.systemMsg.cancelConfirmed", { name: name() }); + } + case "USER_LEFT": { + return t("common:chat.systemMsg.userLeft", { name: name() }); + } + default: { + return null; + } + } + }; + return (
) : null} + {data.ownEntryPeek ? ( + + ) : null} + {data.userLeaderboard ? ( ["userLeaderboard"]>[number]; + nextTier?: SkillTierInterval; +}) { + const data = useLoaderData(); + + return ( +
+ {entry.tier ? ( +
+ + {entry.tier.name} + {entry.tier.isPlus ? "+" : ""} +
+ ) : null} +
+ +
+
{entry.placementRank}
+
+ +
+ {typeof entry.weaponSplId === "number" ? ( + + ) : null} +
{entry.discordName}
+
{entry.power}
+
+ +
+ {nextTier ? ( +
+ {nextTier.name} + {nextTier.isPlus ? "+" : ""} @ {ordinalToSp(nextTier.neededOrdinal!)} + SP +
+ ) : null} +
+ ); +} + function PlayersTable({ entries, showTiers, diff --git a/app/features/mmr/mmr-utils.ts b/app/features/mmr/mmr-utils.ts index e9a5293df..8ed5a1066 100644 --- a/app/features/mmr/mmr-utils.ts +++ b/app/features/mmr/mmr-utils.ts @@ -1,4 +1,4 @@ -import { rate as openskillRate, ordinal } from "openskill"; +import { rate as openskillRate, ordinal, rating } from "openskill"; import type { Rating, Team } from "openskill/dist/types"; import invariant from "tiny-invariant"; @@ -8,6 +8,10 @@ export function ordinalToSp(ordinal: number) { return toTwoDecimals(ordinal * 15 + 1000); } +export function spToOrdinal(sp: number) { + return (sp - 1000) / 15; +} + export function ordinalToRoundedSp(ordinal: number) { return Math.round(ordinalToSp(ordinal)); } @@ -76,3 +80,7 @@ export function userIdsToIdentifier(userIds: number[]) { export function identifierToUserIds(identifier: string) { return identifier.split("-").map(Number); } + +export function defaultOrdinal() { + return ordinal(rating()); +} diff --git a/app/features/mmr/season.ts b/app/features/mmr/season.ts index 661b57314..1f30d1f48 100644 --- a/app/features/mmr/season.ts +++ b/app/features/mmr/season.ts @@ -3,8 +3,13 @@ export const SEASONS = ? ([ { nth: 0, - starts: new Date("2020-08-14T15:00:00.000Z"), - ends: new Date("2029-08-26T20:59:59.999Z"), + starts: new Date("2023-08-14T17:00:00.000Z"), + ends: new Date("2023-08-27T20:59:59.999Z"), + }, + { + nth: 1, + starts: new Date("2023-09-11T17:00:00.000Z"), + ends: new Date("2030-11-17T20:59:59.999Z"), }, ] as const) : ([ diff --git a/app/features/mmr/tiered.server.ts b/app/features/mmr/tiered.server.ts index 1d4bb34b6..742a86a2c 100644 --- a/app/features/mmr/tiered.server.ts +++ b/app/features/mmr/tiered.server.ts @@ -25,19 +25,21 @@ export interface TieredSkill { export function freshUserSkills(season: number): { userSkills: Record; intervals: SkillTierInterval[]; + isAccurateTiers: boolean; } { const points = orderedMMRBySeason({ season, type: "user", }); - const tierIntervals = skillTierIntervals(points, "user"); + const { intervals, isAccurateTiers } = skillTierIntervals(points, "user"); return { - intervals: tierIntervals, + intervals, + isAccurateTiers, userSkills: Object.fromEntries( points.map((p) => { - const { name, isPlus } = tierIntervals.find( + const { name, isPlus } = intervals.find( (t) => t.neededOrdinal! <= p.ordinal, ) ?? { name: "IRON", isPlus: false }; return [ @@ -67,7 +69,9 @@ export async function userSkills(season: number) { return cachedSkills; } -export type SkillTierInterval = ReturnType[number]; +export type SkillTierInterval = ReturnType< + typeof skillTierIntervals +>["intervals"][number]; function skillTierIntervals( orderedPoints: Array>, @@ -112,7 +116,7 @@ function skillTierIntervals( if (points.length === 1) { result[0].neededOrdinal = points[0].ordinal; - return result; + return { intervals: result, isAccurateTiers: hasLeviathan }; } let previousPercentiles = 0; @@ -137,5 +141,5 @@ function skillTierIntervals( } } - return result; + return { intervals: result, isAccurateTiers: hasLeviathan }; } diff --git a/app/features/plus-voting/core/index.ts b/app/features/plus-voting/core/index.ts index c1037e02c..25ee098f2 100644 --- a/app/features/plus-voting/core/index.ts +++ b/app/features/plus-voting/core/index.ts @@ -8,4 +8,4 @@ export type { MonthYear, PlusVoteFromFE } from "./types"; export { usePlusVoting } from "./usePlusVoting"; -export { isVotingActive } from "./voting-time-new"; +export { isVotingActive } from "./voting-time"; diff --git a/app/features/plus-voting/core/voting-time-new.ts b/app/features/plus-voting/core/voting-time-new.ts deleted file mode 100644 index e384841f1..000000000 --- a/app/features/plus-voting/core/voting-time-new.ts +++ /dev/null @@ -1,74 +0,0 @@ -import type { MonthYear } from "./types"; -import { type RankingSeason, SEASONS } from "~/features/mmr/season"; - -export function lastCompletedVoting(now: Date): MonthYear { - let match: { startDate: Date; endDate: Date } | null = null; - for (const season of SEASONS) { - const range = seasonToVotingRange(season); - - if (now.getTime() > range.endDate.getTime()) { - match = range; - } else if (now.getTime() < range.endDate.getTime()) { - break; - } - } - - if (!match) { - throw new Error("No previous voting found."); - } - - return rangeToMonthYear(match); -} - -export function nextNonCompletedVoting(now: Date) { - for (const season of SEASONS) { - const range = seasonToVotingRange(season); - - if (now.getTime() < range.endDate.getTime()) { - return range; - } - } - - throw new Error("No next voting found."); -} - -export function rangeToMonthYear(range: { startDate: Date; endDate: Date }) { - return { - month: range.startDate.getMonth(), - year: range.startDate.getFullYear(), - }; -} - -export function seasonToVotingRange(season: RankingSeason) { - const { ends: date } = season; - - if (date.getUTCDay() !== 0) { - throw new Error("End date is not a Sunday."); - } - - const endDate = new Date(date); - endDate.setUTCDate(endDate.getUTCDate() - 7); - endDate.setUTCHours(18, 0, 0, 0); - - const startDate = new Date(endDate); - startDate.setUTCDate(startDate.getUTCDate() - 2); - - return { startDate, endDate }; -} - -export function isVotingActive() { - const now = new Date(); - - for (const season of SEASONS) { - const { startDate, endDate } = seasonToVotingRange(season); - - if ( - now.getTime() > startDate.getTime() && - now.getTime() < endDate.getTime() - ) { - return true; - } - } - - return false; -} diff --git a/app/features/plus-voting/core/voting-time-old.ts b/app/features/plus-voting/core/voting-time-old.ts deleted file mode 100644 index ffa79dfc5..000000000 --- a/app/features/plus-voting/core/voting-time-old.ts +++ /dev/null @@ -1,74 +0,0 @@ -import type { MonthYear } from "./types"; - -export function lastCompletedVoting(now: Date): MonthYear { - const thisMonthsRange = monthsVotingRange({ - month: now.getMonth(), - year: now.getFullYear(), - }); - - if (thisMonthsRange.endDate.getTime() < now.getTime()) { - return { - month: thisMonthsRange.endDate.getMonth(), - year: thisMonthsRange.endDate.getFullYear(), - }; - } - - return previousMonth({ - month: thisMonthsRange.endDate.getMonth(), - year: thisMonthsRange.endDate.getFullYear(), - }); -} - -export function nextNonCompletedVoting(now: Date): MonthYear { - return nextMonth(lastCompletedVoting(now)); -} - -/** Range of first Friday of a month to the following Sunday (this range is when voting is active) */ -export function monthsVotingRange({ month, year }: MonthYear) { - const startDate = new Date(Date.UTC(year, month, 1, 18)); // EU evening, NA day - - while (startDate.getDay() !== 5) { - startDate.setDate(startDate.getDate() + 1); - } - - const endDate = new Date(startDate.getTime()); - endDate.setDate(endDate.getDate() + 2); - - return { startDate, endDate }; -} - -function previousMonth(input: MonthYear): MonthYear { - let { month, year } = input; - - month--; - if (month < 0) { - month = 11; - year--; - } - - return { month, year }; -} - -function nextMonth(input: MonthYear): MonthYear { - let { month, year } = input; - - month++; - if (month === 12) { - month = 0; - year++; - } - - return { month, year }; -} - -export function isVotingActive() { - const now = new Date(); - const { endDate, startDate } = monthsVotingRange({ - month: now.getMonth(), - year: now.getFullYear(), - }); - - return ( - now.getTime() >= startDate.getTime() && now.getTime() <= endDate.getTime() - ); -} diff --git a/app/features/plus-voting/core/voting-time.ts b/app/features/plus-voting/core/voting-time.ts index cca0eed73..e384841f1 100644 --- a/app/features/plus-voting/core/voting-time.ts +++ b/app/features/plus-voting/core/voting-time.ts @@ -1,25 +1,74 @@ -import type { MonthYear } from "~/features/top-search/top-search-utils"; -import { - seasonToVotingRange, - lastCompletedVoting as lastCompletedVotingNew, -} from "./voting-time-new"; // TODO: seasonToVotingRange can be removed as export after the first new voting under the new system +import type { MonthYear } from "./types"; +import { type RankingSeason, SEASONS } from "~/features/mmr/season"; -export { - isVotingActive, - nextNonCompletedVoting, - rangeToMonthYear, -} from "./voting-time-new"; - -// TODO: this can be removed after the first new voting under the new system export function lastCompletedVoting(now: Date): MonthYear { - const range = seasonToVotingRange({ - nth: 1, - starts: new Date("2023-09-11T17:00:00.000Z"), - ends: new Date("2023-11-19T20:59:59.999Z"), - }); + let match: { startDate: Date; endDate: Date } | null = null; + for (const season of SEASONS) { + const range = seasonToVotingRange(season); - // first voting under the new system has not yet concluded - const usingOldLogic = range.endDate.getTime() > now.getTime(); + if (now.getTime() > range.endDate.getTime()) { + match = range; + } else if (now.getTime() < range.endDate.getTime()) { + break; + } + } - return usingOldLogic ? { month: 9, year: 2023 } : lastCompletedVotingNew(now); + if (!match) { + throw new Error("No previous voting found."); + } + + return rangeToMonthYear(match); +} + +export function nextNonCompletedVoting(now: Date) { + for (const season of SEASONS) { + const range = seasonToVotingRange(season); + + if (now.getTime() < range.endDate.getTime()) { + return range; + } + } + + throw new Error("No next voting found."); +} + +export function rangeToMonthYear(range: { startDate: Date; endDate: Date }) { + return { + month: range.startDate.getMonth(), + year: range.startDate.getFullYear(), + }; +} + +export function seasonToVotingRange(season: RankingSeason) { + const { ends: date } = season; + + if (date.getUTCDay() !== 0) { + throw new Error("End date is not a Sunday."); + } + + const endDate = new Date(date); + endDate.setUTCDate(endDate.getUTCDate() - 7); + endDate.setUTCHours(18, 0, 0, 0); + + const startDate = new Date(endDate); + startDate.setUTCDate(startDate.getUTCDate() - 2); + + return { startDate, endDate }; +} + +export function isVotingActive() { + const now = new Date(); + + for (const season of SEASONS) { + const { startDate, endDate } = seasonToVotingRange(season); + + if ( + now.getTime() > startDate.getTime() && + now.getTime() < endDate.getTime() + ) { + return true; + } + } + + return false; } diff --git a/app/features/sendouq-match/QMatchRepository.server.ts b/app/features/sendouq-match/QMatchRepository.server.ts new file mode 100644 index 000000000..ede02ce01 --- /dev/null +++ b/app/features/sendouq-match/QMatchRepository.server.ts @@ -0,0 +1,161 @@ +import { sql } from "kysely"; +import { jsonArrayFrom, jsonObjectFrom } from "kysely/helpers/sqlite"; +import { db } from "~/db/sql"; +import type { ParsedMemento, Tables } from "~/db/tables"; +import type { UserSkillDifference } from "~/db/types"; +import type { MainWeaponId } from "~/modules/in-game-lists"; +import { COMMON_USER_FIELDS } from "~/utils/kysely.server"; + +export function findById(id: number) { + return db + .selectFrom("GroupMatch") + .select(({ exists, selectFrom, eb }) => [ + "GroupMatch.id", + "GroupMatch.alphaGroupId", + "GroupMatch.bravoGroupId", + "GroupMatch.createdAt", + "GroupMatch.reportedAt", + "GroupMatch.reportedByUserId", + "GroupMatch.chatCode", + "GroupMatch.memento", + exists( + selectFrom("Skill") + .select("Skill.id") + .where("Skill.groupMatchId", "=", id), + ).as("isLocked"), + jsonArrayFrom( + eb + .selectFrom("GroupMatchMap") + .select([ + "GroupMatch.id", + "GroupMatchMap.mode", + "GroupMatchMap.stageId", + "GroupMatchMap.source", + "GroupMatchMap.winnerGroupId", + ]) + .where("GroupMatchMap.matchId", "=", id) + .orderBy("GroupMatchMap.index asc"), + ).as("mapList"), + ]) + .where("GroupMatch.id", "=", id) + .executeTakeFirst(); +} + +export interface GroupForMatch { + id: Tables["Group"]["id"]; + chatCode: Tables["Group"]["chatCode"]; + tier?: ParsedMemento["groups"][number]["tier"]; + skillDifference?: ParsedMemento["groups"][number]["skillDifference"]; + team?: { + name: string; + avatarUrl: string | null; + customUrl: string; + }; + members: Array<{ + id: Tables["GroupMember"]["userId"]; + discordId: Tables["User"]["discordId"]; + discordName: Tables["User"]["discordName"]; + discordAvatar: Tables["User"]["discordAvatar"]; + role: Tables["GroupMember"]["role"]; + customUrl: Tables["User"]["customUrl"]; + inGameName: Tables["User"]["inGameName"]; + weapons: Array; + chatNameColor: string | null; + vc: Tables["User"]["vc"]; + languages: string[]; + skillDifference?: UserSkillDifference; + privateNote: Pick< + Tables["PrivateUserNote"], + "sentiment" | "text" | "updatedAt" + > | null; + }>; +} + +export async function findGroupById({ + loggedInUserId, + groupId, +}: { + groupId: number; + loggedInUserId?: number; +}) { + const row = await db + .selectFrom("Group") + .innerJoin("GroupMatch", (join) => + join.on((eb) => + eb.or([ + eb("GroupMatch.alphaGroupId", "=", eb.ref("Group.id")), + eb("GroupMatch.bravoGroupId", "=", eb.ref("Group.id")), + ]), + ), + ) + .select(({ eb }) => [ + "Group.id", + "Group.chatCode", + "GroupMatch.memento", + jsonObjectFrom( + eb + .selectFrom("AllTeam") + .leftJoin( + "UserSubmittedImage", + "AllTeam.avatarImgId", + "UserSubmittedImage.id", + ) + .select([ + "AllTeam.name", + "AllTeam.customUrl", + "UserSubmittedImage.url as avatarUrl", + ]) + .where("AllTeam.id", "=", eb.ref("Group.teamId")), + ).as("team"), + jsonArrayFrom( + eb + .selectFrom("GroupMember") + .innerJoin("User", "User.id", "GroupMember.userId") + .select((arrayEb) => [ + ...COMMON_USER_FIELDS, + "GroupMember.role", + "User.inGameName", + "User.vc", + "User.languages", + "User.qWeaponPool as weapons", + jsonObjectFrom( + eb + .selectFrom("PrivateUserNote") + .select([ + "PrivateUserNote.sentiment", + "PrivateUserNote.text", + "PrivateUserNote.updatedAt", + ]) + .where("authorId", "=", loggedInUserId ?? -1) + .where("targetId", "=", arrayEb.ref("User.id")), + ).as("privateNote"), + sql< + string | null + >`IIF(COALESCE("User"."patronTier", 0) >= 2, "User"."css" ->> 'chat', null)`.as( + "chatNameColor", + ), + ]) + .where("GroupMember.groupId", "=", groupId) + .orderBy("GroupMember.userId asc"), + ).as("members"), + ]) + .where("Group.id", "=", groupId) + .executeTakeFirst(); + + if (!row) return null; + + return { + id: row.id, + chatCode: row.chatCode, + tier: row.memento?.groups[row.id]?.tier, + skillDifference: row.memento?.groups[row.id]?.skillDifference, + team: row.team, + members: row.members.map((m) => ({ + ...m, + languages: m.languages ? m.languages.split(",") : [], + plusTier: row.memento?.users[m.id]?.plusTier, + skill: row.memento?.users[m.id]?.skill, + skillDifference: row.memento?.users[m.id]?.skillDifference, + })), + } as GroupForMatch; +} diff --git a/app/features/sendouq-match/components/AddPrivateNoteDialog.tsx b/app/features/sendouq-match/components/AddPrivateNoteDialog.tsx new file mode 100644 index 000000000..9ee35fbda --- /dev/null +++ b/app/features/sendouq-match/components/AddPrivateNoteDialog.tsx @@ -0,0 +1,135 @@ +import { useFetcher } from "@remix-run/react"; +import { Dialog } from "~/components/Dialog"; +import * as React from "react"; +import { Label } from "~/components/Label"; +import { SENDOUQ } from "~/features/sendouq/q-constants"; +import { SubmitButton } from "~/components/SubmitButton"; +import { FormMessage } from "~/components/FormMessage"; +import { preferenceEmojiUrl } from "~/utils/urls"; +import { useTranslation } from "~/hooks/useTranslation"; +import { Button } from "~/components/Button"; +import { CrossIcon } from "~/components/icons/Cross"; +import type { GroupForMatch } from "../QMatchRepository.server"; +import type { Tables } from "~/db/tables"; + +export function AddPrivateNoteDialog({ + aboutUser, + close, +}: { + aboutUser?: Pick< + GroupForMatch["members"][number], + "id" | "discordName" | "privateNote" + >; + close: () => void; +}) { + const { t } = useTranslation(["q", "common"]); + const fetcher = useFetcher(); + + if (!aboutUser) return null; + + return ( + + + +
+

+ {t("q:privateNote.header", { name: aboutUser.discordName })} +

+
+