diff --git a/app/components/RelativeTime.browser.test.tsx b/app/components/RelativeTime.browser.test.tsx
new file mode 100644
index 000000000..80d1eae35
--- /dev/null
+++ b/app/components/RelativeTime.browser.test.tsx
@@ -0,0 +1,48 @@
+import { createMemoryRouter, RouterProvider } from "react-router";
+import { describe, expect, test } from "vitest";
+import { render } from "vitest-browser-react";
+import { RelativeTime } from "./RelativeTime";
+
+const FORMAT_OPTIONS: Intl.DateTimeFormatOptions = {
+ hour: "numeric",
+ minute: "numeric",
+ day: "numeric",
+ month: "numeric",
+ timeZoneName: "short",
+};
+
+function renderRelativeTime(timestamp: number) {
+ const router = createMemoryRouter(
+ [
+ {
+ path: "/",
+ element: 3 days ago,
+ },
+ ],
+ { initialEntries: ["/"] },
+ );
+
+ return render();
+}
+
+function expectedTitle(timestamp: number) {
+ const language =
+ navigator.languages.find(
+ (lang) => lang.split("-")[0].toLowerCase() === "en",
+ ) ?? "en";
+
+ return new Intl.DateTimeFormat(language, FORMAT_OPTIONS).format(timestamp);
+}
+
+describe("RelativeTime", () => {
+ test("tooltip shows the date the millisecond timestamp points to", async () => {
+ const timestamp = new Date("2025-08-02T12:00:00Z").getTime();
+
+ const screen = await renderRelativeTime(timestamp);
+
+ const abbr = screen.getByText("3 days ago");
+ await expect.element(abbr).toBeVisible();
+
+ expect(abbr.element().getAttribute("title")).toBe(expectedTitle(timestamp));
+ });
+});
diff --git a/app/components/RelativeTime.tsx b/app/components/RelativeTime.tsx
index f37ab7ff0..e825fde61 100644
--- a/app/components/RelativeTime.tsx
+++ b/app/components/RelativeTime.tsx
@@ -17,6 +17,8 @@ export function RelativeTime({
});
return (
- {children}
+
+ {children}
+
);
}
diff --git a/app/features/tournament-bracket/core/Bracket.test.ts b/app/features/tournament-bracket/core/Bracket.test.ts
index 42fbefe56..470b416e1 100644
--- a/app/features/tournament-bracket/core/Bracket.test.ts
+++ b/app/features/tournament-bracket/core/Bracket.test.ts
@@ -972,6 +972,70 @@ describe("single elimination source - underground", () => {
});
});
+describe("swiss between rounds", () => {
+ const SWISS_MAIN_BRACKET = {
+ type: "swiss" as const,
+ name: "Main Bracket",
+ requiresCheckIn: false,
+ settings: { groupCount: 1, roundCount: 5 },
+ sources: [],
+ };
+
+ // swiss with round 1 fully reported but rounds 2-5 not yet paired
+ const betweenRoundsSwissData = () => {
+ let data = Engine.create({
+ type: "swiss",
+ seeding: [1, 2, 3, 4],
+ settings: { groupCount: 1, roundCount: 5 },
+ });
+
+ // needed to make it "not preview"
+ data.round = data.round.map((r) => ({
+ ...r,
+ maps: { count: 3, type: "BEST_OF" },
+ }));
+
+ for (const match of data.match) {
+ data = reportLowerIdWinner(data, match.id);
+ }
+
+ return data;
+ };
+
+ it("tournament is not over while swiss still has unpaired rounds", () => {
+ const tournament = testTournament({
+ ctx: {
+ settings: { bracketProgression: [SWISS_MAIN_BRACKET] },
+ },
+ data: betweenRoundsSwissData(),
+ });
+
+ expect(tournament.everyBracketOver).toBe(false);
+ });
+
+ it("can't finalize between swiss rounds when progression also has an underground bracket", () => {
+ const tournament = testTournament({
+ ctx: {
+ settings: {
+ bracketProgression: [
+ SWISS_MAIN_BRACKET,
+ {
+ type: "single_elimination" as const,
+ name: "Underground Bracket",
+ requiresCheckIn: false,
+ settings: {},
+ sources: [{ bracketIdx: 0, placements: [3, 4] }],
+ },
+ ],
+ },
+ },
+ data: betweenRoundsSwissData(),
+ });
+
+ expect(tournament.canFinalize({ id: 1 })).toBe(false);
+ });
+});
+
function reportLowerIdWinner(data: BracketData, matchId: number): BracketData {
const match = matchById(data, matchId);
const opponent1Lower = match.opponent1!.id! < match.opponent2!.id!;
diff --git a/app/features/tournament-bracket/core/Bracket/SwissBracket.ts b/app/features/tournament-bracket/core/Bracket/SwissBracket.ts
index 4892646b0..dc77ec149 100644
--- a/app/features/tournament-bracket/core/Bracket/SwissBracket.ts
+++ b/app/features/tournament-bracket/core/Bracket/SwissBracket.ts
@@ -77,8 +77,8 @@ export class SwissBracket extends Bracket {
* Exception being rounds that can never be paired because every team of the group has already
* advanced or been eliminated (early advance variation).
*/
- get standingsAreFinal() {
- if (!this.everyMatchOver) return false;
+ get everyMatchOver() {
+ if (!super.everyMatchOver) return false;
return this.data.group.every((group) => {
const groupsMatches = this.data.match.filter(
diff --git a/app/features/tournament-bracket/core/Tournament.ts b/app/features/tournament-bracket/core/Tournament.ts
index 083e76e92..7a2555da4 100644
--- a/app/features/tournament-bracket/core/Tournament.ts
+++ b/app/features/tournament-bracket/core/Tournament.ts
@@ -40,8 +40,6 @@ export type BracketDerivedMeta = {
createdAt: number | null;
preview: boolean;
everyMatchOver: boolean;
- /** False only while a swiss bracket still has rounds whose matches have not been generated. */
- allRoundsHaveMatches: boolean;
participantTournamentTeamIds: number[];
teamsPendingCheckIn: number[] | null;
seeding: number[] | null;
@@ -247,9 +245,6 @@ export class Tournament {
createdAt: bracket.createdAt ?? null,
preview: bracket.preview,
everyMatchOver: bracket.everyMatchOver,
- allRoundsHaveMatches: bracket.data.round.every((round) =>
- bracket.data.match.some((match) => match.roundId === round.id),
- ),
participantTournamentTeamIds: bracket.participantTournamentTeamIds,
teamsPendingCheckIn: bracket.teamsPendingCheckIn ?? null,
seeding: bracket.seeding ?? null,
@@ -763,20 +758,7 @@ export class Tournament {
(b) => !b.preview || !b.isUnderground,
);
- const everyRoundHasMatches = () => {
- // only in swiss matches get generated as tournament progresses
- if (
- this.ctx.settings.bracketProgression.length > 1 ||
- this.ctx.settings.bracketProgression[0].type !== "swiss"
- ) {
- return true;
- }
-
- return this.bracketsMeta[0].allRoundsHaveMatches;
- };
-
return (
- everyRoundHasMatches() &&
relevantBrackets.every((b) => b.everyMatchOver) &&
this.isOrganizer(user) &&
!this.ctx.isFinalized
diff --git a/app/modules/in-game-lists/utils.test.ts b/app/modules/in-game-lists/utils.test.ts
index aa63d6c92..c982751c3 100644
--- a/app/modules/in-game-lists/utils.test.ts
+++ b/app/modules/in-game-lists/utils.test.ts
@@ -35,6 +35,38 @@ describe("filterWeapon", () => {
).toBe(true);
});
+ const neoSplash = { type: "MAIN" as const, id: 22 as MainWeaponId };
+
+ test("matches a full alt name", () => {
+ expect(
+ filterWeapon({
+ weapon: neoSplash,
+ weaponName: "Neo Splash-o-matic",
+ searchTerm: "gecko",
+ }),
+ ).toBe(true);
+ });
+
+ test("alt names match on the full alt name only, not a partial one", () => {
+ expect(
+ filterWeapon({
+ weapon: neoSplash,
+ weaponName: "Neo Splash-o-matic",
+ searchTerm: "geck",
+ }),
+ ).toBe(false);
+ });
+
+ test("alt names match on the full alt name only, also when the weapon has a single alt name", () => {
+ expect(
+ filterWeapon({
+ weapon: { type: "MAIN", id: 10 as MainWeaponId },
+ weaponName: "Splattershot Jr.",
+ searchTerm: "vj",
+ }),
+ ).toBe(false);
+ });
+
test("does not match unrelated weapon", () => {
expect(
filterWeapon({
diff --git a/app/modules/in-game-lists/utils.ts b/app/modules/in-game-lists/utils.ts
index 1f60aab1e..af466d098 100644
--- a/app/modules/in-game-lists/utils.ts
+++ b/app/modules/in-game-lists/utils.ts
@@ -32,9 +32,12 @@ export function filterWeapon({
}
if (weapon.type === "MAIN") {
- return (
- weaponAltNames.get(weapon.id)?.includes(normalizedSearchTerm) ?? false
- );
+ const altNames = weaponAltNames.get(weapon.id);
+ if (!altNames) return false;
+
+ const altNamesList = typeof altNames === "string" ? [altNames] : altNames;
+
+ return altNamesList.includes(normalizedSearchTerm);
}
return false;
diff --git a/app/utils/users.test.ts b/app/utils/users.test.ts
index d46a6b7c1..4763bca8c 100644
--- a/app/utils/users.test.ts
+++ b/app/utils/users.test.ts
@@ -43,6 +43,30 @@ describe("queryToUserIdentifier()", () => {
id: 42,
});
});
+
+ test("gets custom url from url with trailing slash", () => {
+ expect(queryToUserIdentifier("https://sendou.ink/u/sendou/")).toEqual({
+ customUrl: "sendou",
+ });
+ });
+
+ test("gets custom url from profile sub-page url", () => {
+ expect(queryToUserIdentifier("https://sendou.ink/u/sendou/builds")).toEqual(
+ {
+ customUrl: "sendou",
+ },
+ );
+ });
+
+ test("gets discord id from url with query string", () => {
+ expect(
+ queryToUserIdentifier(
+ "https://sendou.ink/u/79237403620945920?utm_source=discord",
+ ),
+ ).toEqual({
+ discordId: "79237403620945920",
+ });
+ });
});
describe("userDiscordIdIsAged()", () => {
diff --git a/app/utils/users.ts b/app/utils/users.ts
index c7fa6ce38..373f2a90a 100644
--- a/app/utils/users.ts
+++ b/app/utils/users.ts
@@ -1,8 +1,8 @@
import { logger } from "./logger";
import { isCustomUrl } from "./urls";
-const longUrlRegExp = /(https:\/\/)?sendou.ink\/u\/(.+)/;
-const shortUrlRegExp = /(https:\/\/)?snd.ink\/(.+)/;
+const longUrlRegExp = /(https:\/\/)?sendou\.ink\/u\/([^/?#]+)/;
+const shortUrlRegExp = /(https:\/\/)?snd\.ink\/([^/?#]+)/;
const DISCORD_ID_MIN_LENGTH = 17;
export function queryToUserIdentifier(
query: string,