From 6a3ec6a65439450d5ff3a19c56524c19f5a1e52a Mon Sep 17 00:00:00 2001 From: Kalle <38327916+Sendouc@users.noreply.github.com> Date: Mon, 8 Jun 2026 22:04:36 +0300 Subject: [PATCH] Various small bug fixes --- app/components/Pagination.test.ts | 32 +++++++ app/components/Pagination.tsx | 16 +++- .../api-public/routes/calendar.$year.$week.ts | 7 +- app/features/mmr/core/Seasons.test.ts | 18 ++++ app/features/mmr/core/Seasons.ts | 2 +- app/features/scrims/core/Scrim.test.ts | 14 +++ app/features/scrims/core/Scrim.ts | 15 +++- app/features/sendouq/q-schemas.server.ts | 2 +- .../core/Progression.test.ts | 43 ++++++++++ .../tournament-bracket/core/Progression.ts | 2 +- .../tournament-bracket/core/Swiss.test.ts | 12 +++ app/features/tournament-bracket/core/Swiss.ts | 2 +- .../tournament/core/Standings.test.ts | 78 ++++++++++++++++- app/features/tournament/core/Standings.ts | 2 +- .../tournament/core/sets.server.test.ts | 11 +++ app/features/tournament/core/sets.server.ts | 4 +- app/utils/dates.test.ts | 86 +++++++++++++++++++ app/utils/dates.ts | 34 +++++++- app/utils/number.ts | 4 +- app/utils/numbers.test.ts | 10 +++ app/utils/string.test.ts | 6 ++ app/utils/strings.ts | 2 +- app/utils/urls.test.ts | 12 +++ app/utils/urls.ts | 13 ++- 24 files changed, 400 insertions(+), 27 deletions(-) create mode 100644 app/features/mmr/core/Seasons.test.ts create mode 100644 app/features/tournament/core/sets.server.test.ts create mode 100644 app/utils/dates.test.ts create mode 100644 app/utils/urls.test.ts diff --git a/app/components/Pagination.test.ts b/app/components/Pagination.test.ts index 0fb720d49..fe2e84664 100644 --- a/app/components/Pagination.test.ts +++ b/app/components/Pagination.test.ts @@ -86,6 +86,38 @@ describe("getPageNumbers", () => { expect(mobileView(19, 20)).toEqual([1, "...", 18, 19, 20]); }); + it("shows a bridging number instead of an ellipsis that hides a single page", () => { + // An ellipsis takes the same space as one page number, so replacing a + // lone hidden page with "..." is never an improvement (same intent as the + // edge "lonely jump" fix, but for windows one step inward). + // desktop window around page 5 of 10 leaves only page 2 hidden on the left + expect(desktopView(5, 10)).toEqual([1, 2, 3, 4, 5, 6, 7, "...", 10]); + // ...and only page 9 hidden on the right for page 6 of 10 + expect(desktopView(6, 10)).toEqual([1, "...", 4, 5, 6, 7, 8, 9, 10]); + // mobile window around page 3 of 6 leaves only page 5 hidden + expect(mobileView(3, 6)).toEqual([1, 2, 3, 4, 5, 6]); + }); + + it("never renders an ellipsis in place of a single hidden page", () => { + for (let pagesCount = 1; pagesCount <= 25; pagesCount++) { + for (let currentPage = 1; currentPage <= pagesCount; currentPage++) { + for (const view of [ + mobileView(currentPage, pagesCount), + desktopView(currentPage, pagesCount), + ]) { + for (let i = 1; i < view.length - 1; i++) { + if (view[i] !== "...") continue; + const prev = view[i - 1]; + const next = view[i + 1]; + if (typeof prev === "number" && typeof next === "number") { + expect(next - prev).toBeGreaterThan(2); + } + } + } + } + } + }); + it("never produces duplicate page numbers", () => { for (let pagesCount = 1; pagesCount <= 25; pagesCount++) { for (let currentPage = 1; currentPage <= pagesCount; currentPage++) { diff --git a/app/components/Pagination.tsx b/app/components/Pagination.tsx index c075d9f86..396856758 100644 --- a/app/components/Pagination.tsx +++ b/app/components/Pagination.tsx @@ -253,6 +253,11 @@ export function getPageNumbers( * last page) to render around the current page. The window is nudged inward by * one when the current page is the very first or last page, so the edge view * shows a bridging number instead of a lonely jump like "1 2 … 8". + * + * When exactly one page would be left between the window and the always-shown + * first or last page, the window is widened to include it: an ellipsis takes + * the same space as a single page number, so "1 … 3" is never better than + * "1 2 3". */ function innerPageWindow( currentPage: number, @@ -262,8 +267,11 @@ function innerPageWindow( const startNudge = currentPage === pagesCount ? 1 : 0; const endNudge = currentPage === 1 ? 1 : 0; - return { - start: Math.max(2, currentPage - radius - startNudge), - end: Math.min(pagesCount - 1, currentPage + radius + endNudge), - }; + let start = Math.max(2, currentPage - radius - startNudge); + let end = Math.min(pagesCount - 1, currentPage + radius + endNudge); + + if (start === 3) start = 2; + if (end === pagesCount - 2) end = pagesCount - 1; + + return { start, end }; } diff --git a/app/features/api-public/routes/calendar.$year.$week.ts b/app/features/api-public/routes/calendar.$year.$week.ts index da6718787..da7b8f3f0 100644 --- a/app/features/api-public/routes/calendar.$year.$week.ts +++ b/app/features/api-public/routes/calendar.$year.$week.ts @@ -4,7 +4,7 @@ import { db } from "~/db/sql"; import { databaseTimestampToDate, dateToDatabaseTimestamp, - weekNumberToDate, + weekNumberToDateRange, } from "~/utils/dates"; import { parseParams } from "~/utils/remix.server"; import type { GetCalendarWeekResponse } from "../schema"; @@ -35,10 +35,7 @@ export const loader = async ({ params }: LoaderFunctionArgs) => { }; function fetchEventsOfWeek(args: { week: number; year: number }) { - const startTime = weekNumberToDate(args); - - const endTime = new Date(startTime); - endTime.setDate(endTime.getDate() + 7); + const { startTime, endTime } = weekNumberToDateRange(args); return db .selectFrom("CalendarEvent") diff --git a/app/features/mmr/core/Seasons.test.ts b/app/features/mmr/core/Seasons.test.ts new file mode 100644 index 000000000..7bf837d4b --- /dev/null +++ b/app/features/mmr/core/Seasons.test.ts @@ -0,0 +1,18 @@ +import { describe, expect, it } from "vitest"; +import { list, nthToDateRange } from "./Seasons"; + +describe("nthToDateRange()", () => { + it("returns the date range for an existing season", () => { + const { starts, ends } = nthToDateRange(0); + expect(starts).toEqual(list[0].starts); + expect(ends).toEqual(list[0].ends); + }); + + it("throws for a season number past the end of the list", () => { + expect(() => nthToDateRange(list.length)).toThrow(); + }); + + it("throws for a negative season number", () => { + expect(() => nthToDateRange(-1)).toThrow(); + }); +}); diff --git a/app/features/mmr/core/Seasons.ts b/app/features/mmr/core/Seasons.ts index 4d9dcd737..e107b3079 100644 --- a/app/features/mmr/core/Seasons.ts +++ b/app/features/mmr/core/Seasons.ts @@ -167,7 +167,7 @@ export function next(date = new Date()): ListItem | null { * @throws {Error} If the season does not exist. */ export function nthToDateRange(nth: number) { - const seasonObject = list.at(nth); + const seasonObject = list[nth]; if (!seasonObject) { throw new Error(`Season ${nth} not found`); } diff --git a/app/features/scrims/core/Scrim.test.ts b/app/features/scrims/core/Scrim.test.ts index 5a250fda8..cd2ff42b3 100644 --- a/app/features/scrims/core/Scrim.test.ts +++ b/app/features/scrims/core/Scrim.test.ts @@ -359,6 +359,20 @@ describe("applyFilters", () => { expect(applyFilters(post, filters)).toBe(true); }); + + it("returns true when a post time range crossing midnight overlaps the filter", () => { + const post = createPostForFilters( + new Date("2025-01-15T23:00:00"), + new Date("2025-01-16T01:00:00"), + ); + const filters: ScrimFilters = { + divs: null, + weekdayTimes: { start: "00:00", end: "02:00" }, + weekendTimes: null, + }; + + expect(applyFilters(post, filters)).toBe(true); + }); }); describe("weekend time filters", () => { diff --git a/app/features/scrims/core/Scrim.ts b/app/features/scrims/core/Scrim.ts index a4189b126..1ae871bff 100644 --- a/app/features/scrims/core/Scrim.ts +++ b/app/features/scrims/core/Scrim.ts @@ -104,8 +104,19 @@ export function applyFilters(post: ScrimPost, filters: ScrimFilters): boolean { const startTimeString = format(startDate, "HH:mm"); const endTimeString = format(endDate, "HH:mm"); - const hasOverlap = - startTimeString <= timeFilters.end && endTimeString >= timeFilters.start; + // a range that crosses midnight (e.g. 23:00 -> 01:00) is two segments + const postSegments = + endTimeString < startTimeString + ? [ + { start: startTimeString, end: "24:00" }, + { start: "00:00", end: endTimeString }, + ] + : [{ start: startTimeString, end: endTimeString }]; + + const hasOverlap = postSegments.some( + (segment) => + segment.start <= timeFilters.end && segment.end >= timeFilters.start, + ); if (!hasOverlap) { return false; diff --git a/app/features/sendouq/q-schemas.server.ts b/app/features/sendouq/q-schemas.server.ts index d396cf466..5070bddb6 100644 --- a/app/features/sendouq/q-schemas.server.ts +++ b/app/features/sendouq/q-schemas.server.ts @@ -88,7 +88,7 @@ export const lookingSchema = z.union([ export const weaponUsageSearchParamsSchema = z.object({ userId: id, - season: z.coerce.number().int(), + season: z.coerce.number().int().nonnegative(), stageId, modeShort, }); diff --git a/app/features/tournament-bracket/core/Progression.test.ts b/app/features/tournament-bracket/core/Progression.test.ts index d897bcf6a..3a96af5ca 100644 --- a/app/features/tournament-bracket/core/Progression.test.ts +++ b/app/features/tournament-bracket/core/Progression.test.ts @@ -640,6 +640,49 @@ describe("validatedSources - other rules", () => { expect((error as any).bracketIdxs).toEqual([1, 2]); }); + it("only flags GAP_IN_PLACEMENTS brackets sourcing from the problematic bracket", () => { + const error = getValidatedBrackets([ + { + settings: {}, + type: "round_robin", + }, + { + settings: {}, + type: "single_elimination", + sources: [ + { + bracketId: "0", + placements: "1", + }, + ], + }, + { + settings: {}, + type: "single_elimination", + sources: [ + { + bracketId: "0", + placements: "3", + }, + ], + }, + { + settings: {}, + type: "single_elimination", + sources: [ + { + bracketId: "1", + placements: "1", + }, + ], + }, + ]) as Progression.ValidationError; + + expect(error.type).toBe("GAP_IN_PLACEMENTS"); + // bracket 3 sources from bracket 1, not from the gap in bracket 0 + expect((error as any).bracketIdxs).toEqual([1, 2]); + }); + it("handles TOO_MANY_PLACEMENTS", () => { const error = getValidatedBrackets([ { diff --git a/app/features/tournament-bracket/core/Progression.ts b/app/features/tournament-bracket/core/Progression.ts index 4361b94e1..1141e685c 100644 --- a/app/features/tournament-bracket/core/Progression.ts +++ b/app/features/tournament-bracket/core/Progression.ts @@ -590,7 +590,7 @@ function gapInPlacements(brackets: ParsedBracket[]) { return brackets.flatMap((bracket, bracketIdx) => { if (!bracket.sources) return []; - return bracket.sources.flatMap( + return bracket.sources.some( (source) => source.bracketIdx === problematicBracketIdx, ) ? [bracketIdx] diff --git a/app/features/tournament-bracket/core/Swiss.test.ts b/app/features/tournament-bracket/core/Swiss.test.ts index 00475908b..ca9183463 100644 --- a/app/features/tournament-bracket/core/Swiss.test.ts +++ b/app/features/tournament-bracket/core/Swiss.test.ts @@ -519,6 +519,18 @@ describe("Swiss", () => { 2, ]); }); + + it("includes thresholds up to the calculated maximum for large round counts", () => { + const roundCount = 9; + const max = Swiss.maxAdvanceThreshold({ roundCount }); + + expect(Swiss.validAdvanceThresholdOptions({ roundCount })).toContain( + max, + ); + expect( + Swiss.isValidAdvanceThreshold({ roundCount, advanceThreshold: max }), + ).toBe(true); + }); }); }); }); diff --git a/app/features/tournament-bracket/core/Swiss.ts b/app/features/tournament-bracket/core/Swiss.ts index 871cf4842..ad4b40bf9 100644 --- a/app/features/tournament-bracket/core/Swiss.ts +++ b/app/features/tournament-bracket/core/Swiss.ts @@ -525,7 +525,7 @@ export function validAdvanceThresholdOptions({ }) { const result: number[] = []; - for (let i = 2; i <= Math.min(maxAdvanceThreshold({ roundCount }), 5); i++) { + for (let i = 2; i <= maxAdvanceThreshold({ roundCount }); i++) { result.push(i); } diff --git a/app/features/tournament/core/Standings.test.ts b/app/features/tournament/core/Standings.test.ts index 4a6193390..ae6fc0ad2 100644 --- a/app/features/tournament/core/Standings.test.ts +++ b/app/features/tournament/core/Standings.test.ts @@ -7,7 +7,11 @@ import { import { BracketsManager } from "~/modules/brackets-manager"; import { InMemoryDatabase } from "~/modules/brackets-memory-db"; import invariant from "~/utils/invariant"; -import { reNumberPlacements, tournamentStandings } from "./Standings"; +import { + matchesPlayed, + reNumberPlacements, + tournamentStandings, +} from "./Standings"; describe("tournamentStandings", () => { it("returns single-division standings for a tournament with one starting bracket", () => { @@ -137,6 +141,78 @@ describe("reNumberPlacements", () => { }); }); +describe("matchesPlayed", () => { + it("tags each match with the bracket index it was actually played in", () => { + const tournament = roundRobinToSingleEliminationTournament(); + + const matches = matchesPlayed({ tournament, teamId: 1 }); + + // team 1 plays 3 round robin matches (bracket idx 0) + // and 1 single elimination match (bracket idx 1) + const roundRobinMatches = matches.filter((m) => m.bracketIdx === 0); + const singleEliminationMatches = matches.filter((m) => m.bracketIdx === 1); + + expect(roundRobinMatches).toHaveLength(3); + expect(singleEliminationMatches).toHaveLength(1); + }); +}); + +function roundRobinToSingleEliminationTournament() { + const storage = new InMemoryDatabase(); + const manager = new BracketsManager(storage); + + manager.create({ + name: "Main Bracket", + tournamentId: 1, + type: "round_robin", + seeding: [1, 2, 3, 4], + settings: { groupCount: 1, seedOrdering: ["groups.seed_optimized"] }, + }); + manager.create({ + name: "B1", + tournamentId: 1, + type: "single_elimination", + seeding: [1, 2], + settings: { seedOrdering: ["natural"] }, + }); + + // play every match across both brackets, lower id always wins + while (true) { + const pending = storage + .select("match")! + .find( + (m) => + typeof m.opponent1?.id === "number" && + typeof m.opponent2?.id === "number" && + m.opponent1.result !== "win" && + m.opponent2.result !== "win", + ); + if (!pending) break; + + const winnerIsOpp1 = pending.opponent1.id < pending.opponent2.id; + manager.update.match({ + id: pending.id, + opponent1: winnerIsOpp1 ? { score: 2, result: "win" } : { score: 0 }, + opponent2: winnerIsOpp1 ? { score: 0 } : { score: 2, result: "win" }, + }); + } + + return testTournament({ + ctx: { + settings: { + bracketProgression: progressions.roundRobinToSingleElimination, + }, + teams: [ + tournamentCtxTeam(1, { startingBracketIdx: 0, seed: 1 }), + tournamentCtxTeam(2, { startingBracketIdx: 0, seed: 2 }), + tournamentCtxTeam(3, { startingBracketIdx: 0, seed: 3 }), + tournamentCtxTeam(4, { startingBracketIdx: 0, seed: 4 }), + ], + }, + data: manager.get.tournamentData(1), + }); +} + function singleEliminationTournament() { const storage = new InMemoryDatabase(); const manager = new BracketsManager(storage); diff --git a/app/features/tournament/core/Standings.ts b/app/features/tournament/core/Standings.ts index d6894df4a..e2a710144 100644 --- a/app/features/tournament/core/Standings.ts +++ b/app/features/tournament/core/Standings.ts @@ -131,7 +131,7 @@ export function matchesPlayed({ ) .map((match) => ({ ...match, - bracketIdx: bracketIdxs[bracketIdxs.length - 1 - i], + bracketIdx: bracketIdxs[i], })), ); diff --git a/app/features/tournament/core/sets.server.test.ts b/app/features/tournament/core/sets.server.test.ts new file mode 100644 index 000000000..755da4314 --- /dev/null +++ b/app/features/tournament/core/sets.server.test.ts @@ -0,0 +1,11 @@ +import { describe, expect, it } from "vitest"; +import { winCounts } from "./sets.server"; + +describe("winCounts", () => { + it("returns 0% (not NaN) when there are no played sets", () => { + const result = winCounts([]); + + expect(result.sets.percentage).toBe(0); + expect(result.maps.percentage).toBe(0); + }); +}); diff --git a/app/features/tournament/core/sets.server.ts b/app/features/tournament/core/sets.server.ts index 8be1f9e1c..2a8355e45 100644 --- a/app/features/tournament/core/sets.server.ts +++ b/app/features/tournament/core/sets.server.ts @@ -71,12 +71,12 @@ export function winCounts(sets: PlayedSet[]) { sets: { won: setsWon, total: totalSets, - percentage: Math.round((setsWon / totalSets) * 100), + percentage: totalSets === 0 ? 0 : Math.round((setsWon / totalSets) * 100), }, maps: { won: mapsWon, total: totalMaps, - percentage: Math.round((mapsWon / totalMaps) * 100), + percentage: totalMaps === 0 ? 0 : Math.round((mapsWon / totalMaps) * 100), }, }; } diff --git a/app/utils/dates.test.ts b/app/utils/dates.test.ts new file mode 100644 index 000000000..477497a91 --- /dev/null +++ b/app/utils/dates.test.ts @@ -0,0 +1,86 @@ +import { afterAll, beforeAll, describe, expect, it } from "vitest"; +import { + getDateAtNextFullHour, + weekNumberToDate, + weekNumberToDateRange, +} from "./dates"; + +describe("getDateAtNextFullHour", () => { + it("returns a date sitting exactly on a full hour (no leftover minutes/seconds/milliseconds)", () => { + const result = getDateAtNextFullHour(new Date(2024, 0, 1, 14, 30, 0, 500)); + + expect(result).toEqual(new Date(2024, 0, 1, 15, 0, 0, 0)); + }); + + it("advances to the next hour when only seconds/milliseconds are past the hour", () => { + const result = getDateAtNextFullHour(new Date(2024, 0, 1, 14, 0, 30, 0)); + + expect(result).toEqual(new Date(2024, 0, 1, 15, 0, 0, 0)); + }); + + it("keeps the same hour when already exactly on a full hour", () => { + const result = getDateAtNextFullHour(new Date(2024, 0, 1, 14, 0, 0, 0)); + + expect(result).toEqual(new Date(2024, 0, 1, 14, 0, 0, 0)); + }); +}); + +describe("weekNumberToDate", () => { + // Force a timezone west of UTC so the assertion is deterministic regardless + // of where the test happens to run (the bug only manifests west of UTC). + const originalTimezone = process.env.TZ; + beforeAll(() => { + process.env.TZ = "America/Los_Angeles"; + }); + afterAll(() => { + process.env.TZ = originalTimezone; + }); + + it("returns the Monday of the ISO week regardless of server timezone", () => { + // ISO week 1 of 2024 starts on Monday 2024-01-01 + const start = weekNumberToDate({ week: 1, year: 2024 }); + + expect(start.toISOString().slice(0, 10)).toBe("2024-01-01"); + }); + + it("returns the Sunday of the ISO week regardless of server timezone", () => { + // ISO week 1 of 2024 ends on Sunday 2024-01-07 + const end = weekNumberToDate({ week: 1, year: 2024, position: "end" }); + + expect(end.toISOString().slice(0, 10)).toBe("2024-01-07"); + }); +}); + +describe("weekNumberToDateRange", () => { + // Force a timezone west of UTC observing DST so the assertion is deterministic + // regardless of where the test happens to run. + const originalTimezone = process.env.TZ; + beforeAll(() => { + process.env.TZ = "America/New_York"; + }); + afterAll(() => { + process.env.TZ = originalTimezone; + }); + + const SEVEN_DAYS_MS = 7 * 24 * 60 * 60 * 1000; + + it("spans exactly seven days even when the week contains a DST transition", () => { + // US spring-forward 2025 happened on Sunday 2025-03-09, which falls inside + // ISO week 10 of 2025 (Mon 2025-03-03 .. Mon 2025-03-10). + const { startTime, endTime } = weekNumberToDateRange({ + week: 10, + year: 2025, + }); + + expect(endTime.getTime() - startTime.getTime()).toBe(SEVEN_DAYS_MS); + }); + + it("spans exactly seven days for an ordinary week", () => { + const { startTime, endTime } = weekNumberToDateRange({ + week: 20, + year: 2025, + }); + + expect(endTime.getTime() - startTime.getTime()).toBe(SEVEN_DAYS_MS); + }); +}); diff --git a/app/utils/dates.ts b/app/utils/dates.ts index 6780b8d4d..522cfd2b5 100644 --- a/app/utils/dates.ts +++ b/app/utils/dates.ts @@ -133,15 +133,36 @@ export function weekNumberToDate({ }) { const result = new Date(Date.UTC(year, 0, 4)); - result.setDate( - result.getDate() - (result.getDay() || 7) + 1 + 7 * (week - 1), + result.setUTCDate( + result.getUTCDate() - (result.getUTCDay() || 7) + 1 + 7 * (week - 1), ); if (position === "end") { - result.setDate(result.getDate() + 6); + result.setUTCDate(result.getUTCDate() + 6); } return result; } +/** + * Returns the UTC date range covering an ISO week: the Monday that starts the + * week and the Monday that starts the following week (a 7-day span). Uses UTC + * date arithmetic so the span is exactly 7×24h regardless of the server's + * timezone or any DST transition that falls inside the week. + */ +export function weekNumberToDateRange({ + week, + year, +}: { + week: number; + year: number; +}) { + const startTime = weekNumberToDate({ week, year }); + + const endTime = new Date(startTime); + endTime.setUTCDate(endTime.getUTCDate() + 7); + + return { startTime, endTime }; +} + /** * Checks if a date is valid or not. * @@ -190,11 +211,16 @@ export function getDateWithHoursOffset(date: Date, hoursOffset: number) { export function getDateAtNextFullHour(date: Date) { const copiedDate = new Date(date.getTime()); - if (date.getMinutes() > 0) { + if ( + date.getMinutes() > 0 || + date.getSeconds() > 0 || + date.getMilliseconds() > 0 + ) { copiedDate.setHours(date.getHours() + 1); copiedDate.setMinutes(0); } copiedDate.setSeconds(0); + copiedDate.setMilliseconds(0); return copiedDate; } diff --git a/app/utils/number.ts b/app/utils/number.ts index d4f8ba502..a788f80f4 100644 --- a/app/utils/number.ts +++ b/app/utils/number.ts @@ -27,7 +27,9 @@ export function roundToNDecimalPlaces(num: number, n = 2) { */ export function cutToNDecimalPlaces(num: number, n = 2) { const multiplier = 10 ** n; - const truncatedNum = Math.trunc(num * multiplier) / multiplier; + // Round away floating point representation error (e.g. 0.29 * 100 = 28.999...) before truncating + const scaled = Number((num * multiplier).toFixed(8)); + const truncatedNum = Math.trunc(scaled) / multiplier; const result = truncatedNum.toFixed(n); return Number(n > 0 ? result.replace(/\.?0+$/, "") : result); } diff --git a/app/utils/numbers.test.ts b/app/utils/numbers.test.ts index 25323d270..c6b904d69 100644 --- a/app/utils/numbers.test.ts +++ b/app/utils/numbers.test.ts @@ -61,6 +61,16 @@ describe("cutToNDecimalPlaces()", () => { const result = cutToNDecimalPlaces(3.0001, 2); expect(result).toBe(3); }); + + test("cutOff preserves a value already at the desired number of decimal places", () => { + const result = cutToNDecimalPlaces(0.29, 2); + expect(result).toBe(0.29); + }); + + test("cutOff is not thrown off by floating point representation error", () => { + expect(cutToNDecimalPlaces(2.32, 2)).toBe(2.32); + expect(cutToNDecimalPlaces(-0.29, 2)).toBe(-0.29); + }); }); describe("averageArray()", () => { diff --git a/app/utils/string.test.ts b/app/utils/string.test.ts index 4141951fa..bdb48ac41 100644 --- a/app/utils/string.test.ts +++ b/app/utils/string.test.ts @@ -15,6 +15,12 @@ describe("pathnameFromPotentialURL()", () => { test("Returns string as is if not URL", () => { expect(pathnameFromPotentialURL("sendouc")).toBe("sendouc"); }); + + test("Strips trailing slash from URL path", () => { + expect(pathnameFromPotentialURL("https://discord.gg/FW4dKrY/")).toBe( + "FW4dKrY", + ); + }); }); describe("truncateBySentence()", () => { diff --git a/app/utils/strings.ts b/app/utils/strings.ts index 0fe1de694..8f2392e4f 100644 --- a/app/utils/strings.ts +++ b/app/utils/strings.ts @@ -51,7 +51,7 @@ export function gearTypeToInitial(gearType: GearType) { export function pathnameFromPotentialURL(maybeUrl: string) { try { - return new URL(maybeUrl).pathname.replace("/", ""); + return new URL(maybeUrl).pathname.replace(/^\/+|\/+$/g, ""); } catch { return maybeUrl; } diff --git a/app/utils/urls.test.ts b/app/utils/urls.test.ts new file mode 100644 index 000000000..d4e7e409f --- /dev/null +++ b/app/utils/urls.test.ts @@ -0,0 +1,12 @@ +import { describe, expect, it } from "vitest"; +import { userArtPage } from "./urls"; + +describe("userArtPage()", () => { + it("joins source and bigArtId params with a single query string", () => { + const url = userArtPage({ discordId: "123" }, "MADE-BY", 456); + + const params = new URLSearchParams(url.split("?")[1]); + expect(params.get("source")).toBe("MADE-BY"); + expect(params.get("big")).toBe("456"); + }); +}); diff --git a/app/utils/urls.ts b/app/utils/urls.ts index 40995200f..73b195d4e 100644 --- a/app/utils/urls.ts +++ b/app/utils/urls.ts @@ -181,8 +181,17 @@ export const userArtPage = ( user: UserLinkArgs, source?: ArtSource, bigArtId?: number, -) => - `${userPage(user)}/art${source ? `?source=${source}` : ""}${bigArtId ? `?big=${bigArtId}` : ""}`; +) => { + const params = new URLSearchParams(); + if (source) { + params.set("source", source); + } + if (typeof bigArtId === "number") { + params.set("big", String(bigArtId)); + } + + return `${userPage(user)}/art${params.size > 0 ? `?${params.toString()}` : ""}`; +}; export const newArtPage = (artId?: Tables["Art"]["id"]) => `${artPage()}/new${artId ? `?art=${artId}` : ""}`; export const userNewBuildPage = (