mirror of
https://github.com/Sendouc/sendou.ink.git
synced 2026-08-28 14:18:04 -05:00
Commitments engine
This commit is contained in:
@@ -62,6 +62,40 @@ export function findAllWeeksByUserIds({
|
||||
.execute();
|
||||
}
|
||||
|
||||
/**
|
||||
* Team events of every team the given users are members of (secondary teams
|
||||
* included) that overlap the given window, one row per member.
|
||||
*/
|
||||
export function findAllTeamEventsByUserIds({
|
||||
userIds,
|
||||
startsAt,
|
||||
endsAt,
|
||||
}: {
|
||||
userIds: Array<number>;
|
||||
startsAt: number;
|
||||
endsAt: number;
|
||||
}) {
|
||||
if (userIds.length === 0) return Promise.resolve([]);
|
||||
|
||||
return db
|
||||
.selectFrom("TeamEvent")
|
||||
.innerJoin(
|
||||
"TeamMemberWithSecondary",
|
||||
"TeamMemberWithSecondary.teamId",
|
||||
"TeamEvent.teamId",
|
||||
)
|
||||
.select([
|
||||
"TeamMemberWithSecondary.userId",
|
||||
"TeamEvent.name",
|
||||
"TeamEvent.startsAt",
|
||||
"TeamEvent.endsAt",
|
||||
])
|
||||
.where("TeamMemberWithSecondary.userId", "in", userIds)
|
||||
.where("TeamEvent.startsAt", "<", endsAt)
|
||||
.where("TeamEvent.endsAt", ">", startsAt)
|
||||
.execute();
|
||||
}
|
||||
|
||||
interface UpsertOwnWeekArgs {
|
||||
weekStartsAt: number;
|
||||
timezone: string;
|
||||
|
||||
@@ -10,6 +10,8 @@ export const AVAILABILITY = {
|
||||
WEEK_HORIZON: 2,
|
||||
/** Weeks whose end is further in the past than this are deleted. */
|
||||
RETENTION_MONTHS: 3,
|
||||
/** Assumed length of an accepted scrim when it blocks availability — the actual end is not in the data model. */
|
||||
SCRIM_COMMITMENT_SECONDS: 2 * 60 * 60,
|
||||
/** A reported week belongs to a viewer week when their starts are closer than this — timezones set them apart by hours, never by days. */
|
||||
WEEK_MATCH_MAX_DISTANCE_SECONDS: 3.5 * 24 * 60 * 60,
|
||||
/** Left edge of the editor's clock window (14:00) — evenings are when people play. */
|
||||
|
||||
@@ -49,3 +49,13 @@ export interface EditorCommitment {
|
||||
range: DayTimeRange;
|
||||
name: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* A span a commitment makes the user busy for, overriding whatever
|
||||
* availability they reported. `name` is what the user is at (e.g. the
|
||||
* tournament's name); `null` when the type alone says it (a scrim).
|
||||
*/
|
||||
export interface BusyBlock extends TimeRange {
|
||||
type: "tournament" | "scrim" | "teamEvent";
|
||||
name: string | null;
|
||||
}
|
||||
|
||||
@@ -134,6 +134,11 @@ export function MySchedule({ data }: { data: MyScheduleData }) {
|
||||
<WeekAvailabilityEditor
|
||||
key={data.weeks[weekIndex].weekStartsAt}
|
||||
value={shownDays}
|
||||
commitments={data.commitments.map((commitment) => ({
|
||||
date: commitment.date,
|
||||
range: commitment.range,
|
||||
name: commitment.name ?? t("schedule:commitment.scrim"),
|
||||
}))}
|
||||
onChange={(value) =>
|
||||
setWeeks(
|
||||
weeks.map((days, index) => (index === weekIndex ? value : days)),
|
||||
|
||||
@@ -494,6 +494,7 @@ export function WeekAvailabilityEditor({
|
||||
className={styles.commitment}
|
||||
style={barStyle(commitment.range)}
|
||||
title={commitment.name}
|
||||
data-testid="availability-commitment"
|
||||
>
|
||||
<span className={styles.commitmentName}>{commitment.name}</span>
|
||||
</div>
|
||||
|
||||
273
app/features/availability/core/Commitments.server.test.ts
Normal file
273
app/features/availability/core/Commitments.server.test.ts
Normal file
@@ -0,0 +1,273 @@
|
||||
import { beforeEach, describe, expect, test } from "vitest";
|
||||
import * as ScrimPostFactory from "~/db/seed/factories/ScrimPostFactory";
|
||||
import * as TeamEventFactory from "~/db/seed/factories/TeamEventFactory";
|
||||
import * as TeamFactory from "~/db/seed/factories/TeamFactory";
|
||||
import * as TournamentFactory from "~/db/seed/factories/TournamentFactory";
|
||||
import * as TournamentTeamFactory from "~/db/seed/factories/TournamentTeamFactory";
|
||||
import * as UserFactory from "~/db/seed/factories/UserFactory";
|
||||
import { db } from "~/db/sql";
|
||||
import type { TournamentSettings } from "~/db/tables-json";
|
||||
import * as TournamentTeamRepository from "~/features/tournament/TournamentTeamRepository.server";
|
||||
import { withUserId } from "~/utils/Test";
|
||||
import * as Commitments from "./Commitments.server";
|
||||
|
||||
const users = UserFactory.pool();
|
||||
const memberId = () => users.id(1);
|
||||
const teammateId = () => users.id(2);
|
||||
const outsiderId = () => users.id(3);
|
||||
const opponentId = () => users.id(4);
|
||||
const organizerId = () => users.id(5);
|
||||
|
||||
const HOUR = 60 * 60;
|
||||
const DAY = 24 * HOUR;
|
||||
|
||||
/** Monday 2027-01-25 00:00 UTC; any fixed point works, the queries take explicit windows. */
|
||||
const WEEK_STARTS_AT = 1_800_000_000;
|
||||
|
||||
const WINDOW = {
|
||||
startsAt: WEEK_STARTS_AT,
|
||||
endsAt: WEEK_STARTS_AT + 7 * DAY,
|
||||
};
|
||||
|
||||
const DOUBLE_ELIMINATION: TournamentSettings["bracketProgression"] = [
|
||||
{
|
||||
name: "Bracket",
|
||||
type: "double_elimination",
|
||||
requiresCheckIn: false,
|
||||
settings: {},
|
||||
},
|
||||
];
|
||||
|
||||
const blocksOf = async (userId: number, window = WINDOW) =>
|
||||
(
|
||||
await Commitments.busyBlocksByUserIds({
|
||||
userIds: [userId, outsiderId()],
|
||||
...window,
|
||||
})
|
||||
).get(userId);
|
||||
|
||||
describe("Commitments.busyBlocksByUserIds", () => {
|
||||
beforeEach(async () => {
|
||||
await users.create(5);
|
||||
});
|
||||
|
||||
test("a team event blocks every member for its span", async () => {
|
||||
const team = await TeamFactory.create({
|
||||
memberUserIds: [memberId(), teammateId()],
|
||||
});
|
||||
await TeamEventFactory.create({
|
||||
teamId: team.id,
|
||||
authorId: memberId(),
|
||||
name: "VoD review",
|
||||
startsAt: WEEK_STARTS_AT + DAY,
|
||||
endsAt: WEEK_STARTS_AT + DAY + 2 * HOUR,
|
||||
});
|
||||
|
||||
const byUserId = await Commitments.busyBlocksByUserIds({
|
||||
userIds: [memberId(), teammateId(), outsiderId()],
|
||||
...WINDOW,
|
||||
});
|
||||
|
||||
for (const userId of [memberId(), teammateId()]) {
|
||||
expect(byUserId.get(userId)).toEqual([
|
||||
{
|
||||
type: "teamEvent",
|
||||
name: "VoD review",
|
||||
startsAt: WEEK_STARTS_AT + DAY,
|
||||
endsAt: WEEK_STARTS_AT + DAY + 2 * HOUR,
|
||||
},
|
||||
]);
|
||||
}
|
||||
expect(byUserId.get(outsiderId())).toBeUndefined();
|
||||
});
|
||||
|
||||
test("an accepted scrim blocks both sides for the assumed length", async () => {
|
||||
await ScrimPostFactory.create(
|
||||
{
|
||||
startsAt: WEEK_STARTS_AT + 2 * DAY,
|
||||
users: [{ userId: memberId(), isOwner: 1 }],
|
||||
},
|
||||
{
|
||||
requests: [
|
||||
{ users: [{ userId: opponentId(), isOwner: 1 }], isAccepted: true },
|
||||
],
|
||||
},
|
||||
);
|
||||
|
||||
for (const userId of [memberId(), opponentId()]) {
|
||||
expect(await blocksOf(userId)).toEqual([
|
||||
{
|
||||
type: "scrim",
|
||||
name: null,
|
||||
startsAt: WEEK_STARTS_AT + 2 * DAY,
|
||||
endsAt: WEEK_STARTS_AT + 2 * DAY + 2 * HOUR,
|
||||
},
|
||||
]);
|
||||
}
|
||||
});
|
||||
|
||||
test("a scrim that is only requested is not a block", async () => {
|
||||
await ScrimPostFactory.create(
|
||||
{
|
||||
startsAt: WEEK_STARTS_AT + 2 * DAY,
|
||||
users: [{ userId: memberId(), isOwner: 1 }],
|
||||
},
|
||||
{ requests: [{ users: [{ userId: opponentId(), isOwner: 1 }] }] },
|
||||
);
|
||||
|
||||
expect(await blocksOf(memberId())).toBeUndefined();
|
||||
expect(await blocksOf(opponentId())).toBeUndefined();
|
||||
});
|
||||
|
||||
test("a range scrim blocks at the accepted request's chosen time", async () => {
|
||||
await ScrimPostFactory.create(
|
||||
{
|
||||
startsAt: WEEK_STARTS_AT + DAY,
|
||||
rangeEndsAt: WEEK_STARTS_AT + DAY + 3 * HOUR,
|
||||
users: [{ userId: memberId(), isOwner: 1 }],
|
||||
},
|
||||
{
|
||||
requests: [
|
||||
{
|
||||
users: [{ userId: opponentId(), isOwner: 1 }],
|
||||
startsAt: WEEK_STARTS_AT + DAY + HOUR,
|
||||
isAccepted: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
);
|
||||
|
||||
expect(await blocksOf(memberId())).toEqual([
|
||||
{
|
||||
type: "scrim",
|
||||
name: null,
|
||||
startsAt: WEEK_STARTS_AT + DAY + HOUR,
|
||||
endsAt: WEEK_STARTS_AT + DAY + 3 * HOUR,
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
test("a tournament registration blocks from the event start for the estimated duration", async () => {
|
||||
const tournament = await TournamentFactory.create({
|
||||
authorId: organizerId(),
|
||||
name: "In The Zone 42",
|
||||
startTimes: [WEEK_STARTS_AT + 3 * DAY],
|
||||
bracketProgression: DOUBLE_ELIMINATION,
|
||||
});
|
||||
await TournamentTeamFactory.create({
|
||||
tournamentId: tournament.id,
|
||||
memberUserIds: [memberId(), teammateId()],
|
||||
});
|
||||
|
||||
expect(await blocksOf(memberId())).toEqual([
|
||||
{
|
||||
type: "tournament",
|
||||
name: "In The Zone 42",
|
||||
startsAt: WEEK_STARTS_AT + 3 * DAY,
|
||||
endsAt: WEEK_STARTS_AT + 3 * DAY + 4 * HOUR,
|
||||
},
|
||||
]);
|
||||
expect(await blocksOf(outsiderId())).toBeUndefined();
|
||||
});
|
||||
|
||||
test("test and league tournaments are not blocks", async () => {
|
||||
const testTournament = await TournamentFactory.create({
|
||||
authorId: organizerId(),
|
||||
startTimes: [WEEK_STARTS_AT + 3 * DAY],
|
||||
isTest: true,
|
||||
});
|
||||
await TournamentTeamFactory.create({
|
||||
tournamentId: testTournament.id,
|
||||
memberUserIds: [memberId()],
|
||||
});
|
||||
|
||||
const leagueTournament = await TournamentFactory.create({
|
||||
authorId: organizerId(),
|
||||
startTimes: [WEEK_STARTS_AT + 4 * DAY],
|
||||
});
|
||||
await setTournamentSettings(leagueTournament.id, { isLeague: true });
|
||||
await TournamentTeamFactory.create({
|
||||
tournamentId: leagueTournament.id,
|
||||
memberUserIds: [memberId()],
|
||||
});
|
||||
|
||||
expect(await blocksOf(memberId())).toBeUndefined();
|
||||
});
|
||||
|
||||
test("a dropped-out team's registration is not a block", async () => {
|
||||
const tournament = await TournamentFactory.create({
|
||||
authorId: organizerId(),
|
||||
startTimes: [WEEK_STARTS_AT + 3 * DAY],
|
||||
});
|
||||
const tournamentTeam = await TournamentTeamFactory.create({
|
||||
tournamentId: tournament.id,
|
||||
memberUserIds: [memberId()],
|
||||
});
|
||||
await withUserId(memberId(), () =>
|
||||
TournamentTeamRepository.dropOut({
|
||||
tournamentTeamId: tournamentTeam.id,
|
||||
previewBracketIdxs: [],
|
||||
}),
|
||||
);
|
||||
|
||||
expect(await blocksOf(memberId())).toBeUndefined();
|
||||
});
|
||||
|
||||
test("only blocks overlapping the window are returned, sorted by start", async () => {
|
||||
const team = await TeamFactory.create({ memberUserIds: [memberId()] });
|
||||
await TeamEventFactory.create({
|
||||
teamId: team.id,
|
||||
authorId: memberId(),
|
||||
name: "Before the window",
|
||||
startsAt: WEEK_STARTS_AT - 3 * HOUR,
|
||||
endsAt: WEEK_STARTS_AT,
|
||||
});
|
||||
await TeamEventFactory.create({
|
||||
teamId: team.id,
|
||||
authorId: memberId(),
|
||||
name: "Straddles the start",
|
||||
startsAt: WEEK_STARTS_AT - HOUR,
|
||||
endsAt: WEEK_STARTS_AT + HOUR,
|
||||
});
|
||||
await ScrimPostFactory.create(
|
||||
{
|
||||
startsAt: WEEK_STARTS_AT + 2 * DAY,
|
||||
users: [{ userId: memberId(), isOwner: 1 }],
|
||||
},
|
||||
{
|
||||
requests: [
|
||||
{ users: [{ userId: opponentId(), isOwner: 1 }], isAccepted: true },
|
||||
],
|
||||
},
|
||||
);
|
||||
await TeamEventFactory.create({
|
||||
teamId: team.id,
|
||||
authorId: memberId(),
|
||||
name: "After the window",
|
||||
startsAt: WINDOW.endsAt + HOUR,
|
||||
endsAt: WINDOW.endsAt + 2 * HOUR,
|
||||
});
|
||||
|
||||
expect(
|
||||
(await blocksOf(memberId()))?.map((block) => block.startsAt),
|
||||
).toEqual([WEEK_STARTS_AT - HOUR, WEEK_STARTS_AT + 2 * DAY]);
|
||||
});
|
||||
});
|
||||
|
||||
async function setTournamentSettings(
|
||||
tournamentId: number,
|
||||
patch: Partial<TournamentSettings>,
|
||||
) {
|
||||
const { settings } = await db
|
||||
.selectFrom("Tournament")
|
||||
.select("settings")
|
||||
.where("id", "=", tournamentId)
|
||||
.executeTakeFirstOrThrow();
|
||||
|
||||
// biome-ignore lint/plugin: leagues are not created through app code, so no production write reaches isLeague
|
||||
await db
|
||||
.updateTable("Tournament")
|
||||
.set({ settings: JSON.stringify({ ...settings, ...patch }) })
|
||||
.where("id", "=", tournamentId)
|
||||
.execute();
|
||||
}
|
||||
95
app/features/availability/core/Commitments.server.ts
Normal file
95
app/features/availability/core/Commitments.server.ts
Normal file
@@ -0,0 +1,95 @@
|
||||
import * as R from "remeda";
|
||||
import * as ScrimPostRepository from "~/features/scrims/ScrimPostRepository.server";
|
||||
import * as TournamentTeamRepository from "~/features/tournament/TournamentTeamRepository.server";
|
||||
import * as AvailabilityRepository from "../AvailabilityRepository.server";
|
||||
import { AVAILABILITY } from "../availability-constants";
|
||||
import type { BusyBlock } from "../availability-types";
|
||||
import * as Availability from "./Availability";
|
||||
import * as TournamentDuration from "./TournamentDuration";
|
||||
|
||||
/**
|
||||
* The busy blocks of the given users within the given window, keyed by user
|
||||
* id and sorted by start. A busy block overrides whatever availability the
|
||||
* user reported: effective availability = reported − busy blocks.
|
||||
*
|
||||
* Sourced from tournament registrations (start + estimated duration, see
|
||||
* {@link TournamentDuration.estimateSeconds}), accepted scrims (start + an
|
||||
* assumed length) and team events (their actual span). League registrations
|
||||
* are not blocks — a league runs over weeks and its matches are scheduled
|
||||
* separately.
|
||||
*/
|
||||
export async function busyBlocksByUserIds({
|
||||
userIds,
|
||||
startsAt,
|
||||
endsAt,
|
||||
}: {
|
||||
userIds: Array<number>;
|
||||
startsAt: number;
|
||||
endsAt: number;
|
||||
}): Promise<Map<number, Array<BusyBlock>>> {
|
||||
if (userIds.length === 0) return new Map();
|
||||
|
||||
const [registrations, scrims, teamEvents] = await Promise.all([
|
||||
TournamentTeamRepository.findAllRegistrationsByUserIds({
|
||||
userIds,
|
||||
startsAt: startsAt - TournamentDuration.MAX_ESTIMATE_SECONDS,
|
||||
endsAt,
|
||||
}),
|
||||
ScrimPostRepository.findAllAcceptedByUserIds({
|
||||
userIds,
|
||||
startsAt: startsAt - AVAILABILITY.SCRIM_COMMITMENT_SECONDS,
|
||||
endsAt,
|
||||
}),
|
||||
AvailabilityRepository.findAllTeamEventsByUserIds({
|
||||
userIds,
|
||||
startsAt,
|
||||
endsAt,
|
||||
}),
|
||||
]);
|
||||
|
||||
const blocks: Array<BusyBlock & { userId: number }> = [
|
||||
...registrations
|
||||
.filter((registration) => !registration.settings.isLeague)
|
||||
.map((registration) => ({
|
||||
userId: registration.userId,
|
||||
type: "tournament" as const,
|
||||
name: registration.name,
|
||||
startsAt: registration.startsAt,
|
||||
endsAt:
|
||||
registration.startsAt +
|
||||
TournamentDuration.estimateSeconds({
|
||||
minMembersPerTeam: registration.settings.minMembersPerTeam ?? 4,
|
||||
bracketTypes: registration.settings.bracketProgression.map(
|
||||
(bracket) => bracket.type,
|
||||
),
|
||||
teamCount: registration.teamCount,
|
||||
}),
|
||||
})),
|
||||
...scrims.map((scrim) => ({
|
||||
userId: scrim.userId,
|
||||
type: "scrim" as const,
|
||||
name: null,
|
||||
startsAt: scrim.startsAt,
|
||||
endsAt: scrim.startsAt + AVAILABILITY.SCRIM_COMMITMENT_SECONDS,
|
||||
})),
|
||||
...teamEvents.map((event) => ({
|
||||
userId: event.userId,
|
||||
type: "teamEvent" as const,
|
||||
name: event.name,
|
||||
startsAt: event.startsAt,
|
||||
endsAt: event.endsAt,
|
||||
})),
|
||||
].filter((block) => Availability.overlaps(block, { startsAt, endsAt }));
|
||||
|
||||
return new Map(
|
||||
Object.entries(R.groupBy(blocks, (block) => block.userId)).map(
|
||||
([userId, userBlocks]) => [
|
||||
Number(userId),
|
||||
R.sortBy(
|
||||
userBlocks.map((block) => R.omit(block, ["userId"])),
|
||||
(block) => block.startsAt,
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -6,6 +6,7 @@ import * as AvailabilityRepository from "../AvailabilityRepository.server";
|
||||
import { AVAILABILITY } from "../availability-constants";
|
||||
import type { DayTimeRange, TimeRange } from "../availability-types";
|
||||
import * as Availability from "./Availability";
|
||||
import * as Commitments from "./Commitments.server";
|
||||
|
||||
const DAY_SECONDS = 24 * 60 * 60;
|
||||
|
||||
@@ -24,14 +25,22 @@ export async function myScheduleData(userId: number) {
|
||||
const now = new Date();
|
||||
|
||||
const lastWeekRange = Availability.weekRange(subWeeks(now, 1), timezone);
|
||||
const reportedWeeks = await AvailabilityRepository.findAllWeeksByUserIds({
|
||||
userIds: [userId],
|
||||
startsAt: lastWeekRange.startsAt,
|
||||
endsAt: Availability.weekRange(
|
||||
addWeeks(now, AVAILABILITY.WEEK_HORIZON - 1),
|
||||
timezone,
|
||||
).endsAt,
|
||||
});
|
||||
const horizonEndsAt = Availability.weekRange(
|
||||
addWeeks(now, AVAILABILITY.WEEK_HORIZON - 1),
|
||||
timezone,
|
||||
).endsAt;
|
||||
const [reportedWeeks, busyBlocks] = await Promise.all([
|
||||
AvailabilityRepository.findAllWeeksByUserIds({
|
||||
userIds: [userId],
|
||||
startsAt: lastWeekRange.startsAt,
|
||||
endsAt: horizonEndsAt,
|
||||
}),
|
||||
Commitments.busyBlocksByUserIds({
|
||||
userIds: [userId],
|
||||
startsAt: Availability.weekRange(now, timezone).startsAt,
|
||||
endsAt: horizonEndsAt,
|
||||
}),
|
||||
]);
|
||||
|
||||
const weeks = R.range(0, AVAILABILITY.WEEK_HORIZON).map((weekOffset) =>
|
||||
editorWeek({
|
||||
@@ -52,6 +61,12 @@ export async function myScheduleData(userId: number) {
|
||||
lastWeekRanges: lastWeek.submitted
|
||||
? lastWeek.days.map((day) => day.ranges)
|
||||
: null,
|
||||
commitments: (busyBlocks.get(userId) ?? []).map((block) => ({
|
||||
date: Availability.dateInTimezone(block.startsAt, timezone),
|
||||
range: slotToDayRange(block, timezone),
|
||||
type: block.type,
|
||||
name: block.name,
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
105
app/features/availability/core/TournamentDuration.test.ts
Normal file
105
app/features/availability/core/TournamentDuration.test.ts
Normal file
@@ -0,0 +1,105 @@
|
||||
import { describe, expect, test } from "vitest";
|
||||
import type { Tables } from "~/db/tables";
|
||||
import * as TournamentDuration from "./TournamentDuration";
|
||||
|
||||
const HOUR = 60 * 60;
|
||||
|
||||
const DOUBLE_ELIMINATION: Array<Tables["TournamentStage"]["type"]> = [
|
||||
"double_elimination",
|
||||
];
|
||||
const GROUPS_TO_TOP_CUT: Array<Tables["TournamentStage"]["type"]> = [
|
||||
"round_robin",
|
||||
"single_elimination",
|
||||
];
|
||||
|
||||
describe("TournamentDuration.estimateSeconds", () => {
|
||||
test.each([
|
||||
{
|
||||
why: "regular 4v4",
|
||||
minMembersPerTeam: 4,
|
||||
bracketTypes: DOUBLE_ELIMINATION,
|
||||
teamCount: 16,
|
||||
expected: 4 * HOUR,
|
||||
},
|
||||
{
|
||||
why: "large 4v4",
|
||||
minMembersPerTeam: 4,
|
||||
bracketTypes: GROUPS_TO_TOP_CUT,
|
||||
teamCount: 32,
|
||||
expected: 4.5 * HOUR,
|
||||
},
|
||||
{
|
||||
why: "single elimination only is the short outlier",
|
||||
minMembersPerTeam: 4,
|
||||
bracketTypes: ["single_elimination"] as const,
|
||||
teamCount: 16,
|
||||
expected: 2 * HOUR,
|
||||
},
|
||||
{
|
||||
why: "single elimination feeding from groups is not the outlier",
|
||||
minMembersPerTeam: 4,
|
||||
bracketTypes: GROUPS_TO_TOP_CUT,
|
||||
teamCount: 16,
|
||||
expected: 4 * HOUR,
|
||||
},
|
||||
{
|
||||
why: "1v1",
|
||||
minMembersPerTeam: 1,
|
||||
bracketTypes: DOUBLE_ELIMINATION,
|
||||
teamCount: 16,
|
||||
expected: 2.5 * HOUR,
|
||||
},
|
||||
{
|
||||
why: "2v2",
|
||||
minMembersPerTeam: 2,
|
||||
bracketTypes: DOUBLE_ELIMINATION,
|
||||
teamCount: 16,
|
||||
expected: 2.5 * HOUR,
|
||||
},
|
||||
{
|
||||
why: "3v3 stays small-sized regardless of team count",
|
||||
minMembersPerTeam: 3,
|
||||
bracketTypes: DOUBLE_ELIMINATION,
|
||||
teamCount: 64,
|
||||
expected: 2.5 * HOUR,
|
||||
},
|
||||
{
|
||||
why: "small-sized single elimination only",
|
||||
minMembersPerTeam: 1,
|
||||
bracketTypes: ["single_elimination"] as const,
|
||||
teamCount: 8,
|
||||
expected: 2 * HOUR,
|
||||
},
|
||||
])(
|
||||
"returns $expected seconds for $why",
|
||||
({ minMembersPerTeam, bracketTypes, teamCount, expected }) => {
|
||||
expect(
|
||||
TournamentDuration.estimateSeconds({
|
||||
minMembersPerTeam,
|
||||
bracketTypes: [...bracketTypes],
|
||||
teamCount,
|
||||
}),
|
||||
).toBe(expected);
|
||||
},
|
||||
);
|
||||
|
||||
test("no estimate exceeds MAX_ESTIMATE_SECONDS", () => {
|
||||
for (const minMembersPerTeam of [1, 2, 3, 4]) {
|
||||
for (const bracketTypes of [
|
||||
DOUBLE_ELIMINATION,
|
||||
GROUPS_TO_TOP_CUT,
|
||||
["single_elimination" as const],
|
||||
]) {
|
||||
for (const teamCount of [4, 16, 32, 100]) {
|
||||
expect(
|
||||
TournamentDuration.estimateSeconds({
|
||||
minMembersPerTeam,
|
||||
bracketTypes,
|
||||
teamCount,
|
||||
}),
|
||||
).toBeLessThanOrEqual(TournamentDuration.MAX_ESTIMATE_SECONDS);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
68
app/features/availability/core/TournamentDuration.ts
Normal file
68
app/features/availability/core/TournamentDuration.ts
Normal file
@@ -0,0 +1,68 @@
|
||||
import type { Tables } from "~/db/tables";
|
||||
|
||||
const HOUR_SECONDS = 60 * 60;
|
||||
|
||||
const SINGLE_ELIMINATION_ONLY_HOURS = 2;
|
||||
const SMALL_TEAM_SIZE_HOURS = 2.5;
|
||||
const FOUR_VS_FOUR_HOURS = 4;
|
||||
const LARGE_FOUR_VS_FOUR_HOURS = 4.5;
|
||||
/** Team count from which a 4v4 tournament gets the larger estimate. */
|
||||
const LARGE_TOURNAMENT_TEAM_COUNT = 32;
|
||||
|
||||
/** The largest value {@link estimateSeconds} can return, for widening fetch windows. */
|
||||
export const MAX_ESTIMATE_SECONDS = LARGE_FOUR_VS_FOUR_HOURS * HOUR_SECONDS;
|
||||
|
||||
/**
|
||||
* Estimated length of a tournament in seconds, used to block its players'
|
||||
* availability from the event's start. The actual length is not in the data
|
||||
* model, so this is a constant table measured from the production database
|
||||
* (August 2026): 3222 finalized tournaments, duration = scheduled start → last
|
||||
* reported game result, leagues and test tournaments excluded. Hours:
|
||||
*
|
||||
* | case | n | p25 | med | p75 | p90 |
|
||||
* | --------------------------- | ---- | --- | --- | --- | --- |
|
||||
* | 1v1 | 273 | 1.5 | 2.0 | 2.5 | 3.1 |
|
||||
* | 2v2 | 282 | 1.9 | 2.3 | 2.7 | 3.0 |
|
||||
* | 3v3 | 30 | 1.6 | 2.1 | 2.5 | 2.7 |
|
||||
* | 4v4 | 2593 | 2.6 | 3.2 | 3.8 | 4.3 |
|
||||
* | single elim only (any size) | 129 | 0.8 | 1.3 | 1.7 | 2.1 |
|
||||
* | 4v4, 32+ teams | 309 | 3.4 | 3.7 | 4.2 | 4.5 |
|
||||
*
|
||||
* What the data showed:
|
||||
*
|
||||
* - Team size and team count are the strong predictors. Format mostly proxies
|
||||
* team count (round robin → elim and swiss events are the bigger ones); the
|
||||
* one format that stands out on its own is a lone single elimination
|
||||
* bracket, roughly half the length of everything else.
|
||||
* - Team count raises duration (4v4 medians: <8 teams 2.2, 8–15 3.1, 16–31
|
||||
* 3.7, 32–63 3.7, 64+ 4.2) but at estimate time the registered count is
|
||||
* only a lower bound of the final count, so it only ever raises the
|
||||
* estimate above the size default, never lowers it.
|
||||
* - SZ-only vs multi-mode map pools made no meaningful difference (medians
|
||||
* 3.4 vs 3.2), so modes are not a dimension.
|
||||
*
|
||||
* The estimates sit at ≈p75 of their case: slightly generous, because a block
|
||||
* that runs a bit long beats showing a player free while they are still
|
||||
* playing.
|
||||
*/
|
||||
export function estimateSeconds({
|
||||
minMembersPerTeam,
|
||||
bracketTypes,
|
||||
teamCount,
|
||||
}: {
|
||||
minMembersPerTeam: number;
|
||||
bracketTypes: Array<Tables["TournamentStage"]["type"]>;
|
||||
teamCount: number;
|
||||
}) {
|
||||
const isSingleEliminationOnly =
|
||||
bracketTypes.length === 1 && bracketTypes[0] === "single_elimination";
|
||||
if (isSingleEliminationOnly) {
|
||||
return SINGLE_ELIMINATION_ONLY_HOURS * HOUR_SECONDS;
|
||||
}
|
||||
|
||||
if (minMembersPerTeam < 4) return SMALL_TEAM_SIZE_HOURS * HOUR_SECONDS;
|
||||
|
||||
return teamCount >= LARGE_TOURNAMENT_TEAM_COUNT
|
||||
? LARGE_FOUR_VS_FOUR_HOURS * HOUR_SECONDS
|
||||
: FOUR_VS_FOUR_HOURS * HOUR_SECONDS;
|
||||
}
|
||||
@@ -11,8 +11,13 @@ import type { SerializeFrom } from "~/utils/remix";
|
||||
import { notFoundIfNullish } from "~/utils/remix.server";
|
||||
import * as AvailabilityRepository from "../AvailabilityRepository.server";
|
||||
import { AVAILABILITY } from "../availability-constants";
|
||||
import type { PlayableWindowTier, TimeRange } from "../availability-types";
|
||||
import type {
|
||||
BusyBlock,
|
||||
PlayableWindowTier,
|
||||
TimeRange,
|
||||
} from "../availability-types";
|
||||
import * as Availability from "../core/Availability";
|
||||
import * as Commitments from "../core/Commitments.server";
|
||||
|
||||
const DAY_SECONDS = 24 * 60 * 60;
|
||||
|
||||
@@ -35,14 +40,23 @@ export const loader = async ({ params }: LoaderFunctionArgs) => {
|
||||
const timezone = getViewerTimezone() ?? "UTC";
|
||||
const now = new Date();
|
||||
|
||||
const reportedWeeks = await AvailabilityRepository.findAllWeeksByUserIds({
|
||||
userIds: members.map((member) => member.id),
|
||||
const horizon = {
|
||||
startsAt: Availability.weekRange(now, timezone).startsAt,
|
||||
endsAt: Availability.weekRange(
|
||||
addWeeks(now, AVAILABILITY.WEEK_HORIZON - 1),
|
||||
timezone,
|
||||
).endsAt,
|
||||
});
|
||||
};
|
||||
const [reportedWeeks, busyByUserId] = await Promise.all([
|
||||
AvailabilityRepository.findAllWeeksByUserIds({
|
||||
userIds: members.map((member) => member.id),
|
||||
...horizon,
|
||||
}),
|
||||
Commitments.busyBlocksByUserIds({
|
||||
userIds: members.map((member) => member.id),
|
||||
...horizon,
|
||||
}),
|
||||
]);
|
||||
|
||||
const playerIds = members
|
||||
.filter((member) => getMemberRoleType(member) !== "OTHER")
|
||||
@@ -56,6 +70,7 @@ export const loader = async ({ params }: LoaderFunctionArgs) => {
|
||||
memberIds: members.map((member) => member.id),
|
||||
playerIds,
|
||||
reportedWeeks,
|
||||
busyByUserId,
|
||||
}),
|
||||
),
|
||||
};
|
||||
@@ -71,12 +86,14 @@ function weekView({
|
||||
memberIds,
|
||||
playerIds,
|
||||
reportedWeeks,
|
||||
busyByUserId,
|
||||
}: {
|
||||
range: TimeRange;
|
||||
timezone: string;
|
||||
memberIds: Array<number>;
|
||||
playerIds: Array<number>;
|
||||
reportedWeeks: Array<ReportedWeek>;
|
||||
busyByUserId: Map<number, Array<BusyBlock>>;
|
||||
}) {
|
||||
const minPlayers = Math.min(
|
||||
AVAILABILITY.DEFAULT_MIN_PLAYERS,
|
||||
@@ -86,11 +103,14 @@ function weekView({
|
||||
const windows = Availability.playableWindows({
|
||||
members: playerIds.map((userId) => ({
|
||||
userId,
|
||||
ranges: Availability.clip(
|
||||
reportedWeeks
|
||||
.filter((week) => week.userId === userId)
|
||||
.flatMap((week) => week.slots),
|
||||
range,
|
||||
ranges: Availability.subtract(
|
||||
Availability.clip(
|
||||
reportedWeeks
|
||||
.filter((week) => week.userId === userId)
|
||||
.flatMap((week) => week.slots),
|
||||
range,
|
||||
),
|
||||
busyByUserId.get(userId) ?? [],
|
||||
),
|
||||
})),
|
||||
minPlayers,
|
||||
@@ -108,7 +128,14 @@ function weekView({
|
||||
});
|
||||
|
||||
const members = memberIds.map((userId) =>
|
||||
memberWeekRow({ userId, days, timezone, reportedWeeks, range }),
|
||||
memberWeekRow({
|
||||
userId,
|
||||
days,
|
||||
timezone,
|
||||
reportedWeeks,
|
||||
range,
|
||||
busy: busyByUserId.get(userId) ?? [],
|
||||
}),
|
||||
);
|
||||
|
||||
return {
|
||||
@@ -155,13 +182,21 @@ function memberWeekRow({
|
||||
timezone,
|
||||
reportedWeeks,
|
||||
range,
|
||||
busy,
|
||||
}: {
|
||||
userId: number;
|
||||
days: Array<{ date: string; noonAt: number }>;
|
||||
timezone: string;
|
||||
reportedWeeks: Array<ReportedWeek>;
|
||||
range: TimeRange;
|
||||
busy: Array<BusyBlock>;
|
||||
}) {
|
||||
const busyOfDay = (day: { date: string }) =>
|
||||
busy.filter(
|
||||
(block) =>
|
||||
Availability.dateInTimezone(block.startsAt, timezone) === day.date,
|
||||
);
|
||||
|
||||
const memberWeeks = reportedWeeks.filter((week) => week.userId === userId);
|
||||
const matchingWeek = memberWeeks.find(
|
||||
(week) =>
|
||||
@@ -173,24 +208,33 @@ function memberWeekRow({
|
||||
return {
|
||||
userId,
|
||||
reported: false,
|
||||
days: days.map(() => []) as Array<Array<TimeRange>>,
|
||||
days: days.map((day) => ({
|
||||
ranges: [] as Array<TimeRange>,
|
||||
busy: busyOfDay(day),
|
||||
})),
|
||||
notes: [] as Array<{ dayIndex: number; text: string }>,
|
||||
};
|
||||
}
|
||||
|
||||
// slots are placed on the viewer-local day they start on, wherever their
|
||||
// author's week put them — the adjacent weeks' spillover included
|
||||
const slots = memberWeeks.flatMap((week) => week.slots);
|
||||
// author's week put them — the adjacent weeks' spillover included. What a
|
||||
// commitment takes back is cut out first: the grid shows when the member
|
||||
// is actually free.
|
||||
const slots = Availability.subtract(
|
||||
memberWeeks.flatMap((week) => week.slots),
|
||||
busy,
|
||||
);
|
||||
|
||||
return {
|
||||
userId,
|
||||
reported: true,
|
||||
days: days.map((day) =>
|
||||
slots.filter(
|
||||
days: days.map((day) => ({
|
||||
ranges: slots.filter(
|
||||
(slot) =>
|
||||
Availability.dateInTimezone(slot.startsAt, timezone) === day.date,
|
||||
),
|
||||
),
|
||||
busy: busyOfDay(day),
|
||||
})),
|
||||
notes: memberWeeks.flatMap((week) =>
|
||||
week.dayNotes.flatMap((note) => {
|
||||
const noteDate = Availability.dateInTimezone(
|
||||
|
||||
@@ -84,6 +84,32 @@
|
||||
color: var(--color-text-high);
|
||||
}
|
||||
|
||||
.busy {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
max-width: 10rem;
|
||||
padding: var(--s-0-5) var(--s-1-5);
|
||||
background: repeating-linear-gradient(
|
||||
-45deg,
|
||||
var(--color-bg-higher) 0 5px,
|
||||
transparent 5px 10px
|
||||
);
|
||||
border-radius: var(--radius-full);
|
||||
align-self: flex-start;
|
||||
|
||||
& .busyName {
|
||||
max-width: 100%;
|
||||
padding-inline: var(--s-1);
|
||||
font-size: var(--font-3xs);
|
||||
color: var(--color-text-high);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
background-color: var(--color-bg);
|
||||
border-radius: var(--radius-full);
|
||||
}
|
||||
}
|
||||
|
||||
.noteFlag {
|
||||
color: var(--color-text-accent);
|
||||
}
|
||||
|
||||
@@ -131,11 +131,11 @@ function ScheduleGrid({ week }: { week: WeekData }) {
|
||||
<th scope="row" className={styles.memberCell}>
|
||||
<UserLink user={row.member} className={styles.memberLink} />
|
||||
</th>
|
||||
{row.days.map((ranges, dayIndex) => (
|
||||
{row.days.map((day, dayIndex) => (
|
||||
<ScheduleCell
|
||||
key={week.days[dayIndex].date}
|
||||
row={row}
|
||||
ranges={ranges}
|
||||
day={day}
|
||||
dayIndex={dayIndex}
|
||||
/>
|
||||
))}
|
||||
@@ -185,11 +185,11 @@ function ScheduleGrid({ week }: { week: WeekData }) {
|
||||
|
||||
function ScheduleCell({
|
||||
row,
|
||||
ranges,
|
||||
day,
|
||||
dayIndex,
|
||||
}: {
|
||||
row: MemberWeekRow;
|
||||
ranges: MemberWeekRow["days"][number];
|
||||
day: MemberWeekRow["days"][number];
|
||||
dayIndex: number;
|
||||
}) {
|
||||
const { t } = useTranslation(["schedule"]);
|
||||
@@ -203,7 +203,7 @@ function ScheduleCell({
|
||||
// formatRange expands to full dates when the ends fall on different
|
||||
// calendar days, so a range crossing (or ending exactly at) midnight
|
||||
// formats its ends separately to stay times-only
|
||||
const rangeText = (range: MemberWeekRow["days"][number][number]) =>
|
||||
const rangeText = (range: { startsAt: number; endsAt: number }) =>
|
||||
isSameDay(
|
||||
databaseTimestampToDate(range.startsAt),
|
||||
databaseTimestampToDate(range.endsAt),
|
||||
@@ -211,6 +211,9 @@ function ScheduleCell({
|
||||
? timeFormatter.formatRange(range.startsAt, range.endsAt)
|
||||
: `${timeFormatter.format(range.startsAt)} – ${timeFormatter.format(range.endsAt)}`;
|
||||
|
||||
const busyName = (block: MemberWeekRow["days"][number]["busy"][number]) =>
|
||||
block.name ?? t("schedule:commitment.scrim");
|
||||
|
||||
return (
|
||||
<td
|
||||
className={styles.cell}
|
||||
@@ -224,7 +227,7 @@ function ScheduleCell({
|
||||
>
|
||||
?
|
||||
</span>
|
||||
) : ranges.length === 0 ? (
|
||||
) : day.ranges.length === 0 && day.busy.length === 0 ? (
|
||||
<span
|
||||
className={styles.unavailable}
|
||||
title={t("schedule:team.notAvailable")}
|
||||
@@ -232,7 +235,7 @@ function ScheduleCell({
|
||||
—
|
||||
</span>
|
||||
) : (
|
||||
ranges.map((range) => (
|
||||
day.ranges.map((range) => (
|
||||
<div
|
||||
key={range.startsAt}
|
||||
className={styles.range}
|
||||
@@ -242,6 +245,16 @@ function ScheduleCell({
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
{day.busy.map((block) => (
|
||||
<div
|
||||
key={block.startsAt}
|
||||
className={styles.busy}
|
||||
title={`${rangeText(block)} · ${busyName(block)}`}
|
||||
data-testid="schedule-busy"
|
||||
>
|
||||
<span className={styles.busyName}>{busyName(block)}</span>
|
||||
</div>
|
||||
))}
|
||||
{note ? (
|
||||
<span title={note.text}>
|
||||
<Flag className={styles.noteFlag} size={12} aria-hidden />
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { sub } from "date-fns";
|
||||
import type { Insertable } from "kysely";
|
||||
import { type Insertable, sql } from "kysely";
|
||||
import type { Tables, TablesInsertable } from "~/db/tables";
|
||||
import { actorId, actorIdOrNull } from "~/features/auth/core/user.server";
|
||||
import { databaseTimestampNow, dateToDatabaseTimestamp } from "~/utils/dates";
|
||||
@@ -465,6 +465,58 @@ export async function findAcceptedScrimsBetweenTwoTimestamps({
|
||||
return rows.map(mapDBRowToScrimPost).filter((post) => Scrim.isAccepted(post));
|
||||
}
|
||||
|
||||
/**
|
||||
* Finds the accepted (booked), uncanceled scrims of the given users whose
|
||||
* resolved start time — the accepted request's chosen time for a range post,
|
||||
* the post's own otherwise — falls within the given window. Used to resolve
|
||||
* availability commitments.
|
||||
*
|
||||
* @returns one row per participating user per scrim
|
||||
*/
|
||||
export async function findAllAcceptedByUserIds({
|
||||
userIds,
|
||||
startsAt,
|
||||
endsAt,
|
||||
}: {
|
||||
userIds: Array<number>;
|
||||
startsAt: number;
|
||||
endsAt: number;
|
||||
}) {
|
||||
if (userIds.length === 0) return [];
|
||||
|
||||
const resolvedStartsAt = sql<number>`coalesce("ScrimPostRequest"."startsAt", "ScrimPost"."startsAt")`;
|
||||
|
||||
const acceptedInWindow = db
|
||||
.selectFrom("ScrimPost")
|
||||
.innerJoin("ScrimPostRequest", (join) =>
|
||||
join
|
||||
.onRef("ScrimPostRequest.scrimPostId", "=", "ScrimPost.id")
|
||||
.on("ScrimPostRequest.isAccepted", "=", 1),
|
||||
)
|
||||
.where("ScrimPost.canceledAt", "is", null)
|
||||
.where(resolvedStartsAt, ">=", startsAt)
|
||||
.where(resolvedStartsAt, "<=", endsAt);
|
||||
|
||||
const [postSideUsers, requestSideUsers] = await Promise.all([
|
||||
acceptedInWindow
|
||||
.innerJoin("ScrimPostUser", "ScrimPostUser.scrimPostId", "ScrimPost.id")
|
||||
.select(["ScrimPostUser.userId", resolvedStartsAt.as("startsAt")])
|
||||
.where("ScrimPostUser.userId", "in", userIds)
|
||||
.execute(),
|
||||
acceptedInWindow
|
||||
.innerJoin(
|
||||
"ScrimPostRequestUser",
|
||||
"ScrimPostRequestUser.scrimPostRequestId",
|
||||
"ScrimPostRequest.id",
|
||||
)
|
||||
.select(["ScrimPostRequestUser.userId", resolvedStartsAt.as("startsAt")])
|
||||
.where("ScrimPostRequestUser.userId", "in", userIds)
|
||||
.execute(),
|
||||
]);
|
||||
|
||||
return [...postSideUsers, ...requestSideUsers];
|
||||
}
|
||||
|
||||
/**
|
||||
* Finds pending (unaccepted, uncanceled, future) scrim posts and requests
|
||||
* involving any of the given users whose time overlaps [startTime, endTime].
|
||||
|
||||
@@ -921,6 +921,60 @@ async function findTeamRecentMaps(
|
||||
.execute();
|
||||
}
|
||||
|
||||
/**
|
||||
* Tournament registrations of the given users whose event start falls within
|
||||
* the given window, one row per registered member per event date. Dropped-out
|
||||
* teams and hidden events (test and draft tournaments) are excluded. Used to
|
||||
* resolve availability commitments, so alongside the event's name and start
|
||||
* the rows carry what estimating the tournament's duration needs: the
|
||||
* settings and how many teams have registered so far.
|
||||
*/
|
||||
export function findAllRegistrationsByUserIds({
|
||||
userIds,
|
||||
startsAt,
|
||||
endsAt,
|
||||
}: {
|
||||
userIds: Array<number>;
|
||||
startsAt: number;
|
||||
endsAt: number;
|
||||
}) {
|
||||
if (userIds.length === 0) return Promise.resolve([]);
|
||||
|
||||
return db
|
||||
.selectFrom("TournamentTeamMember")
|
||||
.innerJoin(
|
||||
"TournamentTeam",
|
||||
"TournamentTeam.id",
|
||||
"TournamentTeamMember.tournamentTeamId",
|
||||
)
|
||||
.innerJoin("Tournament", "Tournament.id", "TournamentTeam.tournamentId")
|
||||
.innerJoin("CalendarEvent", "CalendarEvent.tournamentId", "Tournament.id")
|
||||
.innerJoin(
|
||||
"CalendarEventDate",
|
||||
"CalendarEventDate.eventId",
|
||||
"CalendarEvent.id",
|
||||
)
|
||||
.select((eb) => [
|
||||
"TournamentTeamMember.userId",
|
||||
"CalendarEvent.name",
|
||||
"CalendarEventDate.startsAt",
|
||||
"Tournament.settings",
|
||||
eb
|
||||
.selectFrom("TournamentTeam as RegisteredTeam")
|
||||
.select(({ fn }) => fn.countAll<number>().as("count"))
|
||||
.whereRef("RegisteredTeam.tournamentId", "=", "Tournament.id")
|
||||
.where("RegisteredTeam.isPlaceholder", "=", 0)
|
||||
.as("teamCount"),
|
||||
])
|
||||
.$narrowType<{ teamCount: NotNull }>()
|
||||
.where("TournamentTeamMember.userId", "in", userIds)
|
||||
.where("TournamentTeam.droppedOut", "=", 0)
|
||||
.where("CalendarEvent.hidden", "=", 0)
|
||||
.where("CalendarEventDate.startsAt", ">=", startsAt)
|
||||
.where("CalendarEventDate.startsAt", "<=", endsAt)
|
||||
.execute();
|
||||
}
|
||||
|
||||
/** Invite code of one team, the secret the tournament layout data does not carry. */
|
||||
export async function findInviteCodeById(tournamentTeamId: number) {
|
||||
const row = await db
|
||||
|
||||
@@ -120,6 +120,44 @@ test.describe("My schedule", () => {
|
||||
await isNotVisible(events.weekNotFilledMarker("current"));
|
||||
});
|
||||
|
||||
test("shows a commitment as a locked block on the editor", async ({
|
||||
page,
|
||||
factories,
|
||||
}) => {
|
||||
const currentWeek = Availability.weekRange(new Date(), MACHINE_TIMEZONE);
|
||||
const wednesday = Availability.dateInTimezone(
|
||||
currentWeek.startsAt + WEDNESDAY * DAY_SECONDS + DAY_SECONDS / 2,
|
||||
MACHINE_TIMEZONE,
|
||||
);
|
||||
const team = await factories.TeamFactory.create({
|
||||
memberUserIds: [ADMIN_ID],
|
||||
});
|
||||
await factories.TeamEventFactory.create({
|
||||
teamId: team.id,
|
||||
authorId: ADMIN_ID,
|
||||
name: "VoD review",
|
||||
startsAt: Availability.localToTimestamp({
|
||||
date: wednesday,
|
||||
time: "20:00",
|
||||
timezone: MACHINE_TIMEZONE,
|
||||
}),
|
||||
endsAt: Availability.localToTimestamp({
|
||||
date: wednesday,
|
||||
time: "21:30",
|
||||
timezone: MACHINE_TIMEZONE,
|
||||
}),
|
||||
});
|
||||
|
||||
await impersonate(page, ADMIN_ID);
|
||||
await setTimezoneCookie(page);
|
||||
|
||||
const events = new EventsPage(page);
|
||||
await events.goto();
|
||||
|
||||
await expect(events.locators.commitments.first()).toBeVisible();
|
||||
await expect(events.locators.commitments.first()).toHaveText("VoD review");
|
||||
});
|
||||
|
||||
test("copies last week's ranges into the current week", async ({
|
||||
page,
|
||||
factories,
|
||||
|
||||
@@ -62,6 +62,7 @@ export async function loadFactories(parallelIndex: number) {
|
||||
SQReadyCheckFactory: await import(
|
||||
"~/db/seed/factories/SQReadyCheckFactory"
|
||||
),
|
||||
TeamEventFactory: await import("~/db/seed/factories/TeamEventFactory"),
|
||||
TeamFactory: await import("~/db/seed/factories/TeamFactory"),
|
||||
TournamentFactory: await import("~/db/seed/factories/TournamentFactory"),
|
||||
TournamentOrganizationFactory: await import(
|
||||
|
||||
@@ -25,6 +25,7 @@ export class EventsPage {
|
||||
emptyCategoryText: page.getByText("No events in this category"),
|
||||
mySchedule: page.getByTestId("my-schedule"),
|
||||
availabilityBars: page.getByTestId("availability-bar"),
|
||||
commitments: page.getByTestId("availability-commitment"),
|
||||
saveWeekButton: page.getByTestId("save-week-button"),
|
||||
copyLastWeekButton: page.getByTestId("copy-last-week-button"),
|
||||
dayEditorPopover: page.getByRole("dialog"),
|
||||
|
||||
@@ -32,6 +32,10 @@ export class TeamSchedulePage {
|
||||
return this.cell(userId, dayIndex).getByTestId("schedule-range");
|
||||
}
|
||||
|
||||
cellBusy(userId: number, dayIndex: number) {
|
||||
return this.cell(userId, dayIndex).getByTestId("schedule-busy");
|
||||
}
|
||||
|
||||
dayDot(dayIndex: number) {
|
||||
return this.page.getByTestId(`schedule-day-dot-${dayIndex}`);
|
||||
}
|
||||
|
||||
@@ -364,7 +364,7 @@ test.describe("Team schedule", () => {
|
||||
factories,
|
||||
}) => {
|
||||
const noScheduleMember = await factories.UserFactory.create();
|
||||
const { customUrl } = await factories.TeamFactory.create({
|
||||
const { id: teamId, customUrl } = await factories.TeamFactory.create({
|
||||
name: TEAM_NAME,
|
||||
memberUserIds: [ADMIN_ID, NZAP_TEST_ID, noScheduleMember.id],
|
||||
});
|
||||
@@ -391,6 +391,14 @@ test.describe("Team schedule", () => {
|
||||
timezone: MACHINE_TIMEZONE,
|
||||
slots: [daySlot(WEDNESDAY, "19:00", "23:00")],
|
||||
});
|
||||
// a commitment late in the shared Wednesday evening: renders as a busy
|
||||
// block and trims effective availability without removing the window
|
||||
await factories.TeamEventFactory.create({
|
||||
teamId,
|
||||
authorId: ADMIN_ID,
|
||||
name: "VoD review",
|
||||
...daySlot(WEDNESDAY, "22:00", "23:30"),
|
||||
});
|
||||
|
||||
await impersonate(page, ADMIN_ID);
|
||||
await setTimezoneCookie(page);
|
||||
@@ -405,6 +413,9 @@ test.describe("Team schedule", () => {
|
||||
await expect(schedule.cellRange(ADMIN_ID, THURSDAY)).toBeVisible();
|
||||
await expect(schedule.cell(ADMIN_ID, 0)).toHaveText("—");
|
||||
await expect(schedule.cell(noScheduleMember.id, 0)).toHaveText("?");
|
||||
await expect(schedule.cellBusy(NZAP_TEST_ID, WEDNESDAY)).toHaveText(
|
||||
"VoD review",
|
||||
);
|
||||
await expect(schedule.locators.notes).toContainText("Leaving early");
|
||||
|
||||
// two members share Wed 19-22 while the third has no schedule, so the
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
{
|
||||
"commitment.scrim": "",
|
||||
"editor.addTime": "",
|
||||
"editor.copyLastWeek": "",
|
||||
"editor.earlier": "",
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
{
|
||||
"commitment.scrim": "",
|
||||
"editor.addTime": "",
|
||||
"editor.copyLastWeek": "",
|
||||
"editor.earlier": "",
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
{
|
||||
"commitment.scrim": "Scrim",
|
||||
"editor.addTime": "Add time",
|
||||
"editor.copyLastWeek": "Copy last week",
|
||||
"editor.earlier": "Earlier",
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
{
|
||||
"commitment.scrim": "",
|
||||
"editor.addTime": "",
|
||||
"editor.copyLastWeek": "",
|
||||
"editor.earlier": "",
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
{
|
||||
"commitment.scrim": "",
|
||||
"editor.addTime": "",
|
||||
"editor.copyLastWeek": "",
|
||||
"editor.earlier": "",
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
{
|
||||
"commitment.scrim": "",
|
||||
"editor.addTime": "",
|
||||
"editor.copyLastWeek": "",
|
||||
"editor.earlier": "",
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
{
|
||||
"commitment.scrim": "",
|
||||
"editor.addTime": "",
|
||||
"editor.copyLastWeek": "",
|
||||
"editor.earlier": "",
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
{
|
||||
"commitment.scrim": "",
|
||||
"editor.addTime": "",
|
||||
"editor.copyLastWeek": "",
|
||||
"editor.earlier": "",
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
{
|
||||
"commitment.scrim": "",
|
||||
"editor.addTime": "",
|
||||
"editor.copyLastWeek": "",
|
||||
"editor.earlier": "",
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
{
|
||||
"commitment.scrim": "",
|
||||
"editor.addTime": "",
|
||||
"editor.copyLastWeek": "",
|
||||
"editor.earlier": "",
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
{
|
||||
"commitment.scrim": "",
|
||||
"editor.addTime": "",
|
||||
"editor.copyLastWeek": "",
|
||||
"editor.earlier": "",
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
{
|
||||
"commitment.scrim": "",
|
||||
"editor.addTime": "",
|
||||
"editor.copyLastWeek": "",
|
||||
"editor.earlier": "",
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
{
|
||||
"commitment.scrim": "",
|
||||
"editor.addTime": "",
|
||||
"editor.copyLastWeek": "",
|
||||
"editor.earlier": "",
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
{
|
||||
"commitment.scrim": "",
|
||||
"editor.addTime": "",
|
||||
"editor.copyLastWeek": "",
|
||||
"editor.earlier": "",
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
{
|
||||
"commitment.scrim": "",
|
||||
"editor.addTime": "",
|
||||
"editor.copyLastWeek": "",
|
||||
"editor.earlier": "",
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
{
|
||||
"commitment.scrim": "",
|
||||
"editor.addTime": "",
|
||||
"editor.copyLastWeek": "",
|
||||
"editor.earlier": "",
|
||||
|
||||
Reference in New Issue
Block a user