From b3e723e2454c8d70bf1b873d1635840e2ed5260c Mon Sep 17 00:00:00 2001 From: Kalle <38327916+Sendouc@users.noreply.github.com> Date: Wed, 5 Aug 2026 16:33:23 +0300 Subject: [PATCH] Fix even even even more bugs --- .../routes/calendar.$year.$week.test.ts | 34 ++++ .../api-public/routes/calendar.$year.$week.ts | 2 +- app/features/chat/chat-utils.test.ts | 10 ++ app/features/chat/chat-utils.ts | 2 +- app/features/lfg/core/filtering.test.ts | 24 +++ app/features/lfg/core/filtering.ts | 2 +- app/features/lfg/lfg-search-params.test.ts | 4 + app/features/lfg/lfg-types.ts | 7 +- .../tournament-bracket/core/Bracket.test.ts | 65 +++++++ .../core/Bracket/SwissBracket.ts | 16 +- .../core/Tournament.test.ts | 163 +++++++++++++++++- .../core/Tournament.timezone.test.ts | 47 +++++ .../tournament-bracket/core/Tournament.ts | 57 +++++- ...amentOrganizationRepository.server.test.ts | 47 +++++ ...TournamentOrganizationRepository.server.ts | 6 +- app/utils/string.test.ts | 21 +++ app/utils/strings.ts | 39 ++++- 17 files changed, 517 insertions(+), 29 deletions(-) create mode 100644 app/features/api-public/routes/calendar.$year.$week.test.ts create mode 100644 app/features/lfg/core/filtering.test.ts create mode 100644 app/features/tournament-bracket/core/Tournament.timezone.test.ts diff --git a/app/features/api-public/routes/calendar.$year.$week.test.ts b/app/features/api-public/routes/calendar.$year.$week.test.ts new file mode 100644 index 000000000..2f74ce87b --- /dev/null +++ b/app/features/api-public/routes/calendar.$year.$week.test.ts @@ -0,0 +1,34 @@ +import { describe, expect, test } from "vitest"; +import * as CalendarEventFactory from "~/db/seed/factories/CalendarEventFactory"; +import * as UserFactory from "~/db/seed/factories/UserFactory"; +import { dateToDatabaseTimestamp } from "~/utils/dates"; +import { wrappedLoader } from "~/utils/Test"; +import type { GetCalendarWeekResponse } from "../schema"; +import { loader } from "./calendar.$year.$week"; + +const weekLoader = wrappedLoader({ loader }); + +const fetchWeek = async (year: number, week: number) => { + const response = await weekLoader({ + params: { year: String(year), week: String(week) }, + }); + + return (await response.json()) as GetCalendarWeekResponse; +}; + +describe("GET /api/calendar/:year/:week", () => { + test("an event starting exactly at the week boundary is returned for exactly one week", async () => { + const user = await UserFactory.createRegular(); + // Monday 2025-01-13 00:00 UTC, i.e. Sunday 7 PM EST — the boundary + // between ISO weeks 2 and 3 of 2025 + await CalendarEventFactory.create({ + authorId: user.id, + startTimes: [dateToDatabaseTimestamp(new Date("2025-01-13T00:00:00Z"))], + }); + + const weekTwoEvents = await fetchWeek(2025, 2); + const weekThreeEvents = await fetchWeek(2025, 3); + + expect(weekTwoEvents.length + weekThreeEvents.length).toBe(1); + }); +}); diff --git a/app/features/api-public/routes/calendar.$year.$week.ts b/app/features/api-public/routes/calendar.$year.$week.ts index 49115b12a..41a555b9b 100644 --- a/app/features/api-public/routes/calendar.$year.$week.ts +++ b/app/features/api-public/routes/calendar.$year.$week.ts @@ -55,7 +55,7 @@ function fetchEventsOfWeek(args: { week: number; year: number }) { ">=", dateToDatabaseTimestamp(startTime), ) - .where("CalendarEventDate.startsAt", "<=", dateToDatabaseTimestamp(endTime)) + .where("CalendarEventDate.startsAt", "<", dateToDatabaseTimestamp(endTime)) .where("CalendarEvent.hidden", "=", 0) .orderBy("CalendarEventDate.startsAt", "asc") .execute(); diff --git a/app/features/chat/chat-utils.test.ts b/app/features/chat/chat-utils.test.ts index 513fb8889..425334081 100644 --- a/app/features/chat/chat-utils.test.ts +++ b/app/features/chat/chat-utils.test.ts @@ -17,6 +17,16 @@ describe("chatCodeVisible", () => { expect(result).toBe(true); }); + test("not visible when just past expiration window", () => { + const result = chatAccessible({ + isStaff: false, + expiresAfterDays: 1, + comparedTo: sub(new Date(), { days: 1, hours: 12 }), + }); + + expect(result).toBe(false); + }); + test("not visible when past expiration window", () => { const result = chatAccessible({ isStaff: false, diff --git a/app/features/chat/chat-utils.ts b/app/features/chat/chat-utils.ts index f70772e94..cd70b78dd 100644 --- a/app/features/chat/chat-utils.ts +++ b/app/features/chat/chat-utils.ts @@ -12,7 +12,7 @@ export function chatAccessible(args: { }): boolean { const extraDays = args.isStaff ? STAFF_EXTRA_DAYS : 0; return ( - differenceInDays(new Date(), args.comparedTo) <= + differenceInDays(new Date(), args.comparedTo) < args.expiresAfterDays + extraDays ); } diff --git a/app/features/lfg/core/filtering.test.ts b/app/features/lfg/core/filtering.test.ts new file mode 100644 index 000000000..628109100 --- /dev/null +++ b/app/features/lfg/core/filtering.test.ts @@ -0,0 +1,24 @@ +import { describe, expect, test } from "vitest"; +import type { LFGLoaderPost } from "../routes/lfg"; +import { filterPosts } from "./filtering"; + +const postOfType = (type: LFGLoaderPost["type"]) => + ({ + type, + author: { weaponPool: [] }, + team: null, + }) as unknown as LFGLoaderPost; + +describe("filterPosts", () => { + test("a weapon filter with no weapons selected shows every post", () => { + const posts = [postOfType("PLAYER_FOR_TEAM"), postOfType("COACH_FOR_TEAM")]; + + const filtered = filterPosts( + posts, + [{ _tag: "Weapon", weaponSplIds: [] }], + new Map(), + ); + + expect(filtered).toHaveLength(2); + }); +}); diff --git a/app/features/lfg/core/filtering.ts b/app/features/lfg/core/filtering.ts index 9e67e495d..432f1cffe 100644 --- a/app/features/lfg/core/filtering.ts +++ b/app/features/lfg/core/filtering.ts @@ -31,7 +31,7 @@ function filterMatchesPost( if (post.type === "COACH_FOR_TEAM") { // not visible in the UI if ( - filter._tag === "Weapon" || + (filter._tag === "Weapon" && filter.weaponSplIds.length > 0) || filter._tag === "MaxTier" || filter._tag === "MinTier" ) { diff --git a/app/features/lfg/lfg-search-params.test.ts b/app/features/lfg/lfg-search-params.test.ts index 2fbd59b0f..3bc9d06ca 100644 --- a/app/features/lfg/lfg-search-params.test.ts +++ b/app/features/lfg/lfg-search-params.test.ts @@ -14,12 +14,16 @@ const plusTierFilter: LFGFilter = { _tag: "PlusTier", tier: 1 }; const maxTierFilter: LFGFilter = { _tag: "MaxTier", tier: "GOLD" }; const minTierFilter: LFGFilter = { _tag: "MinTier", tier: "BRONZE" }; +// the filter LFGAddFilterButton inserts when the user picks "Weapon" +const emptyWeaponFilter: LFGFilter = { _tag: "Weapon", weaponSplIds: [] }; + describe("lfgSearchParams", () => { it("round-trips", () => { assertRoundTrips(lfgSearchParams, { q: [ [], [weaponFilter], + [emptyWeaponFilter], [typeFilter], [timezoneFilter], [languageFilter], diff --git a/app/features/lfg/lfg-types.ts b/app/features/lfg/lfg-types.ts index fd3182967..450afb334 100644 --- a/app/features/lfg/lfg-types.ts +++ b/app/features/lfg/lfg-types.ts @@ -92,15 +92,16 @@ export function filterToSmallStr(filter: LFGFilter): string { export function smallStrToFilter(s: string): LFGFilter | null { const [tag, val] = s.split("."); - if (!tag || !val) return null; + if (!tag || val === undefined) return null; switch (tag) { case "w": { + // an empty weapon filter is valid, it's what the add filter button inserts const weaponIds = val .split(",") + .filter(Boolean) .map((x) => Number.parseInt(x, 10) as MainWeaponId) - .filter((x) => x !== null && x !== undefined); - if (weaponIds.length === 0) return null; + .filter((x) => !Number.isNaN(x)); return { _tag: "Weapon", weaponSplIds: weaponIds, diff --git a/app/features/tournament-bracket/core/Bracket.test.ts b/app/features/tournament-bracket/core/Bracket.test.ts index 470b416e1..25ae7bcca 100644 --- a/app/features/tournament-bracket/core/Bracket.test.ts +++ b/app/features/tournament-bracket/core/Bracket.test.ts @@ -91,6 +91,71 @@ describe("swiss standings - losses against tied", () => { expect(standing.stats?.lossesAgainstTied).toBe(0); // they lost against "Tidy Tidings" but that team dropped out before final round }); + it("should ignore a dropped out team with an identical record (losses against tied)", () => { + const data = Engine.create({ + type: "swiss", + seeding: [1, 2, 3, 4, 5, 6], + settings: { groupCount: 1, roundCount: 3 }, + }); + + const playedMatch = ( + id: number, + roundIdx: number, + number: number, + winnerId: number, + loserId: number, + ): MatchData => ({ + id, + stageId: data.stage[0].id, + groupId: data.group[0].id, + roundId: data.round[roundIdx].id, + number, + opponent1: { id: winnerId }, + opponent2: { id: loserId }, + winnerSide: "opponent1", + }); + + // teams 1 and 6 both finish 2-1; team 1's only loss is to team 6, + // who dropped out after the swiss ended + data.match = [ + playedMatch(0, 0, 1, 1, 2), + playedMatch(1, 0, 2, 3, 4), + playedMatch(2, 0, 3, 5, 6), + playedMatch(3, 1, 1, 1, 3), + playedMatch(4, 1, 2, 2, 5), + playedMatch(5, 1, 3, 6, 4), + playedMatch(6, 2, 1, 6, 1), + playedMatch(7, 2, 2, 3, 5), + playedMatch(8, 2, 3, 2, 4), + ]; + + const tournament = testTournament({ + data, + ctx: { + settings: { + bracketProgression: [ + { + type: "swiss", + name: "Main Bracket", + requiresCheckIn: false, + settings: {}, + }, + ], + }, + teams: [1, 2, 3, 4, 5, 6].map((teamId) => + tournamentCtxTeam(teamId, { droppedOut: teamId === 6 ? 1 : 0 }), + ), + }, + }); + + const standing = tournament + .bracketByIdx(0) + ?.standings.find((standing) => standing.team.id === 1); + invariant(standing, "Standing not found"); + + expect(standing.stats?.lossesAgainstTied).toBe(0); + }); + const inProgressSwissTestTournament = () => { const data = Engine.create({ type: "swiss", diff --git a/app/features/tournament-bracket/core/Bracket/SwissBracket.ts b/app/features/tournament-bracket/core/Bracket/SwissBracket.ts index dc77ec149..dc7bd2180 100644 --- a/app/features/tournament-bracket/core/Bracket/SwissBracket.ts +++ b/app/features/tournament-bracket/core/Bracket/SwissBracket.ts @@ -34,7 +34,9 @@ export class SwissBracket extends Bracket { const relevantMatchesFinished = this.standingsAreFinal; - if (advanceThreshold) { + // explicit placements override the threshold, e.g. a consolation + // bracket for the teams that did not advance + if (advanceThreshold && placements.length === 0) { return { relevantMatchesFinished, teams: standings @@ -316,10 +318,17 @@ export class SwissBracket extends Bracket { }); } - // wins against tied + const droppedOutTeams = this.tournament.ctx.teams + .filter((t) => t.droppedOut) + .map((t) => t.id); + + // wins against tied, results against dropped out teams don't count for (const team of teams) { + if (droppedOutTeams.includes(team.id)) continue; + for (const team2 of teams) { if (team.id === team2.id) continue; + if (droppedOutTeams.includes(team2.id)) continue; if ( team.setWins !== team2.setWins || // check also set losses to account for dropped teams @@ -358,9 +367,6 @@ export class SwissBracket extends Bracket { } } - const droppedOutTeams = this.tournament.ctx.teams - .filter((t) => t.droppedOut) - .map((t) => t.id); placements.push( ...teams .sort((a, b) => { diff --git a/app/features/tournament-bracket/core/Tournament.test.ts b/app/features/tournament-bracket/core/Tournament.test.ts index 93df0c697..4e4e743ac 100644 --- a/app/features/tournament-bracket/core/Tournament.test.ts +++ b/app/features/tournament-bracket/core/Tournament.test.ts @@ -1,5 +1,12 @@ import { describe, expect, it, test } from "vitest"; -import type { MatchData } from "~/features/tournament-bracket/core/engine/types"; +import type { + BracketData, + GeneratedRound, + MatchData, +} from "~/features/tournament-bracket/core/engine/types"; +import { unwrap } from "~/utils/result"; +import * as Engine from "./engine"; +import type * as Progression from "./Progression"; import { Tournament } from "./Tournament"; import { IN_THE_ZONE_32, @@ -498,6 +505,101 @@ describe("Resolving the team a user is a member of", () => { }); }); +describe("teamMemberOfProgressStatus in swiss", () => { + const teamsWithMembers = [1, 2, 3, 4].map((teamId) => + tournamentCtxTeam(teamId, { memberUserIds: [100 + teamId] }), + ); + + it("resolves an early advanced team as waiting for the follow-up bracket", () => { + const data = playOutEarlyAdvanceSwiss(progressions.swissEarlyAdvance); + + const tournament = testTournament({ + data, + ctx: { + settings: { bracketProgression: progressions.swissEarlyAdvance }, + teams: teamsWithMembers, + }, + }); + + expect( + tournament.bracketByIdx(1)?.seeding, + "test setup: the advanced team should be in the top cut preview", + ).toContain(1); + expect(tournament.teamMemberOfProgressStatus({ id: 101 })?.type).toBe( + "WAITING_FOR_BRACKET", + ); + }); + + it("resolves a dropped out team's status as thanks for playing", () => { + const data = Engine.create({ + type: "swiss", + seeding: [1, 2, 3, 4], + settings: {}, + }); + finishPendingMatches(data); + + const tournament = testTournament({ + data, + ctx: { + settings: { bracketProgression: progressions.swissOneGroup }, + teams: [1, 2, 3, 4].map((teamId) => + tournamentCtxTeam(teamId, { + memberUserIds: [100 + teamId], + droppedOut: teamId === 4 ? 1 : 0, + }), + ), + }, + }); + + expect(tournament.teamMemberOfProgressStatus({ id: 104 })?.type).toBe( + "THANKS_FOR_PLAYING", + ); + }); +}); + +describe("Swiss early advance bracket sourcing", () => { + const progressionWithConsolation: Progression.ParsedBracket[] = [ + { + name: "Main Bracket", + type: "swiss", + requiresCheckIn: false, + settings: { advanceThreshold: 3 }, + }, + { + name: "Top Cut", + type: "single_elimination", + requiresCheckIn: false, + settings: {}, + sources: [{ bracketIdx: 0, placements: [] }], + }, + { + name: "Consolation", + type: "single_elimination", + requiresCheckIn: false, + settings: {}, + sources: [{ bracketIdx: 0, placements: [2, 3, 4] }], + }, + ]; + + it("sources a consolation bracket by its placements instead of the advance threshold", () => { + const data = playOutEarlyAdvanceSwiss(progressionWithConsolation); + + const tournament = testTournament({ + data, + ctx: { settings: { bracketProgression: progressionWithConsolation } }, + }); + + expect( + tournament.bracketByIdx(1)?.seeding, + "test setup: the swiss winner should be in the top cut", + ).toContain(1); + expect( + tournament.bracketByIdx(2)?.seeding, + "the swiss winner advanced to the top cut and should not also be in the consolation bracket", + ).not.toContain(1); + }); +}); + describe("teamById division seeds", () => { it("assigns unique seeds within a division when a late registrant has null startingBracketIdx", () => { const tournament = testTournament({ @@ -540,3 +642,62 @@ describe("teamById division seeds", () => { expect(new Set(divATeamSeeds).size).toBe(3); }); }); + +/** + * Plays a 4 team, 5 round, advance threshold 3 swiss all the way to its end. + * Team 1 wins rounds 1-3 locking their top cut spot, after which the pairing + * excludes them from the remaining rounds. + */ +function playOutEarlyAdvanceSwiss( + progression: Progression.ParsedBracket[], +): BracketData { + const data = Engine.create({ + type: "swiss", + seeding: [1, 2, 3, 4], + settings: { advanceThreshold: 3 }, + }); + const groupId = data.group[0].id; + + finishPendingMatches(data); + for (let roundNumber = 2; roundNumber <= 5; roundNumber++) { + const bracket = testTournament({ + data, + ctx: { settings: { bracketProgression: progression } }, + }).bracketByIdx(0)!; + const generated = Engine.generateRound(bracket.data, { + groupId, + standings: bracket.standings, + settings: bracket.settings, + }); + if (!generated.ok) break; + appendGeneratedRound(data, unwrap(generated)); + finishPendingMatches(data); + } + + return data; +} + +/** Finishes every pending match, team 1 always winning theirs and otherwise the home side. */ +function finishPendingMatches(data: BracketData) { + for (const match of data.match) { + if (match.winnerSide !== null || !match.opponent2) continue; + + match.winnerSide = match.opponent2.id === 1 ? "opponent2" : "opponent1"; + } +} + +function appendGeneratedRound(data: BracketData, round: GeneratedRound) { + let id = Math.max(...data.match.map((match) => match.id)) + 1; + for (const match of round.matches) { + data.match.push({ + id: id++, + stageId: data.stage[0].id, + groupId: round.groupId, + roundId: round.roundId, + number: match.number, + opponent1: match.opponent1, + opponent2: match.opponent2, + winnerSide: null, + }); + } +} diff --git a/app/features/tournament-bracket/core/Tournament.timezone.test.ts b/app/features/tournament-bracket/core/Tournament.timezone.test.ts new file mode 100644 index 000000000..f4b6869d9 --- /dev/null +++ b/app/features/tournament-bracket/core/Tournament.timezone.test.ts @@ -0,0 +1,47 @@ +import { afterAll, beforeAll, describe, expect, it } from "vitest"; +import { dateToDatabaseTimestamp } from "~/utils/dates"; +import { testTournament } from "./tests/test-utils"; + +// in its own file because changing the process timezone affects every Date +// operation of the worker while these tests run +describe("regularCheckInStartsAt in a DST observing timezone", () => { + const ORIGINAL_TZ = process.env.TZ; + + beforeAll(() => { + process.env.TZ = "America/New_York"; + }); + + afterAll(() => { + if (ORIGINAL_TZ === undefined) { + delete process.env.TZ; + } else { + process.env.TZ = ORIGINAL_TZ; + } + }); + + it("check-in opens one hour of real time before the start also on the spring forward night", () => { + // 3:30 AM EDT on the night the USA moves to daylight saving time + const startsAt = new Date("2025-03-09T07:30:00Z"); + + const tournament = testTournament({ + ctx: { startsAt: dateToDatabaseTimestamp(startsAt) }, + }); + + expect(tournament.regularCheckInStartsAt.getTime()).toBe( + startsAt.getTime() - 60 * 60 * 1000, + ); + }); + + it("check-in opens one hour of real time before the start also on the fall back night", () => { + // 1:30 AM EST on the night the USA moves off daylight saving time + const startsAt = new Date("2025-11-02T06:30:00Z"); + + const tournament = testTournament({ + ctx: { startsAt: dateToDatabaseTimestamp(startsAt) }, + }); + + expect(tournament.regularCheckInStartsAt.getTime()).toBe( + startsAt.getTime() - 60 * 60 * 1000, + ); + }); +}); diff --git a/app/features/tournament-bracket/core/Tournament.ts b/app/features/tournament-bracket/core/Tournament.ts index 7a2555da4..f7721ef7b 100644 --- a/app/features/tournament-bracket/core/Tournament.ts +++ b/app/features/tournament-bracket/core/Tournament.ts @@ -24,6 +24,7 @@ import { logger } from "~/utils/logger"; import { assertUnreachable } from "~/utils/types"; import { groupNumberToLetters } from "../tournament-bracket-utils"; import { type Bracket, createBracket } from "./Bracket"; +import { calculateTeamStatus } from "./engine/swiss/team-status"; import { getRounds } from "./rounds"; import * as Seeding from "./Seeding"; import type { TournamentData } from "./Tournament.server"; @@ -855,9 +856,9 @@ export class Tournament { /** Date when the regular check-in is scheduled to start. */ get regularCheckInStartsAt() { - const result = new Date(this.ctx.startsAt); - result.setMinutes(result.getMinutes() - 60); - return result; + // elapsed time math instead of wall clock math so that the window + // stays one hour long across a DST transition + return new Date(this.ctx.startsAt.getTime() - 60 * 60 * 1000); } /** Date when the regular check-in is scheduled to start. */ @@ -1176,20 +1177,32 @@ export class Tournament { for (const bracket of startedBrackets) { if (bracket.type !== "swiss") continue; + // dropped out teams and teams whose run ended early via the advance + // threshold are excluded from the pairing, so no round is coming for them + if (bracket.everyMatchOver || team.droppedOut) continue; // TODO: both seeding and participantTournamentTeamIds are used for the same thing const isParticipant = bracket.participantTournamentTeamIds.includes( team.id, ); - const setsGeneratedCount = bracket.data.match.filter( + const teamsMatches = bracket.data.match.filter( (match) => match.opponent1?.id === team.id || match.opponent2?.id === team.id, - ).length; + ); const notAllRoundsGenerated = - setsGeneratedCount !== bracket.swissRoundCount; + teamsMatches.length !== bracket.swissRoundCount; - if (isParticipant && notAllRoundsGenerated) { + const advanceThreshold = bracket.settings?.advanceThreshold; + const runEndedEarly = advanceThreshold + ? calculateTeamStatus({ + ...swissTeamRecord(teamsMatches, team.id), + advanceThreshold, + roundCount: bracket.swissRoundCount, + }) !== "active" + : false; + + if (isParticipant && notAllRoundsGenerated && !runEndedEarly) { return { type: "WAITING_FOR_ROUND" } as const; } } @@ -1446,6 +1459,36 @@ export class Tournament { } } +/** A team's swiss set record off its match data, a BYE counting as a win. */ +function swissTeamRecord(matches: MatchData[], teamId: number) { + let wins = 0; + let losses = 0; + + for (const match of matches) { + const side = + match.opponent1?.id === teamId + ? "opponent1" + : match.opponent2?.id === teamId + ? "opponent2" + : null; + if (!side) continue; + + if (!match.opponent1 || !match.opponent2) { + wins++; + continue; + } + if (!match.winnerSide) continue; + + if (match.winnerSide === side) { + wins++; + } else { + losses++; + } + } + + return { wins, losses }; +} + /** The parts of a tournament that decide who organizes it. */ export type TournamentOrganizerCtx = Pick< TournamentData["ctx"], diff --git a/app/features/tournament-organization/TournamentOrganizationRepository.server.test.ts b/app/features/tournament-organization/TournamentOrganizationRepository.server.test.ts index a74360a60..8f20ece18 100644 --- a/app/features/tournament-organization/TournamentOrganizationRepository.server.test.ts +++ b/app/features/tournament-organization/TournamentOrganizationRepository.server.test.ts @@ -1,6 +1,8 @@ import { beforeEach, describe, expect, test } from "vitest"; +import * as CalendarEventFactory from "~/db/seed/factories/CalendarEventFactory"; import * as TournamentOrganizationFactory from "~/db/seed/factories/TournamentOrganizationFactory"; import * as UserFactory from "~/db/seed/factories/UserFactory"; +import { dateToDatabaseTimestamp } from "~/utils/dates"; import * as TournamentOrganizationRepository from "./TournamentOrganizationRepository.server"; import { seedOrgEventWithParticipants } from "./test-utils"; @@ -59,6 +61,51 @@ describe("findByUserId", () => { }); }); +describe("findEventsByMonth", () => { + beforeEach(async () => { + await users.create(1); + }); + + const seedOrgEventAt = async (startTime: Date) => { + const org = await TournamentOrganizationFactory.create({ + ownerId: users.id(1), + }); + await CalendarEventFactory.create({ + authorId: users.id(1), + organizationId: org.id, + startTimes: [dateToDatabaseTimestamp(startTime)], + }); + + return org; + }; + + test("includes an event starting within the timezone margin before the month", async () => { + const org = await seedOrgEventAt(new Date("2024-12-31T22:00:00Z")); + + const events = await TournamentOrganizationRepository.findEventsByMonth({ + month: 0, + year: 2025, + organizationId: org.id, + }); + + expect(events).toHaveLength(1); + }); + + test("includes an event starting within the timezone margin after the month", async () => { + // Jan 31, 6 PM in America/Los_Angeles — the org page calendar renders + // this into the January grid for viewers west of UTC + const org = await seedOrgEventAt(new Date("2025-02-01T02:00:00Z")); + + const events = await TournamentOrganizationRepository.findEventsByMonth({ + month: 0, + year: 2025, + organizationId: org.id, + }); + + expect(events).toHaveLength(1); + }); +}); + describe("countActiveParticipants", () => { const WINDOW_START = 1_700_000_000; const WINDOW_END = WINDOW_START + 60 * 60 * 24 * 31; diff --git a/app/features/tournament-organization/TournamentOrganizationRepository.server.ts b/app/features/tournament-organization/TournamentOrganizationRepository.server.ts index e7ebd36aa..b2730aa4a 100644 --- a/app/features/tournament-organization/TournamentOrganizationRepository.server.ts +++ b/app/features/tournament-organization/TournamentOrganizationRepository.server.ts @@ -359,11 +359,11 @@ export async function findEventsByMonth({ organizationId, }: FindEventsByMonthArgs) { const firstDayOfTheMonth = new Date(Date.UTC(year, month, 1)); - const lastDayOfTheMonth = new Date(Date.UTC(year, month + 1, 0)); + const firstDayOfTheNextMonth = new Date(Date.UTC(year, month + 1, 1)); // a bit of margin for timezones, filtered in the frontend code firstDayOfTheMonth.setUTCDate(firstDayOfTheMonth.getUTCDate() - 1); - lastDayOfTheMonth.setUTCDate(lastDayOfTheMonth.getUTCDate() + 1); + firstDayOfTheNextMonth.setUTCDate(firstDayOfTheNextMonth.getUTCDate() + 1); const events = await findEventsBaseQuery(organizationId) .where( @@ -374,7 +374,7 @@ export async function findEventsByMonth({ .where( "CalendarEventDate.startsAt", "<=", - dateToDatabaseTimestamp(lastDayOfTheMonth), + dateToDatabaseTimestamp(firstDayOfTheNextMonth), ) .orderBy("CalendarEventDate.startsAt", "asc") .execute(); diff --git a/app/utils/string.test.ts b/app/utils/string.test.ts index 3ef5f9ee4..24cf60662 100644 --- a/app/utils/string.test.ts +++ b/app/utils/string.test.ts @@ -21,6 +21,11 @@ describe("pathnameFromPotentialURL()", () => { "FW4dKrY", ); }); + + test("Resolves a scheme-less URL paste to the path", () => { + // otherwise the generated discordUrl becomes https://discord.gg/discord.gg/FW4dKrY + expect(pathnameFromPotentialURL("discord.gg/FW4dKrY")).toBe("FW4dKrY"); + }); }); describe("truncateBySentence()", () => { @@ -53,6 +58,17 @@ describe("truncateBySentence()", () => { const text = "First line\nSecond line\nThird line"; expect(truncateBySentence(text, 20)).toBe("First line"); }); + + test("Does not treat a period inside a time like 18.00 as a sentence end", () => { + const text = + "Doors at 18.00, we will be playing five rounds of swiss followed by a top cut."; + expect(truncateBySentence(text, 40)).not.toBe("Doors at 18."); + }); + + test("Does not return a tiny fraction of the budget when a long sentence follows a short one", () => { + const text = `Hi. ${"x".repeat(400)}. Everyone is welcome.`; + expect(truncateBySentence(text, 300)).not.toBe("Hi."); + }); }); describe("removeMarkdown()", () => { @@ -71,6 +87,11 @@ describe("removeMarkdown()", () => { expect(removeMarkdown("café & tea")).toBe("café & tea"); }); + test("Does not throw on an out of range numeric entity", () => { + // e.g. an organizer writing a hex color code in the description + expect(() => removeMarkdown("background: � here")).not.toThrow(); + }); + test("Leaves unknown named entities untouched", () => { expect(removeMarkdown("AT&T &fakeentity; rules")).toBe( "AT&T &fakeentity; rules", diff --git a/app/utils/strings.ts b/app/utils/strings.ts index 39fbbddb4..6632ae8b5 100644 --- a/app/utils/strings.ts +++ b/app/utils/strings.ts @@ -50,19 +50,38 @@ export function gearTypeToInitial(gearType: GearType) { } export function pathnameFromPotentialURL(maybeUrl: string) { + const parsed = safeParseUrl(maybeUrl); + if (parsed) return stripEdgeSlashes(parsed.pathname); + + // handle a URL pasted without a protocol, e.g. "discord.gg/FW4dKrY" + const parsedWithProtocol = safeParseUrl(`https://${maybeUrl}`); + const pathname = parsedWithProtocol + ? stripEdgeSlashes(parsedWithProtocol.pathname) + : ""; + + return pathname || maybeUrl; +} + +function safeParseUrl(value: string) { try { - return new URL(maybeUrl).pathname.replace(/^\/+|\/+$/g, ""); + return new URL(value); } catch { - return maybeUrl; + return null; } } +function stripEdgeSlashes(pathname: string) { + return pathname.replace(/^\/+|\/+$/g, ""); +} + export function truncateBySentence(value: string, max: number) { if (value.length <= max) { return value; } - const sentences = value.match(/[^.!?\n]+[.!?\n]*/g) || []; + // a sentence only ends at a terminator followed by whitespace, so that + // e.g. "18.00" does not split in the middle + const sentences = value.match(/[\s\S]+?(?:[.!?](?=\s|$)|\n|$)/g) || []; let result = ""; for (const sentence of sentences) { @@ -72,7 +91,13 @@ export function truncateBySentence(value: string, max: number) { result += sentence; } - return result.length > 0 ? result.trim() : value.slice(0, max).trim(); + // when cutting at a sentence boundary would leave most of the budget + // unused, a mid-sentence cut that fills it is more informative + if (result.length < max / 2) { + return value.slice(0, max).trim(); + } + + return result.trim(); } // based on https://github.com/zuchka/remove-markdown @@ -101,9 +126,9 @@ export function removeMarkdown(value: string) { const codePoint = code.startsWith("x") ? Number.parseInt(code.slice(1), 16) : Number.parseInt(code, 10); - return Number.isFinite(codePoint) - ? String.fromCodePoint(codePoint) - : ""; + const isValidCodePoint = + Number.isFinite(codePoint) && codePoint >= 0 && codePoint <= 0x10ffff; + return isValidCodePoint ? String.fromCodePoint(codePoint) : ""; }) // Remove setext-style headers .replace(/^[=-]{2,}\s*$/g, "")