Commitments engine

This commit is contained in:
Kalle
2026-08-22 18:07:22 +03:00
parent 199e1546c2
commit 6f3d9ddbe8
36 changed files with 901 additions and 33 deletions

View File

@@ -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;

View File

@@ -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. */

View File

@@ -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;
}

View File

@@ -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)),

View File

@@ -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>

View 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();
}

View 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,
),
],
),
);
}

View File

@@ -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,
})),
};
}

View 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);
}
}
}
});
});

View 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, 815 3.1, 1631
* 3.7, 3263 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;
}

View File

@@ -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(

View File

@@ -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);
}

View File

@@ -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 />

View File

@@ -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].

View File

@@ -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

View File

@@ -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,

View File

@@ -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(

View File

@@ -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"),

View File

@@ -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}`);
}

View File

@@ -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

View File

@@ -1,4 +1,5 @@
{
"commitment.scrim": "",
"editor.addTime": "",
"editor.copyLastWeek": "",
"editor.earlier": "",

View File

@@ -1,4 +1,5 @@
{
"commitment.scrim": "",
"editor.addTime": "",
"editor.copyLastWeek": "",
"editor.earlier": "",

View File

@@ -1,4 +1,5 @@
{
"commitment.scrim": "Scrim",
"editor.addTime": "Add time",
"editor.copyLastWeek": "Copy last week",
"editor.earlier": "Earlier",

View File

@@ -1,4 +1,5 @@
{
"commitment.scrim": "",
"editor.addTime": "",
"editor.copyLastWeek": "",
"editor.earlier": "",

View File

@@ -1,4 +1,5 @@
{
"commitment.scrim": "",
"editor.addTime": "",
"editor.copyLastWeek": "",
"editor.earlier": "",

View File

@@ -1,4 +1,5 @@
{
"commitment.scrim": "",
"editor.addTime": "",
"editor.copyLastWeek": "",
"editor.earlier": "",

View File

@@ -1,4 +1,5 @@
{
"commitment.scrim": "",
"editor.addTime": "",
"editor.copyLastWeek": "",
"editor.earlier": "",

View File

@@ -1,4 +1,5 @@
{
"commitment.scrim": "",
"editor.addTime": "",
"editor.copyLastWeek": "",
"editor.earlier": "",

View File

@@ -1,4 +1,5 @@
{
"commitment.scrim": "",
"editor.addTime": "",
"editor.copyLastWeek": "",
"editor.earlier": "",

View File

@@ -1,4 +1,5 @@
{
"commitment.scrim": "",
"editor.addTime": "",
"editor.copyLastWeek": "",
"editor.earlier": "",

View File

@@ -1,4 +1,5 @@
{
"commitment.scrim": "",
"editor.addTime": "",
"editor.copyLastWeek": "",
"editor.earlier": "",

View File

@@ -1,4 +1,5 @@
{
"commitment.scrim": "",
"editor.addTime": "",
"editor.copyLastWeek": "",
"editor.earlier": "",

View File

@@ -1,4 +1,5 @@
{
"commitment.scrim": "",
"editor.addTime": "",
"editor.copyLastWeek": "",
"editor.earlier": "",

View File

@@ -1,4 +1,5 @@
{
"commitment.scrim": "",
"editor.addTime": "",
"editor.copyLastWeek": "",
"editor.earlier": "",

View File

@@ -1,4 +1,5 @@
{
"commitment.scrim": "",
"editor.addTime": "",
"editor.copyLastWeek": "",
"editor.earlier": "",

View File

@@ -1,4 +1,5 @@
{
"commitment.scrim": "",
"editor.addTime": "",
"editor.copyLastWeek": "",
"editor.earlier": "",