From 6f3d9ddbe84fc3bb3a2ff7a4d554ba92afaa244d Mon Sep 17 00:00:00 2001 From: Kalle <38327916+Sendouc@users.noreply.github.com> Date: Sat, 22 Aug 2026 18:07:22 +0300 Subject: [PATCH] Commitments engine --- .../AvailabilityRepository.server.ts | 34 +++ .../availability/availability-constants.ts | 2 + .../availability/availability-types.ts | 10 + .../availability/components/MySchedule.tsx | 5 + .../components/WeekAvailabilityEditor.tsx | 1 + .../core/Commitments.server.test.ts | 273 ++++++++++++++++++ .../availability/core/Commitments.server.ts | 95 ++++++ .../availability/core/MySchedule.server.ts | 31 +- .../core/TournamentDuration.test.ts | 105 +++++++ .../availability/core/TournamentDuration.ts | 68 +++++ .../loaders/t.$customUrl.schedule.server.ts | 76 ++++- .../routes/t.$customUrl.schedule.module.css | 26 ++ .../routes/t.$customUrl.schedule.tsx | 27 +- .../scrims/ScrimPostRepository.server.ts | 54 +++- .../TournamentTeamRepository.server.ts | 54 ++++ e2e/events.spec.ts | 38 +++ e2e/helpers/factories.ts | 1 + e2e/pages/calendar/events-page.ts | 1 + e2e/pages/team/team-schedule-page.ts | 4 + e2e/team.spec.ts | 13 +- locales/da/schedule.json | 1 + locales/de/schedule.json | 1 + locales/en/schedule.json | 1 + locales/es-ES/schedule.json | 1 + locales/es-US/schedule.json | 1 + locales/fr-CA/schedule.json | 1 + locales/fr-EU/schedule.json | 1 + locales/he/schedule.json | 1 + locales/it/schedule.json | 1 + locales/ja/schedule.json | 1 + locales/ko/schedule.json | 1 + locales/nl/schedule.json | 1 + locales/pl/schedule.json | 1 + locales/pt-BR/schedule.json | 1 + locales/ru/schedule.json | 1 + locales/zh/schedule.json | 1 + 36 files changed, 901 insertions(+), 33 deletions(-) create mode 100644 app/features/availability/core/Commitments.server.test.ts create mode 100644 app/features/availability/core/Commitments.server.ts create mode 100644 app/features/availability/core/TournamentDuration.test.ts create mode 100644 app/features/availability/core/TournamentDuration.ts diff --git a/app/features/availability/AvailabilityRepository.server.ts b/app/features/availability/AvailabilityRepository.server.ts index df72ed76d..c6841199e 100644 --- a/app/features/availability/AvailabilityRepository.server.ts +++ b/app/features/availability/AvailabilityRepository.server.ts @@ -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; + 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; diff --git a/app/features/availability/availability-constants.ts b/app/features/availability/availability-constants.ts index d5ee24cde..4a35a6cca 100644 --- a/app/features/availability/availability-constants.ts +++ b/app/features/availability/availability-constants.ts @@ -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. */ diff --git a/app/features/availability/availability-types.ts b/app/features/availability/availability-types.ts index c96c7d662..9f3260947 100644 --- a/app/features/availability/availability-types.ts +++ b/app/features/availability/availability-types.ts @@ -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; +} diff --git a/app/features/availability/components/MySchedule.tsx b/app/features/availability/components/MySchedule.tsx index 67a39d9a0..f905873df 100644 --- a/app/features/availability/components/MySchedule.tsx +++ b/app/features/availability/components/MySchedule.tsx @@ -134,6 +134,11 @@ export function MySchedule({ data }: { data: MyScheduleData }) { ({ + date: commitment.date, + range: commitment.range, + name: commitment.name ?? t("schedule:commitment.scrim"), + }))} onChange={(value) => setWeeks( weeks.map((days, index) => (index === weekIndex ? value : days)), diff --git a/app/features/availability/components/WeekAvailabilityEditor.tsx b/app/features/availability/components/WeekAvailabilityEditor.tsx index 257ba3bf4..e45fb4f30 100644 --- a/app/features/availability/components/WeekAvailabilityEditor.tsx +++ b/app/features/availability/components/WeekAvailabilityEditor.tsx @@ -494,6 +494,7 @@ export function WeekAvailabilityEditor({ className={styles.commitment} style={barStyle(commitment.range)} title={commitment.name} + data-testid="availability-commitment" > {commitment.name} diff --git a/app/features/availability/core/Commitments.server.test.ts b/app/features/availability/core/Commitments.server.test.ts new file mode 100644 index 000000000..13dff666d --- /dev/null +++ b/app/features/availability/core/Commitments.server.test.ts @@ -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, +) { + 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(); +} diff --git a/app/features/availability/core/Commitments.server.ts b/app/features/availability/core/Commitments.server.ts new file mode 100644 index 000000000..72a3587c7 --- /dev/null +++ b/app/features/availability/core/Commitments.server.ts @@ -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; + startsAt: number; + endsAt: number; +}): Promise>> { + 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 = [ + ...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, + ), + ], + ), + ); +} diff --git a/app/features/availability/core/MySchedule.server.ts b/app/features/availability/core/MySchedule.server.ts index 9e29c953f..68da3c780 100644 --- a/app/features/availability/core/MySchedule.server.ts +++ b/app/features/availability/core/MySchedule.server.ts @@ -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, + })), }; } diff --git a/app/features/availability/core/TournamentDuration.test.ts b/app/features/availability/core/TournamentDuration.test.ts new file mode 100644 index 000000000..0632da44c --- /dev/null +++ b/app/features/availability/core/TournamentDuration.test.ts @@ -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 = [ + "double_elimination", +]; +const GROUPS_TO_TOP_CUT: Array = [ + "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); + } + } + } + }); +}); diff --git a/app/features/availability/core/TournamentDuration.ts b/app/features/availability/core/TournamentDuration.ts new file mode 100644 index 000000000..3dd5a777c --- /dev/null +++ b/app/features/availability/core/TournamentDuration.ts @@ -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; + 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; +} diff --git a/app/features/availability/loaders/t.$customUrl.schedule.server.ts b/app/features/availability/loaders/t.$customUrl.schedule.server.ts index eb832a01a..b1ba32e75 100644 --- a/app/features/availability/loaders/t.$customUrl.schedule.server.ts +++ b/app/features/availability/loaders/t.$customUrl.schedule.server.ts @@ -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; playerIds: Array; reportedWeeks: Array; + busyByUserId: Map>; }) { 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; range: TimeRange; + busy: Array; }) { + 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>, + days: days.map((day) => ({ + ranges: [] as Array, + 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( diff --git a/app/features/availability/routes/t.$customUrl.schedule.module.css b/app/features/availability/routes/t.$customUrl.schedule.module.css index 70bedbc6c..f439afb3b 100644 --- a/app/features/availability/routes/t.$customUrl.schedule.module.css +++ b/app/features/availability/routes/t.$customUrl.schedule.module.css @@ -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); } diff --git a/app/features/availability/routes/t.$customUrl.schedule.tsx b/app/features/availability/routes/t.$customUrl.schedule.tsx index 3dc15333c..d19407182 100644 --- a/app/features/availability/routes/t.$customUrl.schedule.tsx +++ b/app/features/availability/routes/t.$customUrl.schedule.tsx @@ -131,11 +131,11 @@ function ScheduleGrid({ week }: { week: WeekData }) { - {row.days.map((ranges, dayIndex) => ( + {row.days.map((day, 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 ( ? - ) : ranges.length === 0 ? ( + ) : day.ranges.length === 0 && day.busy.length === 0 ? ( ) : ( - ranges.map((range) => ( + day.ranges.map((range) => (
)) )} + {day.busy.map((block) => ( +
+ {busyName(block)} +
+ ))} {note ? ( diff --git a/app/features/scrims/ScrimPostRepository.server.ts b/app/features/scrims/ScrimPostRepository.server.ts index 38ef46241..e5c24662f 100644 --- a/app/features/scrims/ScrimPostRepository.server.ts +++ b/app/features/scrims/ScrimPostRepository.server.ts @@ -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; + startsAt: number; + endsAt: number; +}) { + if (userIds.length === 0) return []; + + const resolvedStartsAt = sql`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]. diff --git a/app/features/tournament/TournamentTeamRepository.server.ts b/app/features/tournament/TournamentTeamRepository.server.ts index 421c235b7..fc88966a3 100644 --- a/app/features/tournament/TournamentTeamRepository.server.ts +++ b/app/features/tournament/TournamentTeamRepository.server.ts @@ -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; + 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().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 diff --git a/e2e/events.spec.ts b/e2e/events.spec.ts index d033950ba..3e52a17c6 100644 --- a/e2e/events.spec.ts +++ b/e2e/events.spec.ts @@ -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, diff --git a/e2e/helpers/factories.ts b/e2e/helpers/factories.ts index 1c940ca21..6a5caff92 100644 --- a/e2e/helpers/factories.ts +++ b/e2e/helpers/factories.ts @@ -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( diff --git a/e2e/pages/calendar/events-page.ts b/e2e/pages/calendar/events-page.ts index b9934be73..974094a86 100644 --- a/e2e/pages/calendar/events-page.ts +++ b/e2e/pages/calendar/events-page.ts @@ -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"), diff --git a/e2e/pages/team/team-schedule-page.ts b/e2e/pages/team/team-schedule-page.ts index bfa9c2dd5..36a1e33b6 100644 --- a/e2e/pages/team/team-schedule-page.ts +++ b/e2e/pages/team/team-schedule-page.ts @@ -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}`); } diff --git a/e2e/team.spec.ts b/e2e/team.spec.ts index b8e6732f8..54e4eb440 100644 --- a/e2e/team.spec.ts +++ b/e2e/team.spec.ts @@ -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 diff --git a/locales/da/schedule.json b/locales/da/schedule.json index 6bb2bb555..94db1801f 100644 --- a/locales/da/schedule.json +++ b/locales/da/schedule.json @@ -1,4 +1,5 @@ { + "commitment.scrim": "", "editor.addTime": "", "editor.copyLastWeek": "", "editor.earlier": "", diff --git a/locales/de/schedule.json b/locales/de/schedule.json index 6bb2bb555..94db1801f 100644 --- a/locales/de/schedule.json +++ b/locales/de/schedule.json @@ -1,4 +1,5 @@ { + "commitment.scrim": "", "editor.addTime": "", "editor.copyLastWeek": "", "editor.earlier": "", diff --git a/locales/en/schedule.json b/locales/en/schedule.json index b7d8481a6..a9ecee4d0 100644 --- a/locales/en/schedule.json +++ b/locales/en/schedule.json @@ -1,4 +1,5 @@ { + "commitment.scrim": "Scrim", "editor.addTime": "Add time", "editor.copyLastWeek": "Copy last week", "editor.earlier": "Earlier", diff --git a/locales/es-ES/schedule.json b/locales/es-ES/schedule.json index 6bb2bb555..94db1801f 100644 --- a/locales/es-ES/schedule.json +++ b/locales/es-ES/schedule.json @@ -1,4 +1,5 @@ { + "commitment.scrim": "", "editor.addTime": "", "editor.copyLastWeek": "", "editor.earlier": "", diff --git a/locales/es-US/schedule.json b/locales/es-US/schedule.json index 6bb2bb555..94db1801f 100644 --- a/locales/es-US/schedule.json +++ b/locales/es-US/schedule.json @@ -1,4 +1,5 @@ { + "commitment.scrim": "", "editor.addTime": "", "editor.copyLastWeek": "", "editor.earlier": "", diff --git a/locales/fr-CA/schedule.json b/locales/fr-CA/schedule.json index 6bb2bb555..94db1801f 100644 --- a/locales/fr-CA/schedule.json +++ b/locales/fr-CA/schedule.json @@ -1,4 +1,5 @@ { + "commitment.scrim": "", "editor.addTime": "", "editor.copyLastWeek": "", "editor.earlier": "", diff --git a/locales/fr-EU/schedule.json b/locales/fr-EU/schedule.json index 6bb2bb555..94db1801f 100644 --- a/locales/fr-EU/schedule.json +++ b/locales/fr-EU/schedule.json @@ -1,4 +1,5 @@ { + "commitment.scrim": "", "editor.addTime": "", "editor.copyLastWeek": "", "editor.earlier": "", diff --git a/locales/he/schedule.json b/locales/he/schedule.json index 6bb2bb555..94db1801f 100644 --- a/locales/he/schedule.json +++ b/locales/he/schedule.json @@ -1,4 +1,5 @@ { + "commitment.scrim": "", "editor.addTime": "", "editor.copyLastWeek": "", "editor.earlier": "", diff --git a/locales/it/schedule.json b/locales/it/schedule.json index 6bb2bb555..94db1801f 100644 --- a/locales/it/schedule.json +++ b/locales/it/schedule.json @@ -1,4 +1,5 @@ { + "commitment.scrim": "", "editor.addTime": "", "editor.copyLastWeek": "", "editor.earlier": "", diff --git a/locales/ja/schedule.json b/locales/ja/schedule.json index 6bb2bb555..94db1801f 100644 --- a/locales/ja/schedule.json +++ b/locales/ja/schedule.json @@ -1,4 +1,5 @@ { + "commitment.scrim": "", "editor.addTime": "", "editor.copyLastWeek": "", "editor.earlier": "", diff --git a/locales/ko/schedule.json b/locales/ko/schedule.json index 6bb2bb555..94db1801f 100644 --- a/locales/ko/schedule.json +++ b/locales/ko/schedule.json @@ -1,4 +1,5 @@ { + "commitment.scrim": "", "editor.addTime": "", "editor.copyLastWeek": "", "editor.earlier": "", diff --git a/locales/nl/schedule.json b/locales/nl/schedule.json index 6bb2bb555..94db1801f 100644 --- a/locales/nl/schedule.json +++ b/locales/nl/schedule.json @@ -1,4 +1,5 @@ { + "commitment.scrim": "", "editor.addTime": "", "editor.copyLastWeek": "", "editor.earlier": "", diff --git a/locales/pl/schedule.json b/locales/pl/schedule.json index 6bb2bb555..94db1801f 100644 --- a/locales/pl/schedule.json +++ b/locales/pl/schedule.json @@ -1,4 +1,5 @@ { + "commitment.scrim": "", "editor.addTime": "", "editor.copyLastWeek": "", "editor.earlier": "", diff --git a/locales/pt-BR/schedule.json b/locales/pt-BR/schedule.json index 6bb2bb555..94db1801f 100644 --- a/locales/pt-BR/schedule.json +++ b/locales/pt-BR/schedule.json @@ -1,4 +1,5 @@ { + "commitment.scrim": "", "editor.addTime": "", "editor.copyLastWeek": "", "editor.earlier": "", diff --git a/locales/ru/schedule.json b/locales/ru/schedule.json index 6bb2bb555..94db1801f 100644 --- a/locales/ru/schedule.json +++ b/locales/ru/schedule.json @@ -1,4 +1,5 @@ { + "commitment.scrim": "", "editor.addTime": "", "editor.copyLastWeek": "", "editor.earlier": "", diff --git a/locales/zh/schedule.json b/locales/zh/schedule.json index 6bb2bb555..94db1801f 100644 --- a/locales/zh/schedule.json +++ b/locales/zh/schedule.json @@ -1,4 +1,5 @@ { + "commitment.scrim": "", "editor.addTime": "", "editor.copyLastWeek": "", "editor.earlier": "",