diff --git a/AGENTS.md b/AGENTS.md index da9851cbe..7a97414fb 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,7 +1,8 @@ ## General - only rarely use comments, prefer descriptive variable and function names (leave existing comments as is). -- if you encounter an existing TODO comment assume it is there for a reason and do not remove it +- if you encounter an existing TODO or xxx comment assume it is there for a reason and do not remove it unless you specifically addressed what the comment is about +- when a comment is needed, brevity is the key, less is more - task is not considered completely until `pnpm run checks` passes - normal file structure has constants at the top immediately followed by the main function body of the file. Helpers are used to structure the code and they are at the bottom of the file (main implementation first, at the top of the file) - note: any formatting issue (such as tabs vs. spaces) can be resolved by running the `pnpm run biome:fix` command diff --git a/app/components/InviteLinkInput.module.css b/app/components/InviteLinkInput.module.css new file mode 100644 index 000000000..cceca9c07 --- /dev/null +++ b/app/components/InviteLinkInput.module.css @@ -0,0 +1,10 @@ +.row { + display: flex; + align-items: center; + gap: var(--s-2); + + & input { + flex: 1; + min-width: 0; + } +} diff --git a/app/components/InviteLinkInput.tsx b/app/components/InviteLinkInput.tsx new file mode 100644 index 000000000..c9e1a5fb8 --- /dev/null +++ b/app/components/InviteLinkInput.tsx @@ -0,0 +1,45 @@ +import { Check, Clipboard } from "lucide-react"; +import * as React from "react"; +import { useTranslation } from "react-i18next"; +import { SendouButton } from "~/components/elements/Button"; +import { Label } from "~/components/Label"; +import { useCopyToClipboard } from "~/hooks/useCopyToClipboard"; +import styles from "./InviteLinkInput.module.css"; + +/** A labeled read-only invite link with a copy to clipboard button. */ +export function InviteLinkInput({ + link, + label, +}: { + link: string; + /** Overrides the default "Invite link" label. */ + label?: string; +}) { + const { t } = useTranslation(["common"]); + const id = React.useId(); + const { copyToClipboard, copySuccess } = useCopyToClipboard(); + + return ( +
+ +
+ e.currentTarget.select()} + data-testid="invite-link-input" + /> + copyToClipboard(link)} + icon={copySuccess ? : } + aria-label={t("common:actions.copyToClipboard")} + data-testid="copy-invite-link-button" + /> +
+
+ ); +} diff --git a/app/components/MobileNav.tsx b/app/components/MobileNav.tsx index 889218c67..e90e880b1 100644 --- a/app/components/MobileNav.tsx +++ b/app/components/MobileNav.tsx @@ -17,6 +17,7 @@ import { Dialog, Modal, ModalOverlay } from "react-aria-components"; import { useTranslation } from "react-i18next"; import { Link, useLocation } from "react-router"; import { useUser } from "~/features/auth/core/user"; +import { ScheduleNudge } from "~/features/availability/components/ScheduleNudge"; import { useChatContext } from "~/features/chat/ChatProvider"; import { FriendMenu } from "~/features/friends/components/FriendMenu"; import { SENDOUQ_ACTIVITY_LABEL } from "~/features/friends/friends-constants"; @@ -127,6 +128,7 @@ export function MobileNav({ sidebarData }: { sidebarData: SidebarData }) { {activePanel === "tourneys" ? ( ["events"]; + showScheduleNudge: boolean; onClose: () => void; onTabPress: (panel: PanelType) => void; isLoggedIn: boolean; @@ -525,6 +529,7 @@ function TourneysPanel({ isLoggedIn={isLoggedIn} skipAnimation={skipAnimation} > + {showScheduleNudge ? : null} void; triggerRef: React.RefObject; + "aria-label"?: string; }) { return ( - {children} + + {children} + ); } diff --git a/app/components/elements/Select.module.css b/app/components/elements/Select.module.css index 45bc325d0..a5210a0cf 100644 --- a/app/components/elements/Select.module.css +++ b/app/components/elements/Select.module.css @@ -9,6 +9,11 @@ &[data-placeholder] { color: var(--color-text-high); } + + /* two-line items render only their label line inside the trigger */ + & [slot="description"] { + display: none; + } } .item { diff --git a/app/components/elements/SelectShell.module.css b/app/components/elements/SelectShell.module.css index 104d1a5b6..05ca5989b 100644 --- a/app/components/elements/SelectShell.module.css +++ b/app/components/elements/SelectShell.module.css @@ -12,6 +12,7 @@ gap: var(--s-1-5); width: 100%; cursor: pointer; + text-align: start; &[data-focus-visible], &[aria-expanded="true"] { @@ -44,6 +45,9 @@ display: flex; flex-direction: column; + + /* virtualized lists size from their container, so the popover cannot size from content */ + min-width: var(--trigger-width); } .listBox { @@ -51,6 +55,11 @@ flex: 1; } +.item { + cursor: pointer; + outline: none; +} + .itemFocused { background-color: var(--color-bg-high); color: var(--color-text); diff --git a/app/components/elements/SelectShell.tsx b/app/components/elements/SelectShell.tsx index 91a9a8b30..cb60fbdb4 100644 --- a/app/components/elements/SelectShell.tsx +++ b/app/components/elements/SelectShell.tsx @@ -116,7 +116,7 @@ export function SelectShellItem({ - clsx(className, { + clsx(className, styles.item, { [styles.itemFocused]: isFocused, [styles.itemSelected]: isSelected, }) diff --git a/app/components/layout/index.tsx b/app/components/layout/index.tsx index 7c46588ae..aa09d9eaf 100644 --- a/app/components/layout/index.tsx +++ b/app/components/layout/index.tsx @@ -21,6 +21,7 @@ import { useTranslation } from "react-i18next"; import { Link, useFetcher, useLocation, useMatches } from "react-router"; import { Config } from "~/config"; import { useUser } from "~/features/auth/core/user"; +import { ScheduleNudge } from "~/features/availability/components/ScheduleNudge"; import { useChatContext } from "~/features/chat/ChatProvider"; import { FriendMenu } from "~/features/friends/components/FriendMenu"; import { useLayoutData } from "~/features/layout/LayoutDataProvider"; @@ -277,6 +278,7 @@ export function Layout({ sidebarData?.incomingFriendRequestIds ?? [], ); const streams = sidebarData?.streams ?? []; + const showScheduleNudge = sidebarData?.scheduleNudge ?? false; const isFrontPage = location.pathname === "/"; @@ -306,6 +308,7 @@ export function Layout({ > {t("front:sideNav.myCalendar")} + {showScheduleNudge ? : null} {events.length > 0 ? ( events.map((event) => ( ; + +/** Ranges of a week, Monday first. A day with no ranges is one the user is not available on. */ +type WeekSchedule = [ + DaySchedule, + DaySchedule, + DaySchedule, + DaySchedule, + DaySchedule, + DaySchedule, + DaySchedule, +]; + +type SeededSchedule = { + userId: number; + timezone: string; + weekly: WeekSchedule; + /** Notes of the week, keyed by the day of it they are on. */ + notes?: Record; + /** Whether next week is reported too. Everybody but the admin fills it in, so that the admin has the "next week is empty" nudge waiting for them. */ + fillsNextWeek?: boolean; +}; + +const EMPTY_WEEK: WeekSchedule = [[], [], [], [], [], [], []]; + +const EVENINGS: WeekSchedule = [ + [["18:00", "22:00"]], + [["18:00", "22:00"]], + [["19:00", "23:00"]], + [["18:00", "22:00"]], + [], + [["12:00", "22:00"]], + [["12:00", "18:00"]], +]; + +/** + * Availability of the admin's team, their friends and a stranger, for this week + * and the next. Every state the schedule surfaces can be in is on the admin's + * team: a filled week, a week submitted as unavailable, a week nobody reported, + * ranges crossing midnight, day notes, and ranges a tournament or a booked scrim + * takes back. + */ +export async function seedAvailability({ + users, + teams, + tournaments, + scrims, + misc, +}: { + users: SeededUsers; + teams: SeededTeams; + tournaments: SeededTournaments; + scrims: SeededScrims; + misc: SeededMisc; +}) { + const now = new Date(); + const [, multiRangeId, crossMidnightId, unavailableId, weekendId] = + teams.allianceRogue.playerUserIds; + + // the tournament and the scrim the admin's team is committed to, with room + // around them so that the commitment visibly takes availability back + const commitments = [ + { + userId: users.adminId, + startsAt: scrims.accepted.startsAt - HOUR, + endsAt: scrims.accepted.startsAt + 2 * HOUR, + }, + // registration availability of the reg open tournament: fully available, + // available from an hour in, and not available at all + { + userId: users.adminId, + startsAt: tournaments.regOpen.startsAt - HOUR, + endsAt: tournaments.regOpen.startsAt + 4 * HOUR, + }, + { + userId: multiRangeId, + startsAt: tournaments.regOpen.startsAt - HOUR, + endsAt: tournaments.regOpen.startsAt + 4 * HOUR, + }, + { + userId: weekendId, + startsAt: tournaments.regOpen.startsAt + HOUR, + endsAt: tournaments.regOpen.startsAt + 4 * HOUR, + }, + ]; + + const schedules: Array = [ + // N-ZAP reports nothing at all, so that they are the one the Monday + // reminder routine has something to say to + { + userId: users.adminId, + timezone: "Europe/Helsinki", + weekly: EVENINGS, + }, + { + userId: multiRangeId, + timezone: "Europe/Stockholm", + weekly: [ + [["17:00", "22:00"]], + [["17:00", "22:00"]], + [ + ["13:00", "15:00"], + ["18:00", "22:00"], + ], + [["17:00", "22:00"]], + [["17:00", "22:00"]], + [["09:00", "21:00"]], + [], + ], + notes: { + 2: "Have to stop earlier, work trip next morning", + 5: "Can play all day", + }, + fillsNextWeek: true, + }, + { + userId: crossMidnightId, + timezone: "Europe/London", + weekly: [ + [["16:00", "20:00"]], + [["16:00", "19:00"]], + [], + [["16:00", "20:00"]], + [], + [["22:00", "02:00"]], + [], + ], + fillsNextWeek: true, + }, + { + userId: unavailableId, + timezone: "Europe/Helsinki", + weekly: EMPTY_WEEK, + fillsNextWeek: true, + }, + { + userId: weekendId, + timezone: "Europe/Helsinki", + weekly: [ + [["18:00", "22:00"]], + [], + [["19:00", "22:00"]], + [["19:00", "22:00"]], + [], + [["12:00", "20:00"]], + [], + ], + fillsNextWeek: true, + }, + { + userId: teams.allianceRogue.subUserId, + timezone: "Europe/Helsinki", + // Wednesday ends exactly at midnight, the shape the drag editor + // produces when a bar is pulled to the 00:00 tick + weekly: [[], [["18:00", "22:00"]], [["18:00", "00:00"]], [], [], [], []], + fillsNextWeek: true, + }, + { + userId: teams.allianceRogue.coachUserId, + timezone: "America/Los_Angeles", + weekly: [ + [["09:00", "13:00"]], + [], + [["09:00", "13:00"]], + [], + [], + [["15:00", "19:00"]], + [], + ], + fillsNextWeek: true, + }, + // the last of the admin's friends reports nothing, so the friends page has + // a row with no schedule to sort below the ones that have one + ...misc.adminFriendIds.slice(0, -1).map((userId, index) => ({ + userId, + timezone: "Europe/Helsinki", + weekly: EVENINGS, + fillsNextWeek: index > 0, + })), + { + userId: users.showcaseIds[STRANGER_SHOWCASE_INDEX], + timezone: "Europe/Helsinki", + weekly: EVENINGS, + fillsNextWeek: true, + }, + ]; + + // the friends the admin could ask to sub are free when the tournament runs + for (const friendId of misc.adminFriendIds.slice(0, 2)) { + commitments.push({ + userId: friendId, + startsAt: tournaments.regOpen.startsAt - HOUR, + endsAt: tournaments.regOpen.startsAt + 4 * HOUR, + }); + } + + for (const schedule of schedules) { + const dates = schedule.fillsNextWeek ? [now, addWeeks(now, 1)] : [now]; + + for (const date of dates) { + await seedWeek({ schedule, date, commitments }); + } + } + + // a week the cleanup routine has a reason to delete + await seedWeek({ + schedule: { + userId: multiRangeId, + timezone: "Europe/Stockholm", + weekly: EVENINGS, + }, + date: subMonths(now, OLD_WEEK_MONTHS), + commitments: [], + }); + + await seedTeamEvents({ users, teams, now }); +} + +async function seedWeek({ + schedule, + date, + commitments, +}: { + schedule: SeededSchedule; + date: Date; + commitments: Array; +}) { + const { startsAt: weekStartsAt, endsAt: weekEndsAt } = Availability.weekRange( + date, + schedule.timezone, + ); + const dates = datesOfWeek(weekStartsAt, schedule.timezone); + + const slots = schedule.weekly.flatMap((day, dayIndex) => + day.map(([start, end]) => ({ + startsAt: Availability.localToTimestamp({ + date: dates[dayIndex], + time: start, + timezone: schedule.timezone, + }), + endsAt: Availability.localToTimestamp({ + date: end <= start ? dates[dayIndex + 1] : dates[dayIndex], + time: end, + timezone: schedule.timezone, + }), + })), + ); + + const commitmentSlots = commitments.filter( + (commitment) => + commitment.userId === schedule.userId && + commitment.startsAt >= weekStartsAt && + commitment.startsAt < weekEndsAt, + ); + + await AvailabilityWeekFactory.create({ + userId: schedule.userId, + weekStartsAt, + timezone: schedule.timezone, + slots: Availability.normalize([...slots, ...commitmentSlots]), + dayNotes: Object.entries(schedule.notes ?? {}).map(([dayIndex, text]) => ({ + date: dates[Number(dayIndex)], + text, + })), + }); +} + +async function seedTeamEvents({ + users, + teams, + now, +}: { + users: SeededUsers; + teams: SeededTeams; + now: Date; +}) { + const timezone = "Europe/Helsinki"; + const events = [ + { + date: now, + name: "VoD review vs. FTWin", + day: 1, + start: "20:00", + end: "21:30", + }, + { + date: addWeeks(now, 1), + name: "Team meeting", + day: 2, + start: "19:00", + end: "20:00", + }, + ]; + + for (const event of events) { + const { startsAt: weekStartsAt } = Availability.weekRange( + event.date, + timezone, + ); + const dates = datesOfWeek(weekStartsAt, timezone); + + await TeamEventFactory.create({ + teamId: teams.allianceRogueId, + // N-ZAP owns the team, so they are who can add an event to it + authorId: users.nzapId, + name: event.name, + startsAt: Availability.localToTimestamp({ + date: dates[event.day], + time: event.start, + timezone, + }), + endsAt: Availability.localToTimestamp({ + date: dates[event.day], + time: event.end, + timezone, + }), + }); + } +} + +/** The eight dates a week's days can fall on, the Monday after it included so that a range crossing midnight has one. */ +function datesOfWeek(weekStartsAt: number, timezone: string) { + const monday = new Date( + `${Availability.dateInTimezone(weekStartsAt + 12 * HOUR, timezone)}T12:00:00Z`, + ); + + return Array.from({ length: 8 }, (_, index) => + dateToYYYYMMDD(addDays(monday, index)), + ); +} diff --git a/app/db/seed/dev/misc.ts b/app/db/seed/dev/misc.ts index c84b6421a..a53c7cf7e 100644 --- a/app/db/seed/dev/misc.ts +++ b/app/db/seed/dev/misc.ts @@ -24,8 +24,15 @@ import type { SeededUsers } from "./users"; const NZAP_PLAYER_SPL_ID = "qx6imlx72tfeqrhqfnmm"; const FRIEND_COUNT = 8; +/** Friends of the admin, none of them a teammate, so that friends-only surfaces have something to show. */ +const ADMIN_FRIEND_COUNT = 3; const STREAM_COUNT = 20; +export type SeededMisc = { + /** The admin's friends, who are none of them their teammate. */ + adminFriendIds: number[]; +}; + export async function seedMisc({ users, sendouq, @@ -34,7 +41,7 @@ export async function seedMisc({ users: SeededUsers; sendouq: SeededSendouQ; tournaments: SeededTournaments; -}) { +}): Promise { await seedXRankPlacements(users); await seedArts(users); await seedFriends(users); @@ -45,6 +52,8 @@ export async function seedMisc({ users.showcaseIds.slice(0, STREAM_COUNT).map((userId) => ({ userId })), ); await SplatoonRotationFactory.replaceAll(); + + return { adminFriendIds: adminFriendIds(users) }; } async function seedXRankPlacements(users: SeededUsers) { @@ -123,6 +132,21 @@ async function seedFriends(users: SeededUsers) { senderId: users.showcaseIds[FRIEND_COUNT], receiverId: users.nzapId, }); + + for (const friendId of adminFriendIds(users)) { + await FriendshipFactory.create({ + userOneId: users.adminId, + userTwoId: friendId, + }); + } +} + +/** Showcase users befriending the admin, taken from past the ones N-ZAP's friendships and friend request use. */ +function adminFriendIds(users: SeededUsers) { + return users.showcaseIds.slice( + FRIEND_COUNT + 1, + FRIEND_COUNT + 1 + ADMIN_FRIEND_COUNT, + ); } async function seedNotifications( @@ -165,6 +189,14 @@ async function seedNotifications( }, { type: "SQ_ADDED_TO_GROUP", meta: { adderUsername: "N-ZAP" } }, { type: "SQ_NEW_MATCH", meta: { matchId: 100 } }, + { + type: "TEAM_EVENT_ADDED", + meta: { + eventName: "VoD review vs. FTWin", + teamName: "Alliance Rogue", + teamCustomUrl: "alliance-rogue", + }, + }, { type: "PLUS_VOTING_STARTED", meta: { seasonNth: 1 } }, { type: "TO_CHECK_IN_OPENED", diff --git a/app/db/seed/dev/scrims-lfg.ts b/app/db/seed/dev/scrims-lfg.ts index 3c3bab61f..23df5d653 100644 --- a/app/db/seed/dev/scrims-lfg.ts +++ b/app/db/seed/dev/scrims-lfg.ts @@ -8,14 +8,24 @@ import * as ScrimPostFactory from "../factories/ScrimPostFactory"; import type { SeededTeams } from "./teams"; import type { SeededUsers } from "./users"; +export type SeededScrims = { + /** The booked scrim of the admin's and N-ZAP's rosters, a commitment their availability has to give way to. */ + accepted: { startsAt: number; userIds: number[] }; +}; + const SCRIM_POST_COUNT = 20; const LFG_POST_COUNT = 9; const ASSOCIATION_COUNT = 3; -export async function seedScrimsAndLFG(users: SeededUsers, teams: SeededTeams) { - await seedScrimPosts(users, teams); +export async function seedScrimsAndLFG( + users: SeededUsers, + teams: SeededTeams, +): Promise { + const accepted = await seedScrimPosts(users, teams); await seedLFGPosts(users, teams); await seedAssociations(users); + + return { accepted }; } async function seedScrimPosts(users: SeededUsers, teams: SeededTeams) { @@ -32,20 +42,26 @@ async function seedScrimPosts(users: SeededUsers, teams: SeededTeams) { }; // an accepted scrim between the admin's and N-ZAP's rosters + const acceptedStartsAt = dateToDatabaseTimestamp( + add(new Date(), { hours: 2 }), + ); + const acceptedPostUsers = [ + { userId: users.adminId, isOwner: 1 as const }, + ...takeUsers(3), + ]; + const acceptedRequestUsers = [ + { userId: users.nzapId, isOwner: 1 as const }, + ...takeUsers(3), + ]; await ScrimPostFactory.create( { - startsAt: dateToDatabaseTimestamp(add(new Date(), { hours: 2 })), + startsAt: acceptedStartsAt, isScheduledForFuture: true, managedByAnyone: true, - users: [{ userId: users.adminId, isOwner: 1 }, ...takeUsers(3)], + users: acceptedPostUsers, }, { - requests: [ - { - users: [{ userId: users.nzapId, isOwner: 1 }, ...takeUsers(3)], - isAccepted: true, - }, - ], + requests: [{ users: acceptedRequestUsers, isAccepted: true }], }, ); @@ -81,6 +97,13 @@ async function seedScrimPosts(users: SeededUsers, teams: SeededTeams) { }, ); } + + return { + startsAt: acceptedStartsAt, + userIds: [...acceptedPostUsers, ...acceptedRequestUsers].map( + (user) => user.userId, + ), + }; } async function seedLFGPosts(users: SeededUsers, teams: SeededTeams) { diff --git a/app/db/seed/dev/teams.ts b/app/db/seed/dev/teams.ts index 3eeaaa79e..627e9de6d 100644 --- a/app/db/seed/dev/teams.ts +++ b/app/db/seed/dev/teams.ts @@ -8,6 +8,12 @@ const SECONDARY_TEAM_COUNT = 10; export type SeededTeams = { allianceRogueId: number; + /** Alliance Rogue's roster by the part each member plays. The admin and N-ZAP are both on it, so that logging in as either shows a team with a full roster. */ + allianceRogue: { + playerUserIds: number[]; + subUserId: number; + coachUserId: number; + }; ids: number[]; /** Four members of a shared team, e.g. a lineup for the SQ team leaderboard. */ squads: Array<{ teamId: number; name: string; memberUserIds: number[] }>; @@ -23,12 +29,26 @@ export async function seedTeams(users: SeededUsers): Promise { return members; }; + const allianceRoguePlayers = [users.nzapId, ...takeMembers(4), users.adminId]; + const [allianceRogueSubId, allianceRogueCoachId] = takeMembers(2); const allianceRogue = await TeamFactory.create( { name: "Alliance Rogue", - memberUserIds: [users.nzapId, ...takeMembers(4)], + memberUserIds: [ + ...allianceRoguePlayers, + allianceRogueSubId, + allianceRogueCoachId, + ], + }, + { + avatarUrl: "alliance-rogue.png", + roles: { + [users.nzapId]: "CAPTAIN", + [users.adminId]: "FLEX", + [allianceRogueSubId]: "SUB", + [allianceRogueCoachId]: "COACH", + }, }, - { avatarUrl: "alliance-rogue.png" }, ); const ids: number[] = [allianceRogue.id]; @@ -77,5 +97,14 @@ export async function seedTeams(users: SeededUsers): Promise { ids.push(team.id); } - return { allianceRogueId: allianceRogue.id, ids, squads }; + return { + allianceRogueId: allianceRogue.id, + allianceRogue: { + playerUserIds: allianceRoguePlayers, + subUserId: allianceRogueSubId, + coachUserId: allianceRogueCoachId, + }, + ids, + squads, + }; } diff --git a/app/db/seed/dev/tournaments.ts b/app/db/seed/dev/tournaments.ts index 12fc74073..89ce9c1d4 100644 --- a/app/db/seed/dev/tournaments.ts +++ b/app/db/seed/dev/tournaments.ts @@ -121,7 +121,13 @@ const SWISS_TO_SINGLE_ELIMINATION: Progression = [ export type SeededTournaments = { /** The one with registration still open, which the notifications are about. */ - regOpen: { id: number; name: string }; + regOpen: { + id: number; + name: string; + startsAt: number; + /** The roster the admin registered on. */ + memberUserIds: number[]; + }; /** Teams N-ZAP played on in the tournaments that were played to the end. */ nzapTeamIds: number[]; }; @@ -145,6 +151,7 @@ export async function seedTournaments({ users, organizations, rosters, + teams, trophies, }); await seedPaddlingPool({ users, organizations, rosters }); @@ -169,21 +176,25 @@ type Ctx = { }; /** #1 double elim, TO maps — reg open and a couple of days out, so it has both - * registered teams (some of them still short of a full roster) and LFG teams. */ + * registered teams (some of them still short of a full roster) and LFG teams. + * The admin registers with Alliance Rogue on a roster whose availability mixes + * every state the registration page's panel can show. */ async function seedInTheZone({ users, organizations, rosters, + teams, trophies, -}: Ctx & { trophies: SeededTrophies }) { +}: Ctx & { teams: SeededTeams; trophies: SeededTrophies }) { const name = nameFor("In The Zone"); + const startsAt = dateToDatabaseTimestamp(daysFromNow(2)); const tournament = await TournamentFactory.create({ name, authorId: users.adminId, organizationId: organizations[0]?.id, avatarFileName: "in-the-zone.png", - startTimes: [dateToDatabaseTimestamp(daysFromNow(2))], + startTimes: [startsAt], mapPickingStyle: "TO", mapPoolMaps: toSetMapPool(), bracketProgression: DOUBLE_ELIMINATION, @@ -191,10 +202,28 @@ async function seedInTheZone({ trophyId: trophies.ids[0], }); + // availability panel states, in roster order: the admin and multiRange are + // fully available, weekend is free only from an hour in, unavailable + // submitted an empty week and the captain (N-ZAP) reports nothing at all + const [, multiRangeId, , unavailableId, weekendId] = + teams.allianceRogue.playerUserIds; + const allianceRogueRoster: Roster = { + teamId: teams.allianceRogueId, + name: teams.squads.find((squad) => squad.teamId === teams.allianceRogueId)! + .name, + memberUserIds: [ + users.adminId, + multiRangeId, + weekendId, + unavailableId, + users.nzapId, + ], + }; + const teamRosters = rosters.take({ teamCount: 10, teamSize: 4, - pinned: [{ teamIdx: 0, userId: users.adminId }], + preset: [allianceRogueRoster], }); for (const [i, roster] of teamRosters.entries()) { @@ -210,7 +239,12 @@ async function seedInTheZone({ await seedTournamentExtras(tournament.id, users); - return { id: tournament.id, name }; + return { + id: tournament.id, + name, + startsAt, + memberUserIds: teamRosters[0].memberUserIds, + }; } /** #2 double elim with an underground bracket, AUTO_SZ, ranked — bracket started, @@ -417,7 +451,9 @@ async function seedTournamentExtras(tournamentId: number, users: SeededUsers) { await TournamentStreamerFactory.create({ tournamentId, twitchAccount }); } - const lfgUserIds = [users.nzapId, ...users.showcaseIds.slice(90, 95)]; + // N-ZAP used to be the demo LFG poster, but he registers with Alliance + // Rogue now — a player cannot both be on a team and look for one + const lfgUserIds = users.showcaseIds.slice(90, 96); const lfgTeamIds: number[] = []; for (const [i, userId] of lfgUserIds.entries()) { @@ -474,17 +510,24 @@ function rosterBuilder(users: SeededUsers, teams: SeededTeams) { /** Rosters for one tournament: some of the site's teams registering as * themselves, core players spread over the rest, and the remaining seats drawn * without replacement within the tournament. A `pinned` user is added to a - * roster of their own as its owner, and kept out of everybody else's. */ + * roster of their own as its owner, and kept out of everybody else's. A + * `preset` roster takes the first team slots exactly as given, its members + * kept out of every other roster. */ take({ teamCount, teamSize, pinned = [], + preset = [], }: { teamCount: number; teamSize: number; pinned?: Array<{ teamIdx: number; userId: number }>; + preset?: Roster[]; }): Roster[] { - const pinnedUserIds = new Set(pinned.map((pin) => pin.userId)); + const pinnedUserIds = new Set([ + ...pinned.map((pin) => pin.userId), + ...preset.flatMap((roster) => roster.memberUserIds), + ]); const registering = faker.helpers .shuffle( teams.squads.filter((squad) => @@ -494,7 +537,10 @@ function rosterBuilder(users: SeededUsers, teams: SeededTeams) { .slice(0, Math.round(teamCount * REGISTERED_TEAM_SHARE)); // a tournament can not have two teams of the same name - const takenNames = new Set(registering.map((squad) => squad.name)); + const takenNames = new Set([ + ...registering.map((squad) => squad.name), + ...preset.map((roster) => roster.name), + ]); const takenUserIds = new Set([ ...pinnedUserIds, @@ -505,13 +551,16 @@ function rosterBuilder(users: SeededUsers, teams: SeededTeams) { const shuffled = faker.helpers.shuffle(pool.filter(isFree)); const freeCorePlayers = corePlayers.filter(isFree); - // the teams of the site take the first team slots a pin does not want + // the teams of the site take the first team slots a preset or a pin + // does not want const pinnedIdxs = new Set(pinned.map((pin) => pin.teamIdx)); const registeringIdxs = Array.from({ length: teamCount }, (_, i) => i) - .filter((i) => !pinnedIdxs.has(i)) + .filter((i) => !pinnedIdxs.has(i) && i >= preset.length) .slice(0, registering.length); return Array.from({ length: teamCount }, (_, i) => { + if (i < preset.length) return preset[i]; + const registeringIdx = registeringIdxs.indexOf(i); if (registeringIdx !== -1) { const squad = registering[registeringIdx]; diff --git a/app/db/seed/factories/AvailabilityWeekFactory.ts b/app/db/seed/factories/AvailabilityWeekFactory.ts new file mode 100644 index 000000000..ce032c492 --- /dev/null +++ b/app/db/seed/factories/AvailabilityWeekFactory.ts @@ -0,0 +1,25 @@ +import * as AvailabilityRepository from "~/features/availability/AvailabilityRepository.server"; +import { actAs } from "../core/actAs"; +import { defineFactory } from "../core/defineFactory"; + +type InsertArgs = Parameters[0] & { + /** User whose week this is, saving it as they would themselves. */ + userId: number; +}; + +/** + * Creates the availability one user reported for one week. Slots and day notes + * are absolute, so a range crossing midnight is given as one slot like any other. + * A week with no slots is the "unavailable all week" a user submits. + */ +export const { create } = defineFactory({ + defaults: () => ({ + timezone: "Europe/Helsinki", + slots: [], + dayNotes: [], + }), + insert: async ({ userId, ...args }: InsertArgs) => ({ + id: await actAs(userId, () => AvailabilityRepository.upsertOwnWeek(args)), + userId, + }), +}); diff --git a/app/db/seed/factories/TeamEventFactory.ts b/app/db/seed/factories/TeamEventFactory.ts new file mode 100644 index 000000000..9be0f60c3 --- /dev/null +++ b/app/db/seed/factories/TeamEventFactory.ts @@ -0,0 +1,22 @@ +import * as AvailabilityRepository from "~/features/availability/AvailabilityRepository.server"; +import { actAs } from "../core/actAs"; +import { defineFactory } from "../core/defineFactory"; + +type InsertArgs = Parameters< + typeof AvailabilityRepository.insertTeamEvent +>[0] & { + /** Team member creating the event, the way a manager does in production. */ + authorId: number; +}; + +/** Creates events a team takes part in together, e.g. a VoD review. */ +export const { create } = defineFactory({ + defaults: ({ seq }) => ({ + name: `Team event ${seq}`, + }), + insert: async ({ authorId, ...args }: InsertArgs) => ({ + id: await actAs(authorId, () => + AvailabilityRepository.insertTeamEvent(args), + ), + }), +}); diff --git a/app/db/seed/factories/TeamFactory.ts b/app/db/seed/factories/TeamFactory.ts index fe1258035..ff7d02318 100644 --- a/app/db/seed/factories/TeamFactory.ts +++ b/app/db/seed/factories/TeamFactory.ts @@ -1,6 +1,6 @@ import type { UserMapModePreferences } from "~/db/tables-json"; import * as TeamRepository from "~/features/team/TeamRepository.server"; -import { TEAM } from "~/features/team/team-constants"; +import { type MemberRole, TEAM } from "~/features/team/team-constants"; import invariant from "~/utils/invariant"; import { actAs } from "../core/actAs"; import { defineFactory } from "../core/defineFactory"; @@ -21,6 +21,8 @@ type Options = { avatarUrl?: string; /** SendouQ map & mode preferences, saved as the team edit page saves them. */ mapModePreferences?: UserMapModePreferences; + /** Roles of the members, keyed by user id, saved as the roster page saves them. Members left out keep none. */ + roles?: Record; }; /** @@ -53,8 +55,23 @@ export const { create } = defineFactory({ }, applyOptions: async ( team, - { hasAvatar, avatarUrl, mapModePreferences }: Options, + { hasAvatar, avatarUrl, mapModePreferences, roles }: Options, ) => { + if (roles) { + await TeamRepository.updateRoster({ + teamId: team.id, + members: team.memberUserIds.map((userId, index) => ({ + userId, + role: roles[userId] ?? null, + customRole: null, + roleType: null, + isManager: false, + order: index, + })), + kickedUserIds: [], + }); + } + if (mapModePreferences) { await TeamRepository.updateMapModePreferences({ id: team.id, diff --git a/app/db/seed/index.ts b/app/db/seed/index.ts index 2b8d34709..0d6f389a9 100644 --- a/app/db/seed/index.ts +++ b/app/db/seed/index.ts @@ -3,6 +3,7 @@ import { clearAllTournamentDataCache } from "~/features/tournament-bracket/core/ import { withoutInfoLogs } from "~/utils/logger"; import { resetFactories } from "./core/defineFactory"; import { resetFaker } from "./core/faker"; +import { seedAvailability } from "./dev/availability"; import { seedBadges } from "./dev/badges"; import { seedBuilds } from "./dev/builds"; import { seedCalendarEvents } from "./dev/calendar"; @@ -41,10 +42,13 @@ export async function seed() { const sendouq = await runModule(() => seedSendouQ(users, teams)); await runModule(() => seedPlus(users)); await runModule(() => seedBuilds(users)); - await runModule(() => seedScrimsAndLFG(users, teams)); + const scrims = await runModule(() => seedScrimsAndLFG(users, teams)); await runModule(() => seedVods(users)); - await runModule(() => seedMisc({ users, sendouq, tournaments })); + const misc = await runModule(() => seedMisc({ users, sendouq, tournaments })); await runModule(() => seedSpecialTrophies()); + await runModule(() => + seedAvailability({ users, teams, tournaments, scrims, misc }), + ); clearAllTournamentDataCache(); } diff --git a/app/db/tables-json.ts b/app/db/tables-json.ts index 8412a0f4f..41d711083 100644 --- a/app/db/tables-json.ts +++ b/app/db/tables-json.ts @@ -67,6 +67,8 @@ export interface UserPreferences { /** Is spoiler-free mode enabled? Hides recent tournament results and scores until the user chooses to reveal them. */ spoilerFreeMode?: boolean; weaponReportDefaultOpen?: boolean; + /** Start of the week the schedule sidebar nudge was last dismissed for, so it stays gone until the horizon rolls over. */ + scheduleNudgeDismissedWeekStartsAt?: number; } export type Pronouns = { diff --git a/app/db/tables.ts b/app/db/tables.ts index e91ecff8d..d5aa0b35b 100644 --- a/app/db/tables.ts +++ b/app/db/tables.ts @@ -1345,6 +1345,45 @@ export interface SplatoonRotation { endsAt: number; } +/** One week of availability a user reported. The row existing means the week was submitted, which is what tells "unavailable all week" (submitted, no slots) apart from "unknown" (no row). */ +export interface AvailabilityWeek { + id: GeneratedAlways; + userId: number; + /** Monday 00:00 of the week, in `timezone` */ + weekStartsAt: number; + /** IANA timezone the week was reported in, which the day notes' dates are relative to */ + timezone: string; + createdAt: Generated; + updatedAt: Generated; +} + +/** A range the user is available for. Absolute, so a range crossing midnight is one row like any other. */ +export interface AvailabilitySlot { + id: GeneratedAlways; + availabilityWeekId: number; + startsAt: number; + endsAt: number; +} + +export interface AvailabilityDayNote { + availabilityWeekId: number; + /** YYYY-MM-DD, in the week's `timezone` */ + date: string; + text: string; +} + +/** Something the team does together that is not a tournament or a scrim, e.g. a VoD review. Blocks the members' availability. */ +export interface TeamEvent { + id: GeneratedAlways; + teamId: number; + /** User who created the event. Null if their account has since been deleted. */ + authorId: number | null; + name: string; + startsAt: number; + endsAt: number; + createdAt: Generated; +} + export type Tables = { [P in keyof DB]: Selectable }; export type TablesInsertable = { [P in keyof DB]: Insertable }; @@ -1495,4 +1534,8 @@ export interface DB { NotificationUserSubscription: NotificationUserSubscription; SavedCalendarEvent: SavedCalendarEvent; SplatoonRotation: SplatoonRotation; + AvailabilityWeek: AvailabilityWeek; + AvailabilitySlot: AvailabilitySlot; + AvailabilityDayNote: AvailabilityDayNote; + TeamEvent: TeamEvent; } diff --git a/app/features/availability/AvailabilityRepository.server.test.ts b/app/features/availability/AvailabilityRepository.server.test.ts new file mode 100644 index 000000000..ebae161d7 --- /dev/null +++ b/app/features/availability/AvailabilityRepository.server.test.ts @@ -0,0 +1,493 @@ +import { beforeEach, describe, expect, test } from "vitest"; +import { actAs } from "~/db/seed/core/actAs"; +import * as AvailabilityWeekFactory from "~/db/seed/factories/AvailabilityWeekFactory"; +import * as TeamEventFactory from "~/db/seed/factories/TeamEventFactory"; +import * as TeamFactory from "~/db/seed/factories/TeamFactory"; +import * as UserFactory from "~/db/seed/factories/UserFactory"; +import * as AvailabilityRepository from "./AvailabilityRepository.server"; +import * as Availability from "./core/Availability"; + +const users = UserFactory.pool(); + +const TIMEZONE = "Europe/Helsinki"; + +const at = (date: string, time: string) => + Availability.localToTimestamp({ date, time, timezone: TIMEZONE }); + +const WEEK_STARTS_AT = at("2026-08-24", "00:00"); +const NEXT_WEEK_STARTS_AT = at("2026-08-31", "00:00"); + +const WINDOW = { + startsAt: WEEK_STARTS_AT, + endsAt: NEXT_WEEK_STARTS_AT, +}; + +const weeksOf = (userId: number) => + AvailabilityRepository.findAllWeeksByUserIds({ + userIds: [userId], + ...WINDOW, + }); + +describe("AvailabilityRepository.upsertOwnWeek", () => { + beforeEach(async () => { + await users.create(2); + }); + + test("saves the week with its slots and day notes", async () => { + await AvailabilityWeekFactory.create({ + userId: users.id(1), + weekStartsAt: WEEK_STARTS_AT, + timezone: TIMEZONE, + slots: [ + { + startsAt: at("2026-08-24", "18:00"), + endsAt: at("2026-08-24", "22:00"), + }, + ], + dayNotes: [{ date: "2026-08-24", text: "Have to stop earlier" }], + }); + + const [week] = await weeksOf(users.id(1)); + + expect(week.timezone).toBe(TIMEZONE); + expect(week.slots).toEqual([ + { + startsAt: at("2026-08-24", "18:00"), + endsAt: at("2026-08-24", "22:00"), + }, + ]); + expect(week.dayNotes).toEqual([ + { date: "2026-08-24", text: "Have to stop earlier" }, + ]); + }); + + test("replaces the slots and day notes the week had before", async () => { + await AvailabilityWeekFactory.create({ + userId: users.id(1), + weekStartsAt: WEEK_STARTS_AT, + slots: [ + { + startsAt: at("2026-08-24", "18:00"), + endsAt: at("2026-08-24", "22:00"), + }, + ], + dayNotes: [{ date: "2026-08-24", text: "Have to stop earlier" }], + }); + + await actAs(users.id(1), () => + AvailabilityRepository.upsertOwnWeek({ + weekStartsAt: WEEK_STARTS_AT, + timezone: TIMEZONE, + slots: [ + { + startsAt: at("2026-08-25", "19:00"), + endsAt: at("2026-08-25", "23:00"), + }, + ], + dayNotes: [], + }), + ); + + const weeks = await weeksOf(users.id(1)); + + expect(weeks).toHaveLength(1); + expect(weeks[0].slots).toEqual([ + { + startsAt: at("2026-08-25", "19:00"), + endsAt: at("2026-08-25", "23:00"), + }, + ]); + expect(weeks[0].dayNotes).toEqual([]); + }); + + test("keeps a submitted week with no slots, which is how being unavailable all week is reported", async () => { + await AvailabilityWeekFactory.create({ + userId: users.id(1), + weekStartsAt: WEEK_STARTS_AT, + }); + + const weeks = await weeksOf(users.id(1)); + + expect(weeks).toHaveLength(1); + expect(weeks[0].slots).toEqual([]); + }); + + test("replaces the same week reported earlier from another timezone instead of duplicating it", async () => { + await AvailabilityWeekFactory.create({ + userId: users.id(1), + weekStartsAt: WEEK_STARTS_AT, + timezone: TIMEZONE, + slots: [ + { + startsAt: at("2026-08-24", "18:00"), + endsAt: at("2026-08-24", "22:00"), + }, + ], + }); + + const newYorkWeekStartsAt = Availability.localToTimestamp({ + date: "2026-08-24", + time: "00:00", + timezone: "America/New_York", + }); + await actAs(users.id(1), () => + AvailabilityRepository.upsertOwnWeek({ + weekStartsAt: newYorkWeekStartsAt, + timezone: "America/New_York", + slots: [ + { + startsAt: at("2026-08-26", "19:00"), + endsAt: at("2026-08-26", "21:00"), + }, + ], + dayNotes: [], + }), + ); + + const weeks = await weeksOf(users.id(1)); + + expect(weeks).toHaveLength(1); + expect(weeks[0].weekStartsAt).toBe(newYorkWeekStartsAt); + expect(weeks[0].timezone).toBe("America/New_York"); + expect(weeks[0].slots).toEqual([ + { + startsAt: at("2026-08-26", "19:00"), + endsAt: at("2026-08-26", "21:00"), + }, + ]); + }); + + test("saves each user's week of their own", async () => { + await AvailabilityWeekFactory.create({ + userId: users.id(1), + weekStartsAt: WEEK_STARTS_AT, + }); + await AvailabilityWeekFactory.create({ + userId: users.id(2), + weekStartsAt: WEEK_STARTS_AT, + }); + + const weeks = await AvailabilityRepository.findAllWeeksByUserIds({ + userIds: [users.id(1), users.id(2)], + ...WINDOW, + }); + + expect(weeks.map((week) => week.userId).sort()).toEqual( + [users.id(1), users.id(2)].sort(), + ); + }); +}); + +describe("AvailabilityRepository.findAllWeeksByUserIds", () => { + beforeEach(async () => { + await users.create(1); + }); + + test("leaves out weeks outside the window", async () => { + await AvailabilityWeekFactory.create({ + userId: users.id(1), + weekStartsAt: NEXT_WEEK_STARTS_AT, + }); + + expect(await weeksOf(users.id(1))).toEqual([]); + }); + + test("finds a week reported in a timezone whose Monday starts on the window's Sunday", async () => { + await AvailabilityWeekFactory.create({ + userId: users.id(1), + weekStartsAt: Availability.weekStartsAt( + new Date(at("2026-08-26", "12:00") * 1000), + "Asia/Tokyo", + ), + timezone: "Asia/Tokyo", + }); + + expect(await weeksOf(users.id(1))).toHaveLength(1); + }); +}); + +describe("AvailabilityRepository.deleteWeeksStartedBefore", () => { + beforeEach(async () => { + await users.create(1); + }); + + test("deletes only the weeks that started before the cutoff", async () => { + await AvailabilityWeekFactory.create({ + userId: users.id(1), + weekStartsAt: WEEK_STARTS_AT, + slots: [ + { + startsAt: at("2026-08-24", "18:00"), + endsAt: at("2026-08-24", "22:00"), + }, + ], + }); + await AvailabilityWeekFactory.create({ + userId: users.id(1), + weekStartsAt: NEXT_WEEK_STARTS_AT, + }); + + await AvailabilityRepository.deleteWeeksStartedBefore(NEXT_WEEK_STARTS_AT); + + expect(await weeksOf(users.id(1))).toEqual([]); + expect( + await AvailabilityRepository.findAllWeeksByUserIds({ + userIds: [users.id(1)], + startsAt: NEXT_WEEK_STARTS_AT, + endsAt: at("2026-09-07", "00:00"), + }), + ).toHaveLength(1); + }); +}); + +describe("AvailabilityRepository.hasReportedWeek", () => { + beforeEach(async () => { + await users.create(1); + }); + + test("finds the week even when it was reported in another timezone", async () => { + await AvailabilityWeekFactory.create({ + userId: users.id(1), + weekStartsAt: Availability.weekStartsAt( + new Date(WEEK_STARTS_AT * 1000 + 3 * 24 * 60 * 60 * 1000), + "Asia/Tokyo", + ), + timezone: "Asia/Tokyo", + }); + + expect( + await AvailabilityRepository.hasReportedWeek({ + userId: users.id(1), + weekStartsAt: WEEK_STARTS_AT, + }), + ).toBe(true); + }); + + test("does not confuse a neighbouring week for the asked one", async () => { + await AvailabilityWeekFactory.create({ + userId: users.id(1), + weekStartsAt: NEXT_WEEK_STARTS_AT, + }); + + expect( + await AvailabilityRepository.hasReportedWeek({ + userId: users.id(1), + weekStartsAt: WEEK_STARTS_AT, + }), + ).toBe(false); + }); +}); + +describe("AvailabilityRepository.findWeekReminderUserIds", () => { + beforeEach(async () => { + await users.create(4); + }); + + const reminderUserIds = () => + AvailabilityRepository.findWeekReminderUserIds(WEEK_STARTS_AT); + + test("reminds the members whose teammate reported the week", async () => { + await TeamFactory.create({ + memberUserIds: [users.id(1), users.id(2), users.id(3)], + }); + await AvailabilityWeekFactory.create({ + userId: users.id(1), + weekStartsAt: WEEK_STARTS_AT, + }); + + expect(await reminderUserIds()).toEqual([users.id(2), users.id(3)]); + }); + + test("reminds nobody on a team where nobody reported the week", async () => { + await TeamFactory.create({ memberUserIds: [users.id(1), users.id(2)] }); + await AvailabilityWeekFactory.create({ + userId: users.id(1), + weekStartsAt: NEXT_WEEK_STARTS_AT, + }); + + expect(await reminderUserIds()).toEqual([]); + }); + + test("reminds a user once even when several of their teams qualify", async () => { + await TeamFactory.create({ memberUserIds: [users.id(1), users.id(3)] }); + await TeamFactory.create({ + memberUserIds: [users.id(2), users.id(3)], + isMainTeam: false, + }); + await AvailabilityWeekFactory.create({ + userId: users.id(1), + weekStartsAt: WEEK_STARTS_AT, + }); + await AvailabilityWeekFactory.create({ + userId: users.id(2), + weekStartsAt: WEEK_STARTS_AT, + }); + + expect(await reminderUserIds()).toEqual([users.id(3)]); + }); + + test("leaves users without a team out", async () => { + await AvailabilityWeekFactory.create({ + userId: users.id(1), + weekStartsAt: WEEK_STARTS_AT, + }); + + expect(await reminderUserIds()).toEqual([]); + }); + + test("leaves cheerleaders out, the schedule surfaces do not show them", async () => { + await TeamFactory.create( + { memberUserIds: [users.id(1), users.id(2), users.id(3)] }, + { roles: { [users.id(3)]: "CHEERLEADER" } }, + ); + await AvailabilityWeekFactory.create({ + userId: users.id(1), + weekStartsAt: WEEK_STARTS_AT, + }); + + expect(await reminderUserIds()).toEqual([users.id(2)]); + }); +}); + +describe("AvailabilityRepository.findTeamEventsByTeamId", () => { + beforeEach(async () => { + await users.create(2); + }); + + test("finds only the team's events overlapping the window", async () => { + const team = await TeamFactory.create({ + name: "Alpha", + memberUserIds: [users.id(1)], + }); + const otherTeam = await TeamFactory.create({ + name: "Bravo", + memberUserIds: [users.id(2)], + }); + + await TeamEventFactory.create({ + teamId: team.id, + authorId: users.id(1), + name: "VoD review", + startsAt: at("2026-08-25", "20:00"), + endsAt: at("2026-08-25", "21:30"), + }); + await TeamEventFactory.create({ + teamId: team.id, + authorId: users.id(1), + name: "Next week meeting", + startsAt: at("2026-09-01", "19:00"), + endsAt: at("2026-09-01", "20:00"), + }); + await TeamEventFactory.create({ + teamId: otherTeam.id, + authorId: users.id(2), + name: "Bravo scrim block", + startsAt: at("2026-08-25", "20:00"), + endsAt: at("2026-08-25", "21:00"), + }); + + const events = await AvailabilityRepository.findTeamEventsByTeamId({ + teamId: team.id, + ...WINDOW, + }); + + expect(events).toHaveLength(1); + expect(events[0].name).toBe("VoD review"); + }); +}); + +describe("AvailabilityRepository.findAllUpcomingTeamEventsByUserId", () => { + beforeEach(async () => { + await users.create(2); + }); + + test("finds the events of every team the user is a member of, with the owning team attached", async () => { + const ownTeam = await TeamFactory.create({ + name: "Alpha", + memberUserIds: [users.id(1)], + }); + const otherTeam = await TeamFactory.create({ + name: "Bravo", + memberUserIds: [users.id(2)], + }); + + await TeamEventFactory.create({ + teamId: ownTeam.id, + authorId: users.id(1), + name: "VoD review", + startsAt: at("2026-08-25", "20:00"), + endsAt: at("2026-08-25", "21:30"), + }); + await TeamEventFactory.create({ + teamId: otherTeam.id, + authorId: users.id(2), + name: "Bravo meeting", + startsAt: at("2026-08-25", "20:00"), + endsAt: at("2026-08-25", "21:00"), + }); + + const events = + await AvailabilityRepository.findAllUpcomingTeamEventsByUserId({ + userId: users.id(1), + ...WINDOW, + }); + + expect(events).toHaveLength(1); + expect(events[0]).toMatchObject({ + name: "VoD review", + teamName: "Alpha", + teamCustomUrl: "alpha", + }); + }); + + test("leaves out events that ended before the window", async () => { + const team = await TeamFactory.create({ + name: "Alpha", + memberUserIds: [users.id(1)], + }); + + await TeamEventFactory.create({ + teamId: team.id, + authorId: users.id(1), + name: "Past event", + startsAt: at("2026-08-17", "20:00"), + endsAt: at("2026-08-17", "21:00"), + }); + + expect( + await AvailabilityRepository.findAllUpcomingTeamEventsByUserId({ + userId: users.id(1), + ...WINDOW, + }), + ).toEqual([]); + }); +}); + +describe("AvailabilityRepository.deleteTeamEvent", () => { + beforeEach(async () => { + await users.create(1); + }); + + test("deletes the event", async () => { + const team = await TeamFactory.create({ + name: "Alpha", + memberUserIds: [users.id(1)], + }); + const event = await TeamEventFactory.create({ + teamId: team.id, + authorId: users.id(1), + name: "VoD review", + startsAt: at("2026-08-25", "20:00"), + endsAt: at("2026-08-25", "21:30"), + }); + + await AvailabilityRepository.deleteTeamEvent(event.id); + + expect( + await AvailabilityRepository.findTeamEventsByTeamId({ + teamId: team.id, + ...WINDOW, + }), + ).toEqual([]); + }); +}); diff --git a/app/features/availability/AvailabilityRepository.server.ts b/app/features/availability/AvailabilityRepository.server.ts new file mode 100644 index 000000000..4c045bcde --- /dev/null +++ b/app/features/availability/AvailabilityRepository.server.ts @@ -0,0 +1,395 @@ +import * as R from "remeda"; +import { db } from "~/db/sql"; +import type { TablesInsertable } from "~/db/tables"; +import { actorId } from "~/features/auth/core/user.server"; +import { databaseTimestampNow } from "~/utils/dates"; +import { + concatUserSubmittedImagePrefix, + jsonArrayFrom, +} from "~/utils/kysely.server"; +import { AVAILABILITY } from "./availability-constants"; +import type { TimeRange } from "./availability-types"; + +/** Longest a week can be, a DST week included. Weeks are indexed by their start, so finding the ones overlapping a window means looking this far back. */ +const WEEK_MAX_SECONDS = 169 * 60 * 60; + +/** + * Reported availability of the given users for every week overlapping the given + * window, with the week's slots and day notes. A week without slots was + * submitted as "unavailable all week"; a user with no week at all for the + * window simply has not reported anything. + */ +export function findAllWeeksByUserIds({ + userIds, + startsAt, + endsAt, +}: { + userIds: Array; + startsAt: number; + endsAt: number; +}) { + if (userIds.length === 0) return Promise.resolve([]); + + return db + .selectFrom("AvailabilityWeek") + .select((eb) => [ + "AvailabilityWeek.id", + "AvailabilityWeek.userId", + "AvailabilityWeek.weekStartsAt", + "AvailabilityWeek.timezone", + "AvailabilityWeek.updatedAt", + jsonArrayFrom( + eb + .selectFrom("AvailabilitySlot") + .select(["AvailabilitySlot.startsAt", "AvailabilitySlot.endsAt"]) + .whereRef( + "AvailabilitySlot.availabilityWeekId", + "=", + "AvailabilityWeek.id", + ) + .orderBy("AvailabilitySlot.startsAt", "asc"), + ).as("slots"), + jsonArrayFrom( + eb + .selectFrom("AvailabilityDayNote") + .select(["AvailabilityDayNote.date", "AvailabilityDayNote.text"]) + .whereRef( + "AvailabilityDayNote.availabilityWeekId", + "=", + "AvailabilityWeek.id", + ) + .orderBy("AvailabilityDayNote.date", "asc"), + ).as("dayNotes"), + ]) + .where("AvailabilityWeek.userId", "in", userIds) + .where("AvailabilityWeek.weekStartsAt", "<", endsAt) + .where("AvailabilityWeek.weekStartsAt", ">", startsAt - WEEK_MAX_SECONDS) + .execute(); +} + +/** + * Whether the user has reported the week starting at `weekStartsAt`. The week + * is theirs to place, so a start within {@link AVAILABILITY.WEEK_MATCH_MAX_DISTANCE_SECONDS} + * of the asked one is the same week seen from another timezone. + */ +export async function hasReportedWeek({ + userId, + weekStartsAt, +}: { + userId: number; + weekStartsAt: number; +}) { + const week = await db + .selectFrom("AvailabilityWeek") + .select("AvailabilityWeek.id") + .where("AvailabilityWeek.userId", "=", userId) + .where( + "AvailabilityWeek.weekStartsAt", + ">", + weekStartsAt - AVAILABILITY.WEEK_MATCH_MAX_DISTANCE_SECONDS, + ) + .where( + "AvailabilityWeek.weekStartsAt", + "<", + weekStartsAt + AVAILABILITY.WEEK_MATCH_MAX_DISTANCE_SECONDS, + ) + .executeTakeFirst(); + + return Boolean(week); +} + +/** + * Ids of the users who have not reported the week starting at `weekStartsAt` + * while at least one of their teammates has — the reminder is only worth + * sending when somebody else on the team already moved. Cheerleaders are left + * out, the schedule surfaces do not show them. + */ +export async function findWeekReminderUserIds(weekStartsAt: number) { + const memberships = await db + .selectFrom("TeamMemberWithSecondary") + .where((eb) => + eb.or([ + eb("TeamMemberWithSecondary.role", "is", null), + eb("TeamMemberWithSecondary.role", "!=", "CHEERLEADER"), + ]), + ) + .leftJoin("AvailabilityWeek", (join) => + join + .onRef("AvailabilityWeek.userId", "=", "TeamMemberWithSecondary.userId") + .on( + "AvailabilityWeek.weekStartsAt", + ">", + weekStartsAt - AVAILABILITY.WEEK_MATCH_MAX_DISTANCE_SECONDS, + ) + .on( + "AvailabilityWeek.weekStartsAt", + "<", + weekStartsAt + AVAILABILITY.WEEK_MATCH_MAX_DISTANCE_SECONDS, + ), + ) + .select([ + "TeamMemberWithSecondary.userId", + "TeamMemberWithSecondary.teamId", + "AvailabilityWeek.id as reportedWeekId", + ]) + .execute(); + + const userIds = new Set(); + for (const team of Object.values( + R.groupBy(memberships, (membership) => membership.teamId), + )) { + if (!team.some((member) => member.reportedWeekId !== null)) continue; + + for (const member of team) { + if (member.reportedWeekId === null) userIds.add(member.userId); + } + } + + return Array.from(userIds); +} + +/** + * 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(); +} + +/** Team events of one team overlapping the given window. */ +export function findTeamEventsByTeamId({ + teamId, + startsAt, + endsAt, +}: { + teamId: number; + startsAt: number; + endsAt: number; +}) { + return db + .selectFrom("TeamEvent") + .select([ + "TeamEvent.id", + "TeamEvent.name", + "TeamEvent.startsAt", + "TeamEvent.endsAt", + ]) + .where("TeamEvent.teamId", "=", teamId) + .where("TeamEvent.startsAt", "<", endsAt) + .where("TeamEvent.endsAt", ">", startsAt) + .orderBy("TeamEvent.startsAt", "asc") + .execute(); +} + +/** + * Ongoing and upcoming team events of every team the given user is a member of + * (secondary teams included), starting within the given window, with the + * owning team attached. For the user's personal calendar surfaces. + */ +export function findAllUpcomingTeamEventsByUserId({ + userId, + startsAt, + endsAt, +}: { + userId: number; + startsAt: number; + endsAt: number; +}) { + return db + .selectFrom("TeamEvent") + .innerJoin( + "TeamMemberWithSecondary", + "TeamMemberWithSecondary.teamId", + "TeamEvent.teamId", + ) + .innerJoin("Team", "Team.id", "TeamEvent.teamId") + .leftJoin("UserSubmittedImage", "Team.avatarImgId", "UserSubmittedImage.id") + .select((eb) => [ + "TeamEvent.id", + "TeamEvent.name", + "TeamEvent.startsAt", + "TeamEvent.endsAt", + "Team.name as teamName", + "Team.customUrl as teamCustomUrl", + concatUserSubmittedImagePrefix(eb.ref("UserSubmittedImage.url")).as( + "teamAvatarUrl", + ), + ]) + .where("TeamMemberWithSecondary.userId", "=", userId) + .where("TeamEvent.endsAt", ">", startsAt) + .where("TeamEvent.startsAt", "<", endsAt) + .orderBy("TeamEvent.startsAt", "asc") + .execute(); +} + +export function findTeamEventById(id: number) { + return db + .selectFrom("TeamEvent") + .select(["TeamEvent.id", "TeamEvent.teamId"]) + .where("TeamEvent.id", "=", id) + .executeTakeFirst(); +} + +interface UpsertOwnWeekArgs { + weekStartsAt: number; + timezone: string; + slots: Array; + dayNotes: Array< + Pick + >; +} + +/** + * Saves the acting user's availability for one week, replacing whatever they + * had reported for it. The week is saved as a whole, so slots and day notes + * left out are removed. A week reported earlier from another timezone (its + * start hours apart, never days) is the same week and gets replaced, not + * duplicated. + * + * @returns id of the week + */ +export function upsertOwnWeek(args: UpsertOwnWeekArgs) { + const userId = actorId(); + + return db.transaction().execute(async (trx) => { + const existing = await trx + .selectFrom("AvailabilityWeek") + .select("AvailabilityWeek.id") + .where("AvailabilityWeek.userId", "=", userId) + .where( + "AvailabilityWeek.weekStartsAt", + ">", + args.weekStartsAt - AVAILABILITY.WEEK_MATCH_MAX_DISTANCE_SECONDS, + ) + .where( + "AvailabilityWeek.weekStartsAt", + "<", + args.weekStartsAt + AVAILABILITY.WEEK_MATCH_MAX_DISTANCE_SECONDS, + ) + .executeTakeFirst(); + + const week = existing + ? await trx + .updateTable("AvailabilityWeek") + .set({ + weekStartsAt: args.weekStartsAt, + timezone: args.timezone, + updatedAt: databaseTimestampNow(), + }) + .where("AvailabilityWeek.id", "=", existing.id) + .returning("id") + .executeTakeFirstOrThrow() + : await trx + .insertInto("AvailabilityWeek") + .values({ + userId, + weekStartsAt: args.weekStartsAt, + timezone: args.timezone, + }) + .returning("id") + .executeTakeFirstOrThrow(); + + await trx + .deleteFrom("AvailabilitySlot") + .where("AvailabilitySlot.availabilityWeekId", "=", week.id) + .execute(); + await trx + .deleteFrom("AvailabilityDayNote") + .where("AvailabilityDayNote.availabilityWeekId", "=", week.id) + .execute(); + + if (args.slots.length > 0) { + await trx + .insertInto("AvailabilitySlot") + .values( + args.slots.map((slot) => ({ + availabilityWeekId: week.id, + startsAt: slot.startsAt, + endsAt: slot.endsAt, + })), + ) + .execute(); + } + + if (args.dayNotes.length > 0) { + await trx + .insertInto("AvailabilityDayNote") + .values( + args.dayNotes.map((dayNote) => ({ + availabilityWeekId: week.id, + date: dayNote.date, + text: dayNote.text, + })), + ) + .execute(); + } + + return week.id; + }); +} + +/** + * Deletes availability weeks that started before the given timestamp. Their + * slots and day notes go with them via cascade delete. + */ +export function deleteWeeksStartedBefore(weekStartsAt: number) { + return db + .deleteFrom("AvailabilityWeek") + .where("AvailabilityWeek.weekStartsAt", "<", weekStartsAt) + .executeTakeFirstOrThrow(); +} + +/** Deletes team events that ended before the given timestamp. */ +export function deleteTeamEventsEndedBefore(endsAt: number) { + return db + .deleteFrom("TeamEvent") + .where("TeamEvent.endsAt", "<", endsAt) + .executeTakeFirstOrThrow(); +} + +/** + * Adds an event the whole team takes part in. Author is the acting user. + * + * @returns id of the new event + */ +export async function insertTeamEvent( + args: Omit, +) { + const event = await db + .insertInto("TeamEvent") + .values({ ...args, authorId: actorId() }) + .returning("id") + .executeTakeFirstOrThrow(); + + return event.id; +} + +export function deleteTeamEvent(id: number) { + return db.deleteFrom("TeamEvent").where("TeamEvent.id", "=", id).execute(); +} diff --git a/app/features/availability/actions/events.server.test.ts b/app/features/availability/actions/events.server.test.ts new file mode 100644 index 000000000..7f0791a10 --- /dev/null +++ b/app/features/availability/actions/events.server.test.ts @@ -0,0 +1,85 @@ +import { addWeeks } from "date-fns"; +import * as R from "remeda"; +import { describe, expect, test } from "vitest"; +import * as UserFactory from "~/db/seed/factories/UserFactory"; +import { assertResponseErrored, wrappedAction } from "~/utils/Test"; +import * as AvailabilityRepository from "../AvailabilityRepository.server"; +import type { saveWeekSchema } from "../availability-schemas"; +import * as Availability from "../core/Availability"; +import { action as eventsAction } from "./events.server"; + +const DAY_SECONDS = 24 * 60 * 60; +// the action has no request timezone in tests, so it falls back to UTC +const TIMEZONE = "UTC"; + +const saveWeek = wrappedAction({ + action: eventsAction, + isJsonSubmission: true, +}); + +const weekDays = (weeksFromNow: number) => { + const weekStartsAt = Availability.weekStartsAt( + addWeeks(new Date(), weeksFromNow), + TIMEZONE, + ); + + return R.range(0, 7).map((dayIndex) => ({ + date: Availability.dateInTimezone( + weekStartsAt + dayIndex * DAY_SECONDS + DAY_SECONDS / 2, + TIMEZONE, + ), + ranges: [], + note: "", + })); +}; + +describe("events action: SAVE_WEEK", () => { + test("saves the current week", async () => { + const user = await UserFactory.createRegular(); + + const response = await saveWeek( + { _action: "SAVE_WEEK", days: weekDays(0) }, + { user: "regular" }, + ); + + expect(response).toBeNull(); + expect( + await AvailabilityRepository.hasReportedWeek({ + userId: user.id, + weekStartsAt: Availability.weekStartsAt(new Date(), TIMEZONE), + }), + ).toBe(true); + }); + + test.each([ + { why: "a week before the current one", weeksFromNow: -1 }, + { why: "a week past the horizon", weeksFromNow: 2 }, + ])("rejects $why", async ({ weeksFromNow }) => { + await UserFactory.createRegular(); + + const response = await saveWeek( + { _action: "SAVE_WEEK", days: weekDays(weeksFromNow) }, + { user: "regular" }, + ); + + assertResponseErrored( + response, + "Only the current and the next week can be saved", + ); + }); + + test("rejects days that do not form one week", async () => { + await UserFactory.createRegular(); + const days = weekDays(0); + + const response = await saveWeek( + { + _action: "SAVE_WEEK", + days: [...days.slice(0, 6), { ...days[6], date: days[0].date }], + }, + { user: "regular" }, + ); + + assertResponseErrored(response, "Days do not form one week"); + }); +}); diff --git a/app/features/availability/actions/events.server.ts b/app/features/availability/actions/events.server.ts new file mode 100644 index 000000000..2f2eacb53 --- /dev/null +++ b/app/features/availability/actions/events.server.ts @@ -0,0 +1,103 @@ +import { addWeeks } from "date-fns"; +import type { ActionFunction } from "react-router"; +import * as R from "remeda"; +import { requireUser } from "~/features/auth/core/user.server"; +import { resolveNotifications } from "~/features/notifications/core/resolve.server"; +import { getViewerTimezone } from "~/features/timezone/timezone-context.server"; +import * as UserRepository from "~/features/user-page/UserRepository.server"; +import { errorToastIfFalsy, parseRequestPayload } from "~/utils/remix.server"; +import { assertUnreachable } from "~/utils/types"; +import * as AvailabilityRepository from "../AvailabilityRepository.server"; +import { AVAILABILITY } from "../availability-constants"; +import { eventsActionSchema } from "../availability-schemas"; +import * as Availability from "../core/Availability"; + +const DAY_SECONDS = 24 * 60 * 60; + +export const action: ActionFunction = async ({ request }) => { + const user = requireUser(); + + const data = await parseRequestPayload({ + request, + schema: eventsActionSchema, + }); + const timezone = getViewerTimezone() ?? "UTC"; + const now = new Date(); + + switch (data._action) { + case "SAVE_WEEK": { + const weekStartsAt = Availability.localToTimestamp({ + date: data.days[0].date, + time: "00:00", + timezone, + }); + + errorToastIfFalsy( + R.range(0, AVAILABILITY.WEEK_HORIZON).some( + (weekOffset) => + Availability.weekStartsAt(addWeeks(now, weekOffset), timezone) === + weekStartsAt, + ), + "Only the current and the next week can be saved", + ); + errorToastIfFalsy( + data.days.every( + (day, dayIndex) => + day.date === + Availability.dateInTimezone( + weekStartsAt + dayIndex * DAY_SECONDS + DAY_SECONDS / 2, + timezone, + ), + ), + "Days do not form one week", + ); + + await AvailabilityRepository.upsertOwnWeek({ + weekStartsAt, + timezone, + // normalized so ranges overlapping across midnight land as one slot + slots: Availability.normalize( + data.days.flatMap((day) => + day.ranges.map((range) => ({ + startsAt: Availability.dayMinutesToTimestamp({ + date: day.date, + minutes: range.start, + timezone, + }), + endsAt: Availability.dayMinutesToTimestamp({ + date: day.date, + minutes: range.end, + timezone, + }), + })), + ), + ), + dayNotes: data.days.flatMap((day) => + day.note ? [{ date: day.date, text: day.note }] : [], + ), + }); + + await resolveNotifications({ + userIds: [user.id], + type: "SCHEDULE_TEAM_REMINDER", + }); + + break; + } + case "DISMISS_SCHEDULE_NUDGE": { + await UserRepository.updateOwnPreferences({ + scheduleNudgeDismissedWeekStartsAt: Availability.weekStartsAt( + addWeeks(now, 1), + timezone, + ), + }); + + break; + } + default: { + assertUnreachable(data); + } + } + + return null; +}; diff --git a/app/features/availability/actions/t.$customUrl.schedule.server.ts b/app/features/availability/actions/t.$customUrl.schedule.server.ts new file mode 100644 index 000000000..4a246d530 --- /dev/null +++ b/app/features/availability/actions/t.$customUrl.schedule.server.ts @@ -0,0 +1,82 @@ +import type { ActionFunction } from "react-router"; +import * as v from "valibot"; +import { requireUser } from "~/features/auth/core/user.server"; +import { notify } from "~/features/notifications/core/notify.server"; +import * as TeamRepository from "~/features/team/TeamRepository.server"; +import { teamParamsSchema } from "~/features/team/team-schemas.server"; +import { parseFormData } from "~/form/parse.server"; +import { requirePermission } from "~/modules/permissions/guards.server"; +import { dateToDatabaseTimestamp } from "~/utils/dates"; +import { errorToastIfFalsy, notFoundIfNullish } from "~/utils/remix.server"; +import { assertUnreachable } from "~/utils/types"; +import * as AvailabilityRepository from "../AvailabilityRepository.server"; +import { teamScheduleActionSchema } from "../availability-schemas"; + +export const action: ActionFunction = async ({ request, params }) => { + const user = requireUser(); + const { customUrl } = v.parse(teamParamsSchema, params); + + const team = notFoundIfNullish( + await TeamRepository.findByCustomUrl(customUrl), + ); + + requirePermission(team, "EDIT"); + + const result = await parseFormData({ + request, + schema: teamScheduleActionSchema, + }); + + if (!result.success) { + return { fieldErrors: result.fieldErrors }; + } + + const data = result.data; + + switch (data._action) { + case "ADD_EVENT": { + const startsAt = dateToDatabaseTimestamp(data.startsAt); + + await AvailabilityRepository.insertTeamEvent({ + teamId: team.id, + name: data.name, + startsAt, + endsAt: startsAt + Number(data.duration) * 60, + }); + + await notify({ + userIds: team.members + .filter( + (member) => member.id !== user.id && member.role !== "CHEERLEADER", + ) + .map((member) => member.id), + notification: { + type: "TEAM_EVENT_ADDED", + meta: { + eventName: data.name, + teamName: team.name, + teamCustomUrl: team.customUrl, + }, + pictureUrl: team.avatarUrl ?? undefined, + }, + }); + + return null; + } + case "DELETE_EVENT": { + const event = notFoundIfNullish( + await AvailabilityRepository.findTeamEventById(data.eventId), + ); + errorToastIfFalsy( + event.teamId === team.id, + "Event does not belong to the team", + ); + + await AvailabilityRepository.deleteTeamEvent(event.id); + + return null; + } + default: + assertUnreachable(data); + } +}; diff --git a/app/features/availability/availability-constants.ts b/app/features/availability/availability-constants.ts new file mode 100644 index 000000000..01722874b --- /dev/null +++ b/app/features/availability/availability-constants.ts @@ -0,0 +1,26 @@ +export const AVAILABILITY = { + /** Granularity availability is entered and rendered at. */ + SLOT_STEP_MINUTES: 30, + /** How many players have to be free at once for the team to be able to play. */ + DEFAULT_MIN_PLAYERS: 4, + /** Shorter overlaps are not worth reporting as a playable window. */ + MIN_WINDOW_MINUTES: 60, + DAY_NOTE_MAX_LENGTH: 100, + TEAM_EVENT_NAME_MAX_LENGTH: 100, + /** Weeks that can be filled in: the current one and the next. */ + 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: 1.5 * 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. */ + TRACK_START_MINUTES: 14 * 60, + /** Left edge of the clock window with the earlier-hours expander open (06:00). */ + TRACK_EARLIER_START_MINUTES: 6 * 60, + /** Right edge of the clock window, reaching past midnight (02:00). */ + TRACK_END_MINUTES: 26 * 60, + /** Right edge of the clock window with the later-hours expander open (06:00 the next day). */ + TRACK_LATER_END_MINUTES: 30 * 60, +} as const; diff --git a/app/features/availability/availability-schemas.test.ts b/app/features/availability/availability-schemas.test.ts new file mode 100644 index 000000000..3a1d01d7e --- /dev/null +++ b/app/features/availability/availability-schemas.test.ts @@ -0,0 +1,47 @@ +import * as R from "remeda"; +import * as v from "valibot"; +import { describe, expect, test } from "vitest"; +import { saveWeekSchema } from "./availability-schemas"; + +const DAY_MINUTES = 24 * 60; + +const weekWith = (ranges: Array<{ start: number; end: number }>) => ({ + _action: "SAVE_WEEK" as const, + days: R.range(0, 7).map((dayIndex) => ({ + date: `2026-08-${String(24 + dayIndex).padStart(2, "0")}`, + ranges: dayIndex === 0 ? ranges : [], + note: "", + })), +}); + +describe("saveWeekSchema", () => { + test.each([ + { why: "a range ending when it starts", start: 600, end: 600 }, + { why: "a range ending before it starts", start: 600, end: 540 }, + { + why: "a range longer than a day", + start: 60, + end: 60 + DAY_MINUTES + 30, + }, + { why: "a range ending past the next day", start: 1380, end: 2881 }, + ])("rejects $why", ({ start, end }) => { + expect( + v.safeParse(saveWeekSchema, weekWith([{ start, end }])).success, + ).toBe(false); + }); + + test.each([ + { why: "a range within one day", start: 600, end: 720 }, + { why: "a range crossing midnight", start: 1380, end: 1500 }, + { why: "a range exactly a day long", start: 0, end: DAY_MINUTES }, + { + why: "the last minute a range can start", + start: DAY_MINUTES - 1, + end: DAY_MINUTES, + }, + ])("accepts $why", ({ start, end }) => { + expect( + v.safeParse(saveWeekSchema, weekWith([{ start, end }])).success, + ).toBe(true); + }); +}); diff --git a/app/features/availability/availability-schemas.ts b/app/features/availability/availability-schemas.ts new file mode 100644 index 000000000..a4d835e5c --- /dev/null +++ b/app/features/availability/availability-schemas.ts @@ -0,0 +1,97 @@ +import { add, sub } from "date-fns"; +import * as v from "valibot"; +import { datetime, select, stringConstant, textField } from "~/form/fields"; +import { _action, id } from "~/utils/schema"; +import { AVAILABILITY } from "./availability-constants"; + +const DAY_MINUTES = 24 * 60; +const MAX_RANGES_PER_DAY = 24; + +const dayTimeRangeSchema = v.pipe( + v.object({ + start: v.pipe( + v.number(), + v.integer(), + v.minValue(0), + v.maxValue(DAY_MINUTES - 1), + ), + end: v.pipe( + v.number(), + v.integer(), + v.minValue(1), + v.maxValue(2 * DAY_MINUTES), + ), + }), + v.check((range) => range.end > range.start, "Range must end after it starts"), + v.check( + (range) => range.end - range.start <= DAY_MINUTES, + "Range must be at most a day long", + ), +); + +const editorDaySchema = v.object({ + date: v.pipe(v.string(), v.isoDate()), + ranges: v.pipe(v.array(dayTimeRangeSchema), v.maxLength(MAX_RANGES_PER_DAY)), + note: v.pipe( + v.string(), + v.trim(), + v.maxLength(AVAILABILITY.DAY_NOTE_MAX_LENGTH), + ), +}); + +export const saveWeekSchema = v.object({ + _action: _action("SAVE_WEEK"), + days: v.pipe(v.array(editorDaySchema), v.length(7)), +}); + +export const dismissScheduleNudgeSchema = v.object({ + _action: _action("DISMISS_SCHEDULE_NUDGE"), + revalidateRoot: v.optional(v.nullable(v.literal(true))), +}); + +export const eventsActionSchema = v.union([ + saveWeekSchema, + dismissScheduleNudgeSchema, +]); + +const teamEventDurationItems = [ + { label: "options.duration.30m" as const, value: "30" }, + { label: "options.duration.1h" as const, value: "60" }, + { label: "options.duration.1h30m" as const, value: "90" }, + { label: "options.duration.2h" as const, value: "120" }, + { label: "options.duration.2h30m" as const, value: "150" }, + { label: "options.duration.3h" as const, value: "180" }, + { label: "options.duration.4h" as const, value: "240" }, + { label: "options.duration.5h" as const, value: "300" }, + { label: "options.duration.6h" as const, value: "360" }, +] as const; + +export const addTeamEventSchema = v.object({ + _action: stringConstant("ADD_EVENT"), + name: textField({ + label: "labels.name", + maxLength: AVAILABILITY.TEAM_EVENT_NAME_MAX_LENGTH, + }), + startsAt: datetime({ + label: "labels.start", + min: () => sub(new Date(), { hours: 1 }), + max: () => add(new Date(), { months: 2 }), + minMessage: "errors.dateInPast", + maxMessage: "errors.dateTooFarAway", + }), + duration: select({ + label: "labels.duration", + items: [...teamEventDurationItems], + initialValue: "60", + }), +}); + +const deleteTeamEventSchema = v.object({ + _action: _action("DELETE_EVENT"), + eventId: id, +}); + +export const teamScheduleActionSchema = v.union([ + addTeamEventSchema, + deleteTeamEventSchema, +]); diff --git a/app/features/availability/availability-search-params.test.ts b/app/features/availability/availability-search-params.test.ts new file mode 100644 index 000000000..9a74b5ef8 --- /dev/null +++ b/app/features/availability/availability-search-params.test.ts @@ -0,0 +1,11 @@ +import { describe, test } from "vitest"; +import { assertRoundTrips } from "~/modules/search-params/search-params-test-utils"; +import { scheduleWeekSearchParams } from "./availability-search-params"; + +describe("scheduleWeekSearchParams", () => { + test("round-trips", () => { + assertRoundTrips(scheduleWeekSearchParams, { + week: ["current", "next"], + }); + }); +}); diff --git a/app/features/availability/availability-search-params.ts b/app/features/availability/availability-search-params.ts new file mode 100644 index 000000000..ef1c7dad1 --- /dev/null +++ b/app/features/availability/availability-search-params.ts @@ -0,0 +1,10 @@ +import * as v from "valibot"; +import * as SearchParams from "~/modules/search-params/search-params"; +import { SP } from "~/modules/search-params/search-params"; + +export const scheduleWeekSearchParams = SearchParams.define({ + week: SP.param(v.picklist(["current", "next"]), { + default: "current", + loader: false, + }), +}); diff --git a/app/features/availability/availability-types.ts b/app/features/availability/availability-types.ts new file mode 100644 index 000000000..a4b2b22bd --- /dev/null +++ b/app/features/availability/availability-types.ts @@ -0,0 +1,118 @@ +/** A span of absolute time. Both ends are database timestamps (unix seconds), `endsAt` exclusive. */ +export interface TimeRange { + startsAt: number; + endsAt: number; +} + +/** Availability of one member of a team, as effective availability (reported minus commitments). */ +export interface MemberAvailability { + userId: number; + ranges: Array; +} + +/** + * A span the team could play in: + * - `FULL` = the required amount of players is free for the whole window + * - `ONE_SHORT` = one player short, so they would need a sub + */ +export type PlayableWindowTier = "FULL" | "ONE_SHORT"; + +export interface PlayableWindow extends TimeRange { + tier: PlayableWindowTier; + /** Members free for the whole window, in the order they were given. */ + userIds: Array; +} + +/** + * A span within one day of the schedule editor, in minutes from that day's + * midnight. `end` may pass 1440 for a range crossing midnight. + */ +export interface DayTimeRange { + start: number; + end: number; +} + +/** One day of the schedule editor: the ranges painted on its track plus its note. */ +export interface AvailabilityEditorDay { + /** `YYYY-MM-DD` in the editing user's timezone */ + date: string; + ranges: Array; + note: string; +} + +/** The schedule editor's value: the seven days of one week, Monday first. */ +export type AvailabilityEditorWeek = Array; + +/** A commitment shown on the editor as a locked block that cannot be painted over. */ +export interface EditorCommitment { + date: string; + 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; +} + +/** + * How one person's schedule relates to an event's window: + * - `available` — reported availability covers the whole window + * - `partial` — covers part of it; `ranges` show which part + * - `unavailable` — a week was reported, none of it overlaps the window + * - `busy` — a commitment elsewhere overlaps the window, overriding whatever + * was reported + * - `unknown` — no reported week covers the window + */ +export type WindowAvailability = + | { status: "available" | "partial"; ranges: Array } + | { status: "busy"; block: BusyBlock } + | { status: "unavailable" } + | { status: "unknown" }; + +/** + * How one person's schedule relates to a window, as the surfaces showing a + * roster's fit render it. `notes` is left out by the surfaces that have no day + * notes at hand. + */ +export interface WindowAvailabilityEntry { + userId: number; + availability: WindowAvailability; + notes?: Array; +} + +/** + * What is known about one person inside a window: the material + * `Availability.availabilityInWindow` resolves a status from. Sent to the + * browser as is by the surfaces that ask about many windows at once, so that + * narrowing one down (picking a start inside a post's flexibility) needs no + * further round trip. + */ +export interface WindowSchedule { + userId: number; + /** Whether they filled in the week the window falls in. */ + reported: boolean; + /** Their effective availability inside the window. */ + ranges: Array; + /** Their commitments overlapping the window. */ + busy: Array; +} + +/** + * One person's week as the read-only week views render it: the seven days in + * the viewer's timezone with the time they are effectively free to play. What + * a commitment takes back is already cut out — the view answers "when can they + * play", not "what are they doing". + */ +export interface ScheduleWeekView { + week: "current" | "next"; + weekNumber: number; + /** Whether they filled the week in at all. */ + reported: boolean; + days: Array<{ noonAt: number; ranges: Array }>; +} diff --git a/app/features/availability/components/MySchedule.module.css b/app/features/availability/components/MySchedule.module.css new file mode 100644 index 000000000..5e607b07f --- /dev/null +++ b/app/features/availability/components/MySchedule.module.css @@ -0,0 +1,27 @@ +.header { + display: flex; + align-items: center; + justify-content: space-between; + gap: var(--s-2); + flex-wrap: wrap; +} + +.weekHeading { + font-size: var(--font-xs); + font-weight: var(--weight-semi); + color: var(--color-text-high); + margin-inline: var(--s-2); +} + +.notFilled { + color: inherit; + opacity: 0.7; + font-size: var(--font-2xs); + margin-inline-start: var(--s-1); +} + +.actions { + display: flex; + justify-content: space-between; + gap: var(--s-2); +} diff --git a/app/features/availability/components/MySchedule.tsx b/app/features/availability/components/MySchedule.tsx new file mode 100644 index 000000000..bf2ed3eb3 --- /dev/null +++ b/app/features/availability/components/MySchedule.tsx @@ -0,0 +1,185 @@ +import * as React from "react"; +import { useTranslation } from "react-i18next"; +import type { FetcherWithComponents } from "react-router"; +import * as R from "remeda"; +import { SendouButton } from "~/components/elements/Button"; +import { toastQueue } from "~/components/elements/Toast"; +import { useUnsavedChangesChecker } from "~/form/UnsavedChangesGuard"; +import { useDateTimeFormat } from "~/hooks/intl/useDateTimeFormat"; +import { useActionSubmit } from "~/hooks/useActionSubmit"; +import { useSearchParamsTyped } from "~/modules/search-params/hooks"; +import { saveWeekSchema } from "../availability-schemas"; +import { scheduleWeekSearchParams } from "../availability-search-params"; +import type { AvailabilityEditorWeek } from "../availability-types"; +import type { MyScheduleData } from "../core/MySchedule.server"; +import styles from "./MySchedule.module.css"; +import { WeekAvailabilityEditor } from "./WeekAvailabilityEditor"; +import { WeekToggle } from "./WeekToggle"; + +/** + * The "My schedule" section of the events page: the schedule editor with a + * current/next week toggle, "Copy last week" prefill and the save action. + */ +export function MySchedule({ data }: { data: MyScheduleData }) { + const { t } = useTranslation(["schedule"]); + const [{ week }, setParams] = useSearchParamsTyped(scheduleWeekSearchParams); + const [weeks, setWeeks] = React.useState>(() => + data.weeks.map((editorWeek) => editorWeek.days), + ); + const { submit, fetcher, state } = useActionSubmit(saveWeekSchema, { + encType: "application/json", + }); + useSavedToast(fetcher); + const { formatter: headingFormatter } = useDateTimeFormat({ + month: "short", + day: "numeric", + }); + + // dirty = the editor differs from what the loader last saw, or the day + // popover holds edits it has not committed yet; a successful save + // revalidates the loader, which makes this read clean again. Edits survive + // same-route navigations (the view tabs), so only a pathname change or a + // full unload warns. + const hasPendingDraftRef = React.useRef(false); + const hasUnsavedChangesRef = React.useRef< + Parameters[0]["current"] + >(() => false); + hasUnsavedChangesRef.current = (navigation) => + fetcher.state === "idle" && + (!navigation || + navigation.currentLocation.pathname !== + navigation.nextLocation.pathname) && + (hasPendingDraftRef.current || + !R.isDeepEqual( + weeks, + data.weeks.map((editorWeek) => editorWeek.days), + )); + useUnsavedChangesChecker(hasUnsavedChangesRef); + + const weekIndex = week === "next" ? 1 : 0; + const shownDays = weeks[weekIndex]; + + const copySourceRanges = + weekIndex === 0 ? data.lastWeekRanges : weeks[0].map((day) => day.ranges); + const canCopy = + copySourceRanges?.some((ranges) => ranges.length > 0) ?? false; + + const copyPreviousWeek = () => { + if (!copySourceRanges) return; + + setWeeks( + weeks.map((days, index) => + index === weekIndex + ? days.map((day, dayIndex) => ({ + ...day, + ranges: copySourceRanges[dayIndex], + })) + : days, + ), + ); + }; + + const saveWeek = () => { + submit("SAVE_WEEK", { + days: shownDays.map((day) => ({ + date: day.date, + ranges: day.ranges, + note: day.note, + })), + }); + }; + + return ( +
+
+

{t("schedule:editor.title")}

+ setParams({ week: value })} + renderExtra={(value) => + !data.weeks[value === "next" ? 1 : 0].submitted ? ( + + • {t("schedule:editor.notFilled")} + + ) : null + } + /> +
+

+ {t("schedule:team.weekHeading", { + week: data.weeks[weekIndex].weekNumber, + })}{" "} + ·{" "} + {headingFormatter.formatRange( + dateAtNoon(shownDays[0].date), + dateAtNoon(shownDays[6].date), + )} +

+ ({ + date: commitment.date, + range: commitment.range, + name: commitment.name ?? t("schedule:commitment.scrim"), + }))} + onChange={(value) => + setWeeks( + weeks.map((days, index) => (index === weekIndex ? value : days)), + ) + } + onPendingDraftChange={(hasPendingDraft) => { + hasPendingDraftRef.current = hasPendingDraft; + }} + /> +
+ + {t("schedule:editor.copyLastWeek")} + + + {t("schedule:editor.saveWeek")} + +
+
+ ); +} + +function useSavedToast(fetcher: FetcherWithComponents) { + const { t } = useTranslation(["schedule"]); + const previousStateRef = React.useRef(fetcher.state); + + React.useEffect(() => { + if ( + previousStateRef.current !== "idle" && + fetcher.state === "idle" && + fetcher.data === null + ) { + toastQueue.add({ + message: t("schedule:editor.saved"), + variant: "success", + }); + } + previousStateRef.current = fetcher.state; + }, [fetcher.state, fetcher.data, t]); +} + +function dateAtNoon(date: string) { + const [year, month, day] = date.split("-").map(Number); + + return new Date(year, month - 1, day, 12); +} diff --git a/app/features/availability/components/RegistrationAvailabilityPanel.module.css b/app/features/availability/components/RegistrationAvailabilityPanel.module.css new file mode 100644 index 000000000..9df9945fe --- /dev/null +++ b/app/features/availability/components/RegistrationAvailabilityPanel.module.css @@ -0,0 +1,210 @@ +.panel { + display: flex; + flex-direction: column; + gap: var(--s-3); + width: 100%; + padding: var(--s-4); + background-color: var(--color-bg); + border: var(--border-style); + border-radius: var(--radius-box); + font-size: var(--font-xs); +} + +.heading { + font-size: var(--font-sm); + color: var(--color-text); +} + +.windowText { + font-size: var(--font-xs); + font-weight: var(--weight-body); + color: var(--color-text-high); +} + +.rows { + display: flex; + flex-direction: column; + gap: var(--s-2-5); + list-style: none; + padding: 0; + margin: 0; +} + +.row { + display: flex; + align-items: center; + gap: var(--s-2); + min-width: 0; + font-size: var(--font-xs); +} + +.statusCircle { + width: 24px; + height: 24px; + flex-shrink: 0; + display: grid; + place-items: center; + border-radius: var(--radius-full); + background-color: color-mix(in oklch, var(--color-text) 8%, transparent); + + &[data-status="available"] { + background-color: color-mix( + in oklch, + var(--color-success) 20%, + transparent + ); + } + + &[data-status="partial"] { + background-color: color-mix( + in oklch, + var(--color-warning) 20%, + transparent + ); + } + + &[data-status="unavailable"], + &[data-status="busy"] { + background-color: color-mix(in oklch, var(--color-error) 15%, transparent); + } +} + +.nameBlock { + display: flex; + flex-direction: column; + min-width: 0; +} + +.name { + font-weight: var(--weight-semi); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.secondaryName { + font-size: var(--font-3xs); + font-weight: var(--weight-semi); + color: var(--color-text-high); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.trailing { + margin-inline-start: auto; + display: inline-flex; + align-items: center; +} + +.ranges { + color: var(--color-text-high); + font-variant-numeric: tabular-nums; + white-space: nowrap; +} + +.detailText { + color: var(--color-text-high); + white-space: nowrap; +} + +.mutedText { + color: var(--color-text-high); +} + +.note { + display: inline-flex; + align-items: center; + gap: var(--s-1); + color: var(--color-text-high); + font-size: var(--font-3xs); + font-style: italic; +} + +.noteFlag { + color: var(--color-text-accent); + flex-shrink: 0; +} + +.busy { + display: inline-flex; + align-items: center; + max-width: 12rem; + 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); + + & .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); + } +} + +.summary { + font-size: var(--font-xs); + font-weight: var(--weight-semi); + color: var(--color-text); +} + +.dots { + display: inline-flex; + align-items: center; + gap: var(--s-1); +} + +.dot { + width: 8px; + height: 8px; + border-radius: var(--radius-full); + flex-shrink: 0; + + &[data-status="available"] { + background-color: var(--color-success); + } + + &[data-status="partial"] { + background-color: var(--color-warning); + } +} + +.subsSection { + display: flex; + flex-direction: column; + gap: var(--s-1-5); + border-top: 1px solid var(--color-border); + padding-top: var(--s-2); +} + +.subsHeading { + font-size: var(--font-xs); + color: var(--color-text-high); + text-transform: uppercase; + letter-spacing: 0.5px; +} + +.iconAvailable { + color: var(--color-success); +} + +.iconPartial { + color: var(--color-warning); +} + +.iconUnavailable { + color: var(--color-error); +} + +.iconUnknown { + color: var(--color-text-high); +} diff --git a/app/features/availability/components/RegistrationAvailabilityPanel.tsx b/app/features/availability/components/RegistrationAvailabilityPanel.tsx new file mode 100644 index 000000000..4314451b2 --- /dev/null +++ b/app/features/availability/components/RegistrationAvailabilityPanel.tsx @@ -0,0 +1,354 @@ +import clsx from "clsx"; +import { + CalendarX, + Check, + Clock, + Ellipsis, + EyeOff, + Flag, + X, +} from "lucide-react"; +import type * as React from "react"; +import { useTranslation } from "react-i18next"; +import { Avatar } from "~/components/Avatar"; +import { useDateTimeFormat } from "~/hooks/intl/useDateTimeFormat"; +import type { SerializeFrom } from "~/utils/remix"; +import type { TimeRange, WindowAvailabilityEntry } from "../availability-types"; +import type { RegistrationAvailability } from "../core/RegistrationAvailability.server"; +import styles from "./RegistrationAvailabilityPanel.module.css"; +import { useRangeText } from "./ScheduleDayCell"; + +export interface AvailabilityPanelUser { + id: number; + username: string; + discordId: string; + discordAvatar: string | null; + customAvatarUrl?: string | null; +} + +export type AvailabilityPanelData = SerializeFrom; +export type AvailabilityPanelEntry = WindowAvailabilityEntry; + +export type AvailabilityRowStatus = + | AvailabilityPanelEntry["availability"]["status"] + /** On the roster, but their schedule is not visible to the viewer (neither a teammate nor a friend). */ + | "hidden"; + +/** + * The tournament registration page's availability panel: how each member of + * the roster relates to the event's estimated window, plus the friends who + * could sub (the ones actually free during it). + */ +export function RegistrationAvailabilityPanel({ + availability, + roster, + subCandidates, +}: { + availability: AvailabilityPanelData; + roster: Array; + /** Friends not on the shown roster and not in the tournament, panel keeps the free ones. */ + subCandidates: Array; +}) { + const { t } = useTranslation(["schedule"]); + const { formatter: dateFormatter } = useDateTimeFormat({ + month: "long", + day: "numeric", + }); + + if (availability.beyondHorizon) { + return ( +
+

{t("schedule:registration.title")}

+
+ {t("schedule:registration.beyondHorizon", { + date: dateFormatter.format(availability.beyondHorizon.opensAt), + })} +
+
+ ); + } + + const entryByUserId = new Map( + availability.entries.map((entry) => [entry.userId, entry]), + ); + + const freeSubs = subCandidates.filter((user) => { + const status = entryByUserId.get(user.id)?.availability.status; + return status === "available" || status === "partial"; + }); + + if (roster.length === 0 && freeSubs.length === 0) return null; + + const freeSubRows = ( +
    + {freeSubs.map((user) => ( + + ))} +
+ ); + + return ( +
+

+ {t("schedule:registration.title")} ·{" "} + +

+ {roster.length > 0 ? ( + <> +
    + {roster.map((user) => ( + + ))} +
+ + availabilityRowStatus(entryByUserId.get(user.id)), + )} + /> + + ) : null} + {freeSubs.length > 0 ? ( + roster.length > 0 ? ( +
+
+ {t("schedule:registration.friends")} +
+ {freeSubRows} +
+ ) : ( + freeSubRows + ) + ) : null} +
+ ); +} + +/** + * One user's availability as a list row: status icon, avatar, name and the + * availability detail. The registration page composes it with roster extras + * (an in-game name line, a remove button). + */ +export function AvailabilityMemberRow({ + user, + entry, + showAvailability = true, + primaryName, + secondaryName, + trailing, + nameTestId, +}: { + user: AvailabilityPanelUser; + entry?: AvailabilityPanelEntry; + /** Set false when there is no availability data for the event (e.g. leagues), keeping just avatar + name. */ + showAvailability?: boolean; + primaryName?: string; + secondaryName?: string; + trailing?: React.ReactNode; + nameTestId?: string; +}) { + const status = availabilityRowStatus(entry); + + return ( +
  • + {showAvailability ? : null} + + + {primaryName ?? user.username} + {secondaryName ? ( + {secondaryName} + ) : null} + + {showAvailability ? : null} + {showAvailability + ? entry?.notes?.map((note, index) => ( + + {note} + + )) + : null} + {trailing ? {trailing} : null} +
  • + ); +} + +/** + * Resolves the shown status for a roster member; no entry at all means their + * schedule is not visible to the viewer. + */ +export function availabilityRowStatus( + entry?: AvailabilityPanelEntry, +): AvailabilityRowStatus { + return entry?.availability.status ?? "hidden"; +} + +/** The availability detail text of one user: free ranges, a busy block or a muted explanation. */ +export function AvailabilityRowDetail({ + entry, +}: { + entry?: AvailabilityPanelEntry; +}) { + const { t } = useTranslation(["schedule"]); + + if (!entry) { + return null; + } + + const availability = entry.availability; + + switch (availability.status) { + case "available": + case "partial": + return ; + case "unavailable": + return ( + + {t("schedule:team.notAvailable")} + + ); + case "unknown": + return ( + + {t("schedule:team.noSchedule")} + + ); + case "busy": + return ( + + + {availability.block.name ?? t("schedule:commitment.scrim")} + + + ); + } +} + +/** The event's estimated window as a localized time range, e.g. "Tue, Aug 25, 10:32 AM – 2:32 PM (estimated)". */ +export function AvailabilityWindowText({ + window, +}: { + window: NonNullable; +}) { + const { t } = useTranslation(["schedule"]); + const { formatter } = useDateTimeFormat({ + weekday: "short", + month: "short", + day: "numeric", + hour: "numeric", + minute: "2-digit", + }); + + return ( + + {formatter.formatRange(window.startsAt, window.endsAt)} ( + {t("schedule:registration.estimated")}) + + ); +} + +/** Counts by status, e.g. "2 available · 1 partial · 1 out". */ +export function AvailabilitySummary({ + statuses, + className, +}: { + statuses: Array; + className?: string; +}) { + const { t } = useTranslation(["schedule"]); + + const counts = { available: 0, partial: 0, out: 0, unknown: 0 }; + for (const status of statuses) { + if (status === "available") counts.available++; + else if (status === "partial") counts.partial++; + else if (status === "unavailable" || status === "busy") counts.out++; + else counts.unknown++; + } + + const parts = (["available", "partial", "out", "unknown"] as const).flatMap( + (key) => + counts[key] > 0 + ? [t(`schedule:registration.summary.${key}`, { amount: counts[key] })] + : [], + ); + + return ( + {parts.join(" · ")} + ); +} + +/** A green dot per available member and a yellow dot per partially available one; other statuses show no dot. */ +export function AvailabilityStatusDots({ + statuses, +}: { + statuses: Array; +}) { + const shown = [ + ...statuses.filter((status) => status === "available"), + ...statuses.filter((status) => status === "partial"), + ]; + if (shown.length === 0) return null; + + return ( + + {shown.map((status, i) => ( + + ))} + + ); +} + +function RangesText({ ranges }: { ranges: Array }) { + const rangeText = useRangeText(); + + return ( + {ranges.map(rangeText).join(" · ")} + ); +} + +function StatusIcon({ status }: { status: AvailabilityRowStatus }) { + return ( + + {statusGlyph(status)} + + ); +} + +function statusGlyph(status: AvailabilityRowStatus) { + switch (status) { + case "available": + return ( + + ); + case "partial": + return ; + case "unavailable": + return ; + case "busy": + return ( + + ); + case "unknown": + return ( + + ); + case "hidden": + return ( + + ); + } +} diff --git a/app/features/availability/components/ScheduleDayCell.module.css b/app/features/availability/components/ScheduleDayCell.module.css new file mode 100644 index 000000000..b9b536f99 --- /dev/null +++ b/app/features/availability/components/ScheduleDayCell.module.css @@ -0,0 +1,44 @@ +.content { + display: flex; + flex-direction: column; + gap: var(--s-0-5); +} + +.range { + white-space: nowrap; +} + +.unknown, +.unavailable { + 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/components/ScheduleDayCell.tsx b/app/features/availability/components/ScheduleDayCell.tsx new file mode 100644 index 000000000..e3c4311be --- /dev/null +++ b/app/features/availability/components/ScheduleDayCell.tsx @@ -0,0 +1,94 @@ +import { isSameDay } from "date-fns"; +import { Flag } from "lucide-react"; +import { useTranslation } from "react-i18next"; +import { useDateTimeFormat } from "~/hooks/intl/useDateTimeFormat"; +import { databaseTimestampToDate } from "~/utils/dates"; +import type { BusyBlock, TimeRange } from "../availability-types"; +import styles from "./ScheduleDayCell.module.css"; + +/** + * One day of one person's week: the ranges they are effectively free for and, + * where the surface shows them, the commitments taking time back and the note + * they left on the day. Shared by the team schedule grid and the single person + * week view so the two cannot drift apart. + */ +export function ScheduleDayCell({ + reported, + ranges, + busy = [], + note, +}: { + /** False when they have not filled the week in at all, which reads differently from being unavailable. */ + reported: boolean; + ranges: Array; + busy?: Array; + note?: string; +}) { + const { t } = useTranslation(["schedule"]); + const rangeText = useRangeText(); + + const busyName = (block: BusyBlock) => + block.name ?? t("schedule:commitment.scrim"); + + return ( +
    + {!reported ? ( + + ? + + ) : ranges.length === 0 && busy.length === 0 ? ( + + — + + ) : ( + ranges.map((range) => ( +
    + {rangeText(range)} +
    + )) + )} + {busy.map((block, index) => ( +
    + {busyName(block)} +
    + ))} + {note ? ( + + + + ) : null} +
    + ); +} + +/** + * Formats a range as times only. `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. + */ +export function useRangeText() { + const { formatter } = useDateTimeFormat({ + hour: "numeric", + minute: "2-digit", + }); + + return (range: TimeRange) => + isSameDay( + databaseTimestampToDate(range.startsAt), + databaseTimestampToDate(range.endsAt), + ) + ? formatter.formatRange(range.startsAt, range.endsAt) + : `${formatter.format(range.startsAt)} – ${formatter.format(range.endsAt)}`; +} diff --git a/app/features/availability/components/ScheduleNudge.module.css b/app/features/availability/components/ScheduleNudge.module.css new file mode 100644 index 000000000..7ed4c490d --- /dev/null +++ b/app/features/availability/components/ScheduleNudge.module.css @@ -0,0 +1,37 @@ +.container { + display: flex; + align-items: center; + gap: var(--s-2); + padding: var(--s-1-5) var(--s-2); + border-bottom: 1.5px solid var(--color-border); +} + +/** Flush against the events header, bleeding past the sidebar's own padding. */ +.sidebar { + margin-block-start: calc(-1 * var(--s-2)); + margin-inline: calc(-1 * var(--s-1-5)); +} + +/** Same, for the mobile events panel. */ +.panel { + margin-block-start: calc(-1 * var(--s-2)); + margin-inline: calc(-1 * var(--s-2)); +} + +.link { + display: flex; + align-items: center; + gap: var(--s-2); + font-size: var(--font-2xs); + font-weight: var(--weight-bold); + color: var(--color-text-accent); + + &:hover { + text-decoration: underline; + } +} + +.dismissButton { + margin-inline-start: auto; + color: var(--color-text-high); +} diff --git a/app/features/availability/components/ScheduleNudge.tsx b/app/features/availability/components/ScheduleNudge.tsx new file mode 100644 index 000000000..16f2f00a6 --- /dev/null +++ b/app/features/availability/components/ScheduleNudge.tsx @@ -0,0 +1,63 @@ +import clsx from "clsx"; +import { CalendarPlus, X } from "lucide-react"; +import * as React from "react"; +import { useTranslation } from "react-i18next"; +import { Link } from "react-router"; +import { SendouButton } from "~/components/elements/Button"; +import { useActionSubmit } from "~/hooks/useActionSubmit"; +import { EVENTS_PAGE } from "~/utils/urls"; +import { dismissScheduleNudgeSchema } from "../availability-schemas"; +import { scheduleWeekSearchParams } from "../availability-search-params"; +import styles from "./ScheduleNudge.module.css"; + +/** + * Prompt to report next week's availability, shown on the last day of the week + * while next week is still empty. Sits as a band right under the events header. + * Dismissing it is remembered for the week, so it can be waved away without + * filling anything in. + */ +export function ScheduleNudge({ + panel, + onNavigate, +}: { + /** Bleeds past the mobile events panel's padding rather than the sidebar's. */ + panel?: boolean; + onNavigate?: () => void; +}) { + const { t } = useTranslation(["front"]); + const [dismissed, setDismissed] = React.useState(false); + const { submit } = useActionSubmit(dismissScheduleNudgeSchema, { + action: EVENTS_PAGE, + encType: "application/json", + }); + + if (dismissed) return null; + + const dismiss = () => { + setDismissed(true); + submit("DISMISS_SCHEDULE_NUDGE", { revalidateRoot: true }); + }; + + return ( +
    + + + {t("front:sideNav.scheduleNudge")} + + } + variant="minimal" + size="miniscule" + className={styles.dismissButton} + aria-label={t("front:sideNav.scheduleNudge.dismiss")} + onPress={dismiss} + /> +
    + ); +} diff --git a/app/features/availability/components/ScheduleTracks.module.css b/app/features/availability/components/ScheduleTracks.module.css new file mode 100644 index 000000000..64f74a5c7 --- /dev/null +++ b/app/features/availability/components/ScheduleTracks.module.css @@ -0,0 +1,202 @@ +.container { + container: tracks / inline-size; +} + +.tracks { + display: none; +} + +.list { + display: flex; + flex-direction: column; +} + +@container tracks (min-width: 40rem) { + .tracks { + display: grid; + grid-template-columns: max-content minmax(0, 1fr) max-content; + column-gap: var(--s-3); + row-gap: var(--s-2); + align-items: center; + } + + .list { + display: none; + } +} + +.axisToggle { + display: flex; + align-items: center; + gap: 2px; + align-self: end; + padding: 0; + background: none; + border: none; + font-size: var(--font-3xs); + line-height: 1rem; + color: var(--color-text-high); + white-space: nowrap; + cursor: pointer; + + &:hover { + color: var(--color-text); + } + + &:focus-visible { + outline: var(--focus-ring); + } + + &.axisLead { + justify-self: start; + } + + &.axisTrail { + justify-self: end; + } +} + +.axis { + position: relative; + height: 1rem; + align-self: end; + + & .axisLabel { + position: absolute; + top: 0; + transform: translateX(-50%); + font-size: var(--font-3xs); + line-height: 1rem; + color: var(--color-text-high); + white-space: nowrap; + + &.axisLabelFirst { + transform: translateX(0); + } + + &.axisLabelLast { + transform: translateX(-100%); + } + } +} + +.dayLabel { + display: flex; + align-items: center; + gap: var(--s-1); + font-size: var(--font-xs); + font-weight: var(--weight-semi); + white-space: nowrap; +} + +.noteFlag { + color: var(--color-text-accent); + flex-shrink: 0; +} + +.track { + position: relative; + height: 32px; + background-color: var(--color-bg-high); + border-radius: var(--radius-field); + touch-action: none; + user-select: none; +} + +.tick { + position: absolute; + top: 0; + bottom: 0; + width: 1px; + background-color: var(--color-bg-higher); + pointer-events: none; + + &.tickMidnight { + background-color: var(--color-border); + } +} + +.commitment { + position: absolute; + top: 3px; + bottom: 3px; + display: flex; + align-items: center; + justify-content: center; + overflow: hidden; + background: repeating-linear-gradient( + -45deg, + var(--color-bg-higher) 0 5px, + transparent 5px 10px + ); + border: 1px dashed var(--color-border-high); + border-radius: var(--radius-field); + pointer-events: none; + z-index: 2; +} + +.commitmentName { + 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); +} + +.commitmentChip { + padding: var(--s-0-5) var(--s-2); + background: repeating-linear-gradient( + -45deg, + var(--color-bg-higher) 0 5px, + transparent 5px 10px + ); + border: 1px dashed var(--color-border-high); + border-radius: var(--radius-full); + font-size: var(--font-xs); + color: var(--color-text-high); +} + +.listDay { + display: flex; + flex-direction: column; + gap: var(--s-1-5); + padding-block: var(--s-2); + border-bottom: 1px solid var(--color-bg-higher); + + &:last-child { + border-bottom: none; + } +} + +.listDayHeader { + display: flex; + align-items: center; + gap: var(--s-1); + font-size: var(--font-xs); + font-weight: var(--weight-semi); +} + +.listDayBody { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: var(--s-1-5); +} + +.timeChip { + padding: var(--s-0-5) var(--s-2); + background-color: var(--color-success-low); + border: 1px solid var(--color-success); + border-radius: var(--radius-full); + font-size: var(--font-xs); + color: var(--color-text); + cursor: pointer; + + &:focus-visible { + outline: var(--focus-ring); + } +} diff --git a/app/features/availability/components/ScheduleTracks.tsx b/app/features/availability/components/ScheduleTracks.tsx new file mode 100644 index 000000000..477a519a1 --- /dev/null +++ b/app/features/availability/components/ScheduleTracks.tsx @@ -0,0 +1,217 @@ +import clsx from "clsx"; +import { ChevronLeft, ChevronRight } from "lucide-react"; +import * as React from "react"; +import { useTranslation } from "react-i18next"; +import { useDateTimeFormat } from "~/hooks/intl/useDateTimeFormat"; +import { AVAILABILITY } from "../availability-constants"; +import type { DayTimeRange } from "../availability-types"; +import styles from "./ScheduleTracks.module.css"; + +const AXIS_LABEL_EVERY_HOURS = 2; +const MINUTES_IN_HOUR = 60; +/** However little is on the tracks, a compact window still reads as a stretch of a day. */ +const MIN_FITTED_SPAN_MINUTES = 4 * MINUTES_IN_HOUR; + +export type ClockWindow = ReturnType; + +/** + * The hours the day tracks put on screen and how a range of a day maps onto + * them. Defaults to the evening hours people play in, the expanders widening + * it towards the morning on either end. + * + * `fitTo` compacts it around what is actually on the tracks instead, for views + * that only show availability: the width then goes to the bars and their + * labels rather than to hours nobody is free in. The expanders still open the + * full day either way. + */ +export function useClockWindow({ + fitTo, + expandTo, +}: { + /** Everything drawn on the tracks, in minutes from their own day's midnight. */ + fitTo?: Array; + /** + * Content the window must reach even when it falls outside the default + * hours (a time typed by hand, a week saved in another timezone), for + * views that keep the default window otherwise — the editor. + */ + expandTo?: Array; +} = {}) { + const [earlierShown, setEarlierShown] = React.useState(false); + const [laterShown, setLaterShown] = React.useState(false); + + const fitted = fittedWindow(fitTo); + const expanded = fittedWindow(expandTo); + const defaultStart = + fitted?.start ?? + Math.min( + expanded?.start ?? Number.POSITIVE_INFINITY, + AVAILABILITY.TRACK_START_MINUTES, + ); + const defaultEnd = + fitted?.end ?? + Math.max( + expanded?.end ?? Number.NEGATIVE_INFINITY, + AVAILABILITY.TRACK_END_MINUTES, + ); + + const trackStart = earlierShown + ? Math.min(AVAILABILITY.TRACK_EARLIER_START_MINUTES, defaultStart) + : defaultStart; + const trackEnd = laterShown + ? Math.max(AVAILABILITY.TRACK_LATER_END_MINUTES, defaultEnd) + : defaultEnd; + + const pct = (minutes: number) => + ((Math.min(Math.max(minutes, trackStart), trackEnd) - trackStart) / + (trackEnd - trackStart)) * + 100; + + const hours: Array = []; + for ( + let hour = trackStart / MINUTES_IN_HOUR; + hour <= trackEnd / MINUTES_IN_HOUR; + hour += AXIS_LABEL_EVERY_HOURS + ) { + hours.push(hour); + } + + return { + trackStart, + trackEnd, + hours, + earlierShown, + setEarlierShown, + laterShown, + setLaterShown, + pct, + barStyle: (range: DayTimeRange) => ({ + left: `${pct(range.start)}%`, + width: `${pct(range.end) - pct(range.start)}%`, + }), + }; +} + +/** + * Whole hours around everything on the tracks, the span rounded up so that a + * label lands on both edges. Null when there is nothing to fit, leaving the + * default window in place. + */ +function fittedWindow(ranges?: Array) { + if (!ranges || ranges.length === 0) return null; + + const start = + Math.floor( + Math.min(...ranges.map((range) => range.start)) / MINUTES_IN_HOUR, + ) * MINUTES_IN_HOUR; + const end = + Math.ceil(Math.max(...ranges.map((range) => range.end)) / MINUTES_IN_HOUR) * + MINUTES_IN_HOUR; + + const labelStep = AXIS_LABEL_EVERY_HOURS * MINUTES_IN_HOUR; + const span = Math.max(end - start, MIN_FITTED_SPAN_MINUTES); + + return { start, end: start + Math.ceil(span / labelStep) * labelStep }; +} + +/** The hour labels above the day tracks, with the expanders widening the clock window. */ +export function ClockAxis({ + clockWindow, + dayStartsAt, +}: { + clockWindow: ClockWindow; + /** Midnight of any of the shown days, the hour labels are read off it. */ + dayStartsAt: Date; +}) { + const { t } = useTranslation(["schedule"]); + const { formatter } = useDateTimeFormat({ hour: "numeric" }); + + const hourAt = (hour: number) => + new Date(dayStartsAt.getTime() + hour * MINUTES_IN_HOUR * 60 * 1000); + + return ( + <> + +
    + {clockWindow.hours.map((hour) => ( + + {formatter.format(hourAt(hour))} + + ))} +
    + + + ); +} + +/** The hour gridlines of one day track, midnight drawn stronger than the rest. */ +export function TrackTicks({ clockWindow }: { clockWindow: ClockWindow }) { + return clockWindow.hours + .filter( + (hour) => + hour * MINUTES_IN_HOUR > clockWindow.trackStart && + hour * MINUTES_IN_HOUR < clockWindow.trackEnd, + ) + .map((hour) => ( +
    + )); +} + +/** A commitment on a day track: a hatched block naming what the time is taken by. */ +export function TrackCommitment({ + clockWindow, + range, + name, +}: { + clockWindow: ClockWindow; + range: DayTimeRange; + name: string; +}) { + return ( +
    + {name} +
    + ); +} diff --git a/app/features/availability/components/ScheduleWeekDialog.module.css b/app/features/availability/components/ScheduleWeekDialog.module.css new file mode 100644 index 000000000..d98922a0d --- /dev/null +++ b/app/features/availability/components/ScheduleWeekDialog.module.css @@ -0,0 +1,40 @@ +.header { + display: flex; + flex-wrap: wrap; + align-items: center; + justify-content: space-between; + gap: var(--s-2); +} + +.weekLabel { + font-size: var(--font-xs); + font-weight: var(--weight-semi); + color: var(--color-text-high); +} + +.days { + display: flex; + flex-direction: column; + padding: 0; + margin: 0; + list-style: none; + font-size: var(--font-xs); +} + +.day { + display: flex; + align-items: baseline; + gap: var(--s-3); + padding-block: var(--s-1-5); + + &:not(:first-child) { + border-top: var(--border-style); + } +} + +.dayLabel { + flex-shrink: 0; + width: 4.5rem; + font-weight: var(--weight-semi); + color: var(--color-text-high); +} diff --git a/app/features/availability/components/ScheduleWeekDialog.tsx b/app/features/availability/components/ScheduleWeekDialog.tsx new file mode 100644 index 000000000..e8861d25b --- /dev/null +++ b/app/features/availability/components/ScheduleWeekDialog.tsx @@ -0,0 +1,85 @@ +import { useTranslation } from "react-i18next"; +import { SendouDialog } from "~/components/elements/Dialog"; +import { useDateTimeFormat } from "~/hooks/intl/useDateTimeFormat"; +import { useSearchParamsTyped } from "~/modules/search-params/hooks"; +import { scheduleWeekSearchParams } from "../availability-search-params"; +import type { ScheduleWeekView } from "../availability-types"; +import { ScheduleDayCell } from "./ScheduleDayCell"; +import styles from "./ScheduleWeekDialog.module.css"; +import { WeekToggle } from "./WeekToggle"; + +/** + * One person's reportable weeks as a read-only day-by-day list of the time + * they are free to play. + */ +export function ScheduleWeekDialog({ + username, + weeks, + onClose, +}: { + username: string; + weeks: Array; + onClose: () => void; +}) { + const { t } = useTranslation(["schedule"]); + const [{ week }, setParams] = useSearchParamsTyped(scheduleWeekSearchParams); + const { formatter: headingFormatter } = useDateTimeFormat({ + month: "short", + day: "numeric", + }); + + const shownWeek = + weeks.find((candidate) => candidate.week === week) ?? weeks[0]; + + return ( + +
    +
    + + {t("schedule:team.weekHeading", { week: shownWeek.weekNumber })} ·{" "} + {headingFormatter.formatRange( + shownWeek.days[0].noonAt, + shownWeek.days[6].noonAt, + )} + + setParams({ week: value })} + /> +
    + {shownWeek.reported ? ( + + ) : ( +
    + {t("schedule:team.noSchedule")} +
    + )} +
    +
    + ); +} + +function WeekDays({ week }: { week: ScheduleWeekView }) { + const { formatter: dayFormatter } = useDateTimeFormat({ + weekday: "short", + day: "numeric", + }); + + return ( +
      + {week.days.map((day) => ( +
    • + + {dayFormatter.format(day.noonAt)} + + +
    • + ))} +
    + ); +} diff --git a/app/features/availability/components/WeekAvailabilityEditor.module.css b/app/features/availability/components/WeekAvailabilityEditor.module.css new file mode 100644 index 000000000..dfde1ea51 --- /dev/null +++ b/app/features/availability/components/WeekAvailabilityEditor.module.css @@ -0,0 +1,181 @@ +.paintable { + cursor: crosshair; +} + +.editor { + display: flex; + flex-direction: column; + gap: var(--s-3); +} + +.bar { + container: bar / inline-size; + position: absolute; + top: 3px; + bottom: 3px; + display: flex; + align-items: center; + justify-content: center; + padding: 0; + background-color: var(--color-success-low); + border: 1px solid var(--color-success); + border-radius: var(--radius-field); + cursor: grab; + z-index: 1; + + &:focus-visible { + outline: var(--focus-ring); + } +} + +.barPreview { + border-style: dashed; + opacity: 0.7; + pointer-events: none; +} + +.barTimes, +.barTimesShort { + display: none; + max-width: 100%; + padding-inline: var(--s-1); + font-size: var(--font-3xs); + color: var(--color-text); + white-space: nowrap; + overflow: hidden; + pointer-events: none; +} + +@container bar (min-width: 3rem) { + .barTimesShort { + display: block; + } +} + +@container bar (min-width: 7.5rem) { + .barTimes { + display: block; + } + + .barTimesShort { + display: none; + } +} + +.handle { + position: absolute; + top: 0; + bottom: 0; + width: 8px; + cursor: ew-resize; + + &.handleStart { + left: -2px; + } + + &.handleEnd { + right: -2px; + } +} + +.fillHandle { + position: absolute; + right: 8px; + bottom: -5px; + width: 10px; + height: 10px; + background-color: var(--color-success); + border: 2px solid var(--color-bg); + border-radius: var(--radius-full); + cursor: ns-resize; + opacity: 0; + + .bar:hover &, + .bar:focus-visible & { + opacity: 1; + } +} + +.liveLabel { + position: absolute; + bottom: calc(100% + 4px); + z-index: 3; + padding: 0 var(--s-1-5); + background-color: var(--color-bg-higher); + border-radius: var(--radius-field); + font-size: var(--font-2xs); + white-space: nowrap; + pointer-events: none; +} + +.editButton { + display: flex; + align-items: center; + justify-content: center; + padding: var(--s-1); + background: none; + border: none; + border-radius: var(--radius-field); + color: var(--color-text-high); + cursor: pointer; + + &:hover { + color: var(--color-text); + } + + &:focus-visible { + outline: var(--focus-ring); + } +} + +.footer { + font-size: var(--font-xs); + color: var(--color-text-high); +} + +.addChip { + display: inline-flex; + align-items: center; + gap: var(--s-0-5); + padding: var(--s-0-5) var(--s-1); + background: none; + border: none; + border-radius: var(--radius-full); + font-size: var(--font-xs); + color: var(--color-text-accent); + cursor: pointer; + + &:focus-visible { + outline: var(--focus-ring); + } +} + +.listNote { + display: flex; + align-items: center; + gap: var(--s-1); + font-size: var(--font-2xs); + color: var(--color-text-high); +} + +.dayEditor { + display: flex; + flex-direction: column; + gap: var(--s-3); + min-width: 240px; +} + +.dayEditorTitle { + font-size: var(--font-sm); + font-weight: var(--weight-bold); +} + +.dayEditorRange { + display: flex; + align-items: flex-end; + gap: var(--s-2); +} + +.dayEditorAdd { + align-self: start; +} diff --git a/app/features/availability/components/WeekAvailabilityEditor.tsx b/app/features/availability/components/WeekAvailabilityEditor.tsx new file mode 100644 index 000000000..dc1b6b58b --- /dev/null +++ b/app/features/availability/components/WeekAvailabilityEditor.tsx @@ -0,0 +1,778 @@ +import clsx from "clsx"; +import { Flag, Plus, SquarePen, Trash } from "lucide-react"; +import * as React from "react"; +import { useTranslation } from "react-i18next"; +import * as R from "remeda"; +import { SendouButton } from "~/components/elements/Button"; +import { SendouAnchoredPopover } from "~/components/elements/Popover"; +import { Input } from "~/components/Input"; +import { Label } from "~/components/Label"; +import { TimeRangeFormField } from "~/form/fields/TimeRangeFormField"; +import { useDateTimeFormat } from "~/hooks/intl/useDateTimeFormat"; +import { AVAILABILITY } from "../availability-constants"; +import type { + AvailabilityEditorDay, + AvailabilityEditorWeek, + DayTimeRange, + EditorCommitment, +} from "../availability-types"; +import * as Availability from "../core/Availability"; +import { + ClockAxis, + TrackCommitment, + TrackTicks, + useClockWindow, +} from "./ScheduleTracks"; +import trackStyles from "./ScheduleTracks.module.css"; +import styles from "./WeekAvailabilityEditor.module.css"; + +const MOVE_THRESHOLD_PX = 4; + +type Gesture = + | { + type: "paint"; + dayIndex: number; + anchor: number; + range: DayTimeRange | null; + } + | { + type: "move"; + dayIndex: number; + original: DayTimeRange; + range: DayTimeRange; + startClientX: number; + moved: boolean; + } + | { + type: "resize"; + dayIndex: number; + original: DayTimeRange; + edge: "start" | "end"; + range: DayTimeRange; + } + | { + type: "fill"; + dayIndex: number; + range: DayTimeRange; + targetDayIndex: number; + }; + +interface DraftRange { + id: number; + start: string; + end: string; +} + +interface DayDraft { + ranges: Array; + note: string; +} + +/** + * One week of the user's own availability as an editable timeline: on wide + * containers each day is a track where ranges are painted, moved, resized and + * drag-filled with the pointer; on narrow containers a stacked per-day list. + * Both share the same popover with exact time inputs and the day note, which + * is also the keyboard path. Commitments render as locked blocks on the + * tracks; gestures may cross them, but a new range cannot start on one. + */ +export function WeekAvailabilityEditor({ + value, + onChange, + commitments = [], + onPendingDraftChange, +}: { + value: AvailabilityEditorWeek; + onChange: (value: AvailabilityEditorWeek) => void; + commitments?: Array; + /** Reports edits typed in the day popover but not yet committed into `value`, which an unsaved changes guard would otherwise miss. */ + onPendingDraftChange?: (hasPendingDraft: boolean) => void; +}) { + const { t } = useTranslation(["schedule", "common"]); + const { formatter: dayFormatter } = useDateTimeFormat({ + weekday: "short", + day: "numeric", + }); + const { formatter: timeFormatter } = useDateTimeFormat({ + hour: "numeric", + minute: "2-digit", + }); + + const clockWindow = useClockWindow({ + expandTo: [ + ...value.flatMap((day) => day.ranges), + ...commitments.map((commitment) => commitment.range), + ], + }); + const { trackStart, trackEnd, pct, barStyle } = clockWindow; + const gestureWindow = { + trackStart: Math.min(trackStart, AVAILABILITY.TRACK_EARLIER_START_MINUTES), + trackEnd: Math.max(trackEnd, AVAILABILITY.TRACK_LATER_END_MINUTES), + }; + const [gesture, setGesture] = React.useState(null); + const gestureRef = React.useRef(null); + const [openDayDate, setOpenDayDate] = React.useState(null); + const [openDayAddRow, setOpenDayAddRow] = React.useState(false); + const popoverAnchorRef = React.useRef(null); + const dayDraftRef = React.useRef(null); + const trackRefs = React.useRef>([]); + const pressPointRef = React.useRef<{ x: number; y: number } | null>(null); + + const wallsOf = (date: string) => + commitments + .filter((commitment) => commitment.date === date) + .map((commitment) => commitment.range); + + const dateAt = (date: string, minutes: number) => { + const [year, month, day] = date.split("-").map(Number); + + return new Date(year, month - 1, day, 0, minutes); + }; + + const dayLabelText = (day: AvailabilityEditorDay) => + dayFormatter.format(dateAt(day.date, 12 * 60)); + + const rangeText = (date: string, range: DayTimeRange) => + `${timeFormatter.format(dateAt(date, range.start))} – ${timeFormatter.format(dateAt(date, range.end))}`; + + const minutesAt = (dayIndex: number, clientX: number) => { + const track = trackRefs.current[dayIndex]; + if (!track) return trackStart; + + const rect = track.getBoundingClientRect(); + const fraction = (clientX - rect.left) / rect.width; + + return R.clamp(trackStart + fraction * (trackEnd - trackStart), { + min: gestureWindow.trackStart, + max: gestureWindow.trackEnd, + }); + }; + + const pxToMinutes = (dayIndex: number, px: number) => { + const track = trackRefs.current[dayIndex]; + if (!track) return 0; + + return (px / track.getBoundingClientRect().width) * (trackEnd - trackStart); + }; + + const dayIndexAt = (clientY: number) => { + let closest = 0; + let closestDistance = Number.POSITIVE_INFINITY; + + for (const [index, track] of trackRefs.current.entries()) { + if (!track) continue; + + const rect = track.getBoundingClientRect(); + const center = rect.top + rect.height / 2; + const distance = Math.abs(clientY - center); + + if (distance < closestDistance) { + closest = index; + closestDistance = distance; + } + } + + return closest; + }; + + const applyGesture = (next: Gesture | null) => { + gestureRef.current = next; + setGesture(next); + }; + + const replaceDayRanges = (dayIndex: number, ranges: Array) => { + onChange( + value.map((day, index) => + index === dayIndex ? { ...day, ranges } : day, + ), + ); + }; + + const handleTrackPointerDown = + (dayIndex: number) => (event: React.PointerEvent) => { + if (event.button !== 0) return; + if (event.target !== event.currentTarget) return; + if (gestureRef.current) return; + + event.currentTarget.setPointerCapture(event.pointerId); + applyGesture({ + type: "paint", + dayIndex, + anchor: minutesAt(dayIndex, event.clientX), + range: null, + }); + }; + + const handleBarPointerDown = + (dayIndex: number, range: DayTimeRange) => + (event: React.PointerEvent) => { + if (event.button !== 0) return; + if (gestureRef.current) return; + + event.stopPropagation(); + event.currentTarget.setPointerCapture(event.pointerId); + pressPointRef.current = { x: event.clientX, y: event.clientY }; + applyGesture({ + type: "move", + dayIndex, + original: range, + range, + startClientX: event.clientX, + moved: false, + }); + }; + + const handleResizePointerDown = + (dayIndex: number, range: DayTimeRange, edge: "start" | "end") => + (event: React.PointerEvent) => { + if (event.button !== 0) return; + if (gestureRef.current) return; + + event.stopPropagation(); + event.currentTarget.setPointerCapture(event.pointerId); + pressPointRef.current = { x: event.clientX, y: event.clientY }; + applyGesture({ type: "resize", dayIndex, original: range, edge, range }); + }; + + const handleFillPointerDown = + (dayIndex: number, range: DayTimeRange) => + (event: React.PointerEvent) => { + if (event.button !== 0) return; + if (gestureRef.current) return; + + event.stopPropagation(); + event.currentTarget.setPointerCapture(event.pointerId); + pressPointRef.current = { x: event.clientX, y: event.clientY }; + applyGesture({ type: "fill", dayIndex, range, targetDayIndex: dayIndex }); + }; + + const handleGestureMove = (event: React.PointerEvent) => { + const current = gestureRef.current; + if (!current) return; + + switch (current.type) { + case "paint": { + applyGesture({ + ...current, + range: Availability.paintedRange({ + anchor: current.anchor, + cursor: minutesAt(current.dayIndex, event.clientX), + walls: wallsOf(value[current.dayIndex].date), + ...gestureWindow, + }), + }); + break; + } + case "move": { + const moved = + current.moved || + Math.abs(event.clientX - current.startClientX) > MOVE_THRESHOLD_PX; + if (!moved) return; + + applyGesture({ + ...current, + moved, + range: Availability.movedRange({ + range: current.original, + delta: pxToMinutes( + current.dayIndex, + event.clientX - current.startClientX, + ), + ...gestureWindow, + }), + }); + break; + } + case "resize": { + applyGesture({ + ...current, + range: Availability.resizedRange({ + range: current.original, + edge: current.edge, + cursor: minutesAt(current.dayIndex, event.clientX), + ...gestureWindow, + }), + }); + break; + } + case "fill": { + applyGesture({ ...current, targetDayIndex: dayIndexAt(event.clientY) }); + break; + } + } + }; + + const handleGestureEnd = () => { + const current = gestureRef.current; + if (!current) return; + + if (current.type === "paint" && current.range) { + const painted = current.range; + replaceDayRanges( + current.dayIndex, + Availability.mergedDayRanges([ + ...value[current.dayIndex].ranges, + painted, + ]), + ); + } else if ( + (current.type === "move" && current.moved) || + current.type === "resize" + ) { + replaceDayRanges( + current.dayIndex, + Availability.mergedDayRanges([ + ...value[current.dayIndex].ranges.filter( + (range) => !sameRange(range, current.original), + ), + current.range, + ]), + ); + } else if (current.type === "fill") { + onChange( + value.map((day, index) => { + if ( + index === current.dayIndex || + !isBetween(index, current.dayIndex, current.targetDayIndex) + ) { + return day; + } + + return { + ...day, + ranges: Availability.mergedDayRanges([ + ...day.ranges, + current.range, + ]), + }; + }), + ); + } + + applyGesture(null); + }; + + const handleGestureCancel = () => applyGesture(null); + + const openDayEditor = (date: string, anchor: HTMLElement, addRow = false) => { + popoverAnchorRef.current = anchor; + dayDraftRef.current = null; + onPendingDraftChange?.(false); + setOpenDayDate(date); + setOpenDayAddRow(addRow); + }; + + const dayFromDraft = (day: AvailabilityEditorDay, draft: DayDraft) => ({ + ...day, + ranges: Availability.mergedDayRanges( + draft.ranges + .filter((range) => range.start && range.end) + .map((range) => Availability.dayRangeFromTimes(range.start, range.end)), + ), + note: draft.note.trim(), + }); + + const applyDayDraft = (date: string, draft: DayDraft) => { + onChange( + value.map((day) => (day.date === date ? dayFromDraft(day, draft) : day)), + ); + onPendingDraftChange?.(false); + }; + + const closeDayEditor = () => { + const draft = dayDraftRef.current; + + if (draft && openDayDate) { + applyDayDraft(openDayDate, draft); + } + + dayDraftRef.current = null; + onPendingDraftChange?.(false); + setOpenDayDate(null); + }; + + // deleting commits right away so the bar disappears as the button is + // pressed; once no ranges are left the popover has nothing to edit and + // closes too + const handleRangeDelete = (draft: DayDraft) => { + if (!openDayDate) return; + + applyDayDraft(openDayDate, draft); + + if (draft.ranges.every((range) => !range.start || !range.end)) { + dayDraftRef.current = null; + setOpenDayDate(null); + } else { + dayDraftRef.current = draft; + } + }; + + const handleBarClick = ( + date: string, + event: React.MouseEvent, + ) => { + const pressedAt = pressPointRef.current; + pressPointRef.current = null; + + if ( + pressedAt && + event.detail > 0 && + Math.hypot(event.clientX - pressedAt.x, event.clientY - pressedAt.y) > + MOVE_THRESHOLD_PX + ) { + return; + } + + openDayEditor(date, event.currentTarget); + }; + + const openDay = value.find((day) => day.date === openDayDate); + + const dayRow = (day: AvailabilityEditorDay, dayIndex: number) => { + const dayCommitments = commitments.filter( + (commitment) => commitment.date === day.date, + ); + const dayGesture = + gesture && gesture.type !== "fill" && gesture.dayIndex === dayIndex + ? gesture + : null; + // a plain click on a bar starts a move gesture too; the live time label + // only belongs to an actual drag, not to the click opening the popover + const liveRange = + dayGesture?.type === "move" && !dayGesture.moved + ? null + : (dayGesture?.range ?? null); + const fillPreview = + gesture?.type === "fill" && + gesture.dayIndex !== dayIndex && + isBetween(dayIndex, gesture.dayIndex, gesture.targetDayIndex) + ? [gesture.range] + : []; + + return ( + +
    + {dayLabelText(day)} + {day.note ? ( + + ) : null} +
    +
    { + trackRefs.current[dayIndex] = element; + }} + className={clsx(trackStyles.track, styles.paintable)} + data-testid={`availability-track-${dayIndex}`} + onPointerDown={handleTrackPointerDown(dayIndex)} + onPointerMove={handleGestureMove} + onPointerUp={handleGestureEnd} + onPointerCancel={handleGestureCancel} + > + + {dayCommitments.map((commitment, index) => ( + + ))} + {day.ranges.map((range) => { + const isDragged = + (dayGesture?.type === "move" || dayGesture?.type === "resize") && + sameRange(range, dayGesture.original); + const shown = isDragged && dayGesture ? dayGesture.range : range; + + return ( + + ); + })} + {gesture?.type === "paint" && + gesture.dayIndex === dayIndex && + gesture.range ? ( +
    + ) : null} + {fillPreview.map((piece) => ( +
    + ))} + {liveRange ? ( + + {rangeText(day.date, liveRange)} + + ) : null} +
    + + + ); + }; + + return ( +
    +
    +
    + + {value.map((day, dayIndex) => dayRow(day, dayIndex))} +
    +
    + {value.map((day) => { + const dayCommitments = commitments.filter( + (commitment) => commitment.date === day.date, + ); + + return ( +
    +
    + {dayLabelText(day)} +
    +
    + {day.ranges.map((range) => ( + + ))} + {dayCommitments.map((commitment, index) => ( + + {commitment.name} ·{" "} + {rangeText(day.date, commitment.range)} + + ))} + +
    + {day.note ? ( +
    + + {day.note} +
    + ) : null} +
    + ); + })} +
    +

    + {t("schedule:editor.timesInYourTimezone")} ·{" "} + {t("schedule:editor.visibility")} +

    +
    + {openDay ? ( + { + if (!isOpen) closeDayEditor(); + }} + triggerRef={popoverAnchorRef} + aria-label={t("schedule:editor.editDay", { + day: dayLabelText(openDay), + })} + > + { + dayDraftRef.current = draft; + onPendingDraftChange?.( + !R.isDeepEqual(dayFromDraft(openDay, draft), openDay), + ); + }} + onRangeDelete={handleRangeDelete} + /> + + ) : null} +
    + ); +} + +function DayEditor({ + day, + dayLabel, + startWithNewRow, + onDraftChange, + onRangeDelete, +}: { + day: AvailabilityEditorDay; + dayLabel: string; + /** Opens with an empty row already appended, for an "add time" entry point. */ + startWithNewRow: boolean; + onDraftChange: (draft: DayDraft) => void; + /** Called with the remaining draft after a range row is deleted — deletes commit instantly instead of waiting for the popover to close. */ + onRangeDelete: (draft: DayDraft) => void; +}) { + const { t } = useTranslation(["schedule", "common", "forms"]); + const noteId = React.useId(); + const nextIdRef = React.useRef(day.ranges.length + 1); + const [ranges, setRanges] = React.useState>(() => { + const existing = day.ranges.map((range, index) => ({ + id: index, + start: Availability.minutesToTime(range.start), + end: Availability.minutesToTime(range.end), + })); + + return startWithNewRow || existing.length === 0 + ? [...existing, { id: existing.length, start: "", end: "" }] + : existing; + }); + const [note, setNote] = React.useState(day.note); + + const update = (nextRanges: Array, nextNote: string) => { + setRanges(nextRanges); + setNote(nextNote); + onDraftChange({ ranges: nextRanges, note: nextNote }); + }; + + return ( +
    +
    {dayLabel}
    + {ranges.map((range) => ( +
    + + update( + ranges.map((other) => + other.id === range.id + ? { + ...other, + start: next?.start ?? "", + end: next?.end ?? "", + } + : other, + ), + note, + ) + } + startLabel={t("forms:labels.start")} + endLabel={t("forms:labels.end")} + /> + } + variant="minimal-destructive" + size="small" + aria-label={t("common:actions.delete")} + onPress={() => { + const remaining = ranges.filter((other) => other.id !== range.id); + update(remaining, note); + onRangeDelete({ ranges: remaining, note }); + }} + /> +
    + ))} + } + variant="minimal" + size="small" + className={styles.dayEditorAdd} + onPress={() => { + const id = nextIdRef.current; + nextIdRef.current += 1; + update([...ranges, { id, start: "", end: "" }], note); + }} + > + {t("schedule:editor.addTime")} + +
    + + update(ranges, event.target.value)} + /> +
    +
    + ); +} + +const sameRange = (one: DayTimeRange, other: DayTimeRange) => + one.start === other.start && one.end === other.end; + +const isBetween = (index: number, one: number, other: number) => + index >= Math.min(one, other) && index <= Math.max(one, other); diff --git a/app/features/availability/components/WeekToggle.tsx b/app/features/availability/components/WeekToggle.tsx new file mode 100644 index 000000000..a91b9e7f6 --- /dev/null +++ b/app/features/availability/components/WeekToggle.tsx @@ -0,0 +1,54 @@ +import type * as React from "react"; +import { useTranslation } from "react-i18next"; +import { + SendouChipRadio, + SendouChipRadioGroup, +} from "~/components/elements/ChipRadio"; + +const WEEK_VALUES = ["current", "next"] as const; + +export type WeekToggleValue = (typeof WEEK_VALUES)[number]; + +/** The current/next week chip toggle shared by the schedule surfaces. */ +export function WeekToggle({ + name, + value, + onChange, + renderExtra, +}: { + name: string; + value: WeekToggleValue; + onChange: (value: WeekToggleValue) => void; + /** Rendered after a chip's label, e.g. the editor's "not filled" marker. */ + renderExtra?: (week: WeekToggleValue) => React.ReactNode; +}) { + const { t } = useTranslation(["schedule"]); + + const label = (week: WeekToggleValue) => + week === "current" + ? t("schedule:team.currentWeek") + : t("schedule:team.nextWeek"); + + return ( + + {WEEK_VALUES.map((week) => ( + onChange(week)} + > + {renderExtra ? ( + + {label(week)} + {renderExtra(week)} + + ) : ( + label(week) + )} + + ))} + + ); +} diff --git a/app/features/availability/core/Availability.test.ts b/app/features/availability/core/Availability.test.ts new file mode 100644 index 000000000..b44495e36 --- /dev/null +++ b/app/features/availability/core/Availability.test.ts @@ -0,0 +1,867 @@ +import { describe, expect, test } from "vitest"; +import * as Availability from "./Availability"; + +const HELSINKI = "Europe/Helsinki"; +const LOS_ANGELES = "America/Los_Angeles"; + +const at = (date: string, time: string, timezone = HELSINKI) => + Availability.localToTimestamp({ date, time, timezone }); + +const range = (date: string, start: string, end: string, endDate = date) => ({ + startsAt: at(date, start), + endsAt: at(endDate, end), +}); + +const HOUR = 60 * 60; + +describe("Availability.weekStartsAt", () => { + test.each([ + { why: "a Monday morning", date: "2026-08-24", time: "09:00" }, + { why: "a Sunday just before midnight", date: "2026-08-30", time: "23:59" }, + { why: "a Wednesday", date: "2026-08-26", time: "18:00" }, + ])("resolves $why to the Monday that starts its week", ({ date, time }) => { + expect( + Availability.weekStartsAt(new Date(at(date, time) * 1000), HELSINKI), + ).toBe(at("2026-08-24", "00:00")); + }); + + test("resolves the same instant to a different Monday midnight per timezone", () => { + const instant = new Date(at("2026-08-26", "18:00") * 1000); + + expect(Availability.weekStartsAt(instant, HELSINKI)).toBe( + at("2026-08-24", "00:00"), + ); + expect(Availability.weekStartsAt(instant, LOS_ANGELES)).toBe( + at("2026-08-24", "00:00", LOS_ANGELES), + ); + }); +}); + +describe("Availability.weekRange", () => { + test.each([ + { why: "no DST transition", date: "2026-08-26", hours: 168 }, + { why: "the spring transition", date: "2026-03-25", hours: 167 }, + { why: "the autumn transition", date: "2026-10-21", hours: 169 }, + ])("is $hours hours long for a week with $why", ({ date, hours }) => { + const { startsAt, endsAt } = Availability.weekRange( + new Date(at(date, "12:00") * 1000), + HELSINKI, + ); + + expect((endsAt - startsAt) / HOUR).toBe(hours); + }); + + test("ends at the Monday midnight that starts the next week", () => { + const { endsAt } = Availability.weekRange( + new Date(at("2026-08-26", "12:00") * 1000), + HELSINKI, + ); + + expect(endsAt).toBe(at("2026-08-31", "00:00")); + }); +}); + +describe("Availability.dateInTimezone", () => { + test("places a slot on the viewer's day, not the author's", () => { + const pastMidnightInHelsinki = at("2026-08-25", "00:30"); + + expect(Availability.dateInTimezone(pastMidnightInHelsinki, HELSINKI)).toBe( + "2026-08-25", + ); + expect( + Availability.dateInTimezone(pastMidnightInHelsinki, LOS_ANGELES), + ).toBe("2026-08-24"); + }); + + test("round trips with localToTimestamp", () => { + const timestamp = at("2026-08-25", "22:30"); + + expect(Availability.dateInTimezone(timestamp, HELSINKI)).toBe("2026-08-25"); + expect(Availability.timeInTimezone(timestamp, HELSINKI)).toBe("22:30"); + }); +}); + +describe("Availability.dayMinutesToTimestamp", () => { + test("matches localToTimestamp for a same-day time", () => { + expect( + Availability.dayMinutesToTimestamp({ + date: "2026-08-25", + minutes: 18 * 60 + 30, + timezone: HELSINKI, + }), + ).toBe(at("2026-08-25", "18:30")); + }); + + test("rolls minutes past 1440 into the next day", () => { + expect( + Availability.dayMinutesToTimestamp({ + date: "2026-08-25", + minutes: 26 * 60, + timezone: HELSINKI, + }), + ).toBe(at("2026-08-26", "02:00")); + }); + + test.each([ + { + why: "spring", + date: "2026-03-28", + minutes: 28 * 60, + expectedDate: "2026-03-29", + expectedTime: "04:00", + }, + { + why: "autumn", + date: "2026-10-24", + minutes: 26 * 60, + expectedDate: "2026-10-25", + expectedTime: "02:00", + }, + ])( + "rolls through the $why DST transition like a hand-entered time", + ({ date, minutes, expectedDate, expectedTime }) => { + expect( + Availability.dayMinutesToTimestamp({ + date, + minutes, + timezone: HELSINKI, + }), + ).toBe(at(expectedDate, expectedTime)); + }, + ); +}); + +describe("Availability.overlaps", () => { + test.each([ + { + why: "ranges sharing an hour", + other: ["19:00", "21:00"], + expected: true, + }, + { why: "ranges only touching", other: ["20:00", "22:00"], expected: false }, + { why: "ranges apart", other: ["21:00", "22:00"], expected: false }, + { why: "a contained range", other: ["19:00", "19:30"], expected: true }, + ])("returns $expected for $why", ({ other, expected }) => { + expect( + Availability.overlaps( + range("2026-08-24", "18:00", "20:00"), + range("2026-08-24", other[0], other[1]), + ), + ).toBe(expected); + }); +}); + +describe("Availability.normalize", () => { + test("merges overlapping and touching ranges and sorts them", () => { + expect( + Availability.normalize([ + range("2026-08-24", "20:00", "22:00"), + range("2026-08-24", "18:00", "20:00"), + range("2026-08-24", "19:00", "21:00"), + range("2026-08-24", "23:00", "23:30"), + ]), + ).toEqual([ + range("2026-08-24", "18:00", "22:00"), + range("2026-08-24", "23:00", "23:30"), + ]); + }); + + test("drops ranges with no length", () => { + expect( + Availability.normalize([range("2026-08-24", "18:00", "18:00")]), + ).toEqual([]); + }); + + test("keeps a range crossing midnight in one piece", () => { + expect( + Availability.normalize([ + range("2026-08-24", "22:00", "02:00", "2026-08-25"), + ]), + ).toEqual([range("2026-08-24", "22:00", "02:00", "2026-08-25")]); + }); +}); + +describe("Availability.subtract", () => { + test("splits a range around a busy block inside it", () => { + expect( + Availability.subtract( + [range("2026-08-24", "18:00", "23:00")], + [range("2026-08-24", "19:00", "21:00")], + ), + ).toEqual([ + range("2026-08-24", "18:00", "19:00"), + range("2026-08-24", "21:00", "23:00"), + ]); + }); + + test("cuts a busy block reaching over the end of a range", () => { + expect( + Availability.subtract( + [range("2026-08-24", "18:00", "23:00")], + [range("2026-08-24", "21:00", "02:00", "2026-08-25")], + ), + ).toEqual([range("2026-08-24", "18:00", "21:00")]); + }); + + test("removes a range covered by a busy block", () => { + expect( + Availability.subtract( + [range("2026-08-24", "18:00", "23:00")], + [range("2026-08-24", "17:00", "23:30")], + ), + ).toEqual([]); + }); + + test("leaves a range a busy block only touches", () => { + expect( + Availability.subtract( + [range("2026-08-24", "18:00", "23:00")], + [range("2026-08-24", "23:00", "23:30")], + ), + ).toEqual([range("2026-08-24", "18:00", "23:00")]); + }); +}); + +describe("Availability.clip", () => { + test("cuts the ends reaching outside the window", () => { + expect( + Availability.clip( + [range("2026-08-30", "22:00", "02:00", "2026-08-31")], + range("2026-08-24", "00:00", "00:00", "2026-08-31"), + ), + ).toEqual([range("2026-08-30", "22:00", "00:00", "2026-08-31")]); + }); + + test("drops a range entirely outside the window", () => { + expect( + Availability.clip( + [range("2026-08-31", "18:00", "22:00")], + range("2026-08-24", "00:00", "00:00", "2026-08-31"), + ), + ).toEqual([]); + }); + + test("keeps a range inside the window as is", () => { + expect( + Availability.clip( + [range("2026-08-26", "18:00", "22:00")], + range("2026-08-24", "00:00", "00:00", "2026-08-31"), + ), + ).toEqual([range("2026-08-26", "18:00", "22:00")]); + }); +}); + +describe("Availability.availabilityInWindow", () => { + const window = range("2026-08-30", "18:00", "22:00"); + const busyBlock = (r: { startsAt: number; endsAt: number }) => ({ + ...r, + type: "tournament" as const, + name: "In The Zone 42", + }); + + test("a slot covering the whole window is available, ranges as reported", () => { + expect( + Availability.availabilityInWindow({ + reported: true, + slots: [range("2026-08-30", "17:00", "23:00")], + busy: [], + window, + }), + ).toEqual({ + status: "available", + ranges: [range("2026-08-30", "17:00", "23:00")], + }); + }); + + test("a slot covering part of the window is partial, ranges clipped to it", () => { + expect( + Availability.availabilityInWindow({ + reported: true, + slots: [range("2026-08-30", "19:00", "23:00")], + busy: [], + window, + }), + ).toEqual({ + status: "partial", + ranges: [range("2026-08-30", "19:00", "22:00")], + }); + }); + + test("split slots leaving a gap inside the window are partial even when they span it", () => { + expect( + Availability.availabilityInWindow({ + reported: true, + slots: [ + range("2026-08-30", "17:00", "19:00"), + range("2026-08-30", "20:00", "23:00"), + ], + busy: [], + window, + }), + ).toEqual({ + status: "partial", + ranges: [ + range("2026-08-30", "18:00", "19:00"), + range("2026-08-30", "20:00", "22:00"), + ], + }); + }); + + test("a reported week without overlap is unavailable", () => { + expect( + Availability.availabilityInWindow({ + reported: true, + slots: [range("2026-08-30", "12:00", "17:00")], + busy: [], + window, + }), + ).toEqual({ status: "unavailable" }); + }); + + test("a slot only touching the window start is unavailable", () => { + expect( + Availability.availabilityInWindow({ + reported: true, + slots: [range("2026-08-30", "12:00", "18:00")], + busy: [], + window, + }), + ).toEqual({ status: "unavailable" }); + }); + + test("no reported week is unknown", () => { + expect( + Availability.availabilityInWindow({ + reported: false, + slots: [], + busy: [], + window, + }), + ).toEqual({ status: "unknown" }); + }); + + test("a busy block overlapping the window wins over reported availability", () => { + const block = busyBlock(range("2026-08-30", "19:00", "21:00")); + + expect( + Availability.availabilityInWindow({ + reported: true, + slots: [range("2026-08-30", "17:00", "23:00")], + busy: [block], + window, + }), + ).toEqual({ status: "busy", block }); + }); + + test("a busy block wins even when nothing was reported", () => { + const block = busyBlock(range("2026-08-30", "18:00", "22:00")); + + expect( + Availability.availabilityInWindow({ + reported: false, + slots: [], + busy: [block], + window, + }), + ).toEqual({ status: "busy", block }); + }); + + test("a busy block outside the window changes nothing", () => { + expect( + Availability.availabilityInWindow({ + reported: true, + slots: [range("2026-08-30", "17:00", "23:00")], + busy: [busyBlock(range("2026-08-29", "18:00", "22:00"))], + window, + }), + ).toEqual({ + status: "available", + ranges: [range("2026-08-30", "17:00", "23:00")], + }); + }); + + test("a cross-midnight slot covers a window reaching past midnight", () => { + expect( + Availability.availabilityInWindow({ + reported: true, + slots: [range("2026-08-30", "20:00", "02:30", "2026-08-31")], + busy: [], + window: range("2026-08-30", "22:00", "02:00", "2026-08-31"), + }), + ).toEqual({ + status: "available", + ranges: [range("2026-08-30", "20:00", "02:30", "2026-08-31")], + }); + }); +}); + +describe("Availability.isoWeekNumber", () => { + test.each([ + { why: "a midweek day", date: "2026-08-26", timezone: HELSINKI, week: 35 }, + { + why: "a new year week counted to the old year", + date: "2027-01-01", + timezone: HELSINKI, + week: 53, + }, + ])("resolves $why to week $week", ({ date, timezone, week }) => { + expect( + Availability.isoWeekNumber(at(date, "12:00", timezone), timezone), + ).toBe(week); + }); + + test("resolves an instant near midnight by the timezone's local day", () => { + const sundayLateHelsinki = at("2026-08-30", "23:30"); + + expect(Availability.isoWeekNumber(sundayLateHelsinki, HELSINKI)).toBe(35); + expect(Availability.isoWeekNumber(sundayLateHelsinki, LOS_ANGELES)).toBe( + 35, + ); + }); +}); + +describe("Availability.isFirstDayOfWeek", () => { + test.each([ + { why: "a Monday", date: "2026-08-24", is: true }, + { why: "a Sunday", date: "2026-08-30", is: false }, + { why: "a Wednesday", date: "2026-08-26", is: false }, + ])("resolves $why to $is", ({ date, is }) => { + expect( + Availability.isFirstDayOfWeek( + new Date(at(date, "12:00") * 1000), + HELSINKI, + ), + ).toBe(is); + }); +}); + +describe("Availability.isLastDayOfWeek", () => { + test.each([ + { why: "a Sunday", date: "2026-08-30", is: true }, + { why: "a Monday", date: "2026-08-24", is: false }, + { why: "a Saturday", date: "2026-08-29", is: false }, + ])("resolves $why to $is", ({ date, is }) => { + expect( + Availability.isLastDayOfWeek( + new Date(at(date, "12:00") * 1000), + HELSINKI, + ), + ).toBe(is); + }); + + test("resolves an instant by the timezone's local day", () => { + const mondayEarlyHelsinki = new Date(at("2026-08-31", "01:00") * 1000); + + expect(Availability.isLastDayOfWeek(mondayEarlyHelsinki, HELSINKI)).toBe( + false, + ); + expect(Availability.isLastDayOfWeek(mondayEarlyHelsinki, LOS_ANGELES)).toBe( + true, + ); + }); +}); + +describe("Availability.playableWindows", () => { + const members = ( + ranges: Array>, + ) => + ranges.map((memberRanges, index) => ({ + userId: index + 1, + ranges: memberRanges.map(([start, end, endDate]) => + range("2026-08-24", start, end, endDate), + ), + })); + + test("reports the span the required amount of players share as FULL", () => { + const windows = Availability.playableWindows({ + members: members([ + [["18:00", "23:00"]], + [["18:00", "23:00"]], + [["19:00", "23:00"]], + [["19:00", "22:00"]], + ]), + }); + + expect(windows).toEqual([ + { + ...range("2026-08-24", "19:00", "22:00"), + tier: "FULL", + userIds: [1, 2, 3, 4], + }, + ]); + }); + + test("reports a span one player short as ONE_SHORT", () => { + const windows = Availability.playableWindows({ + members: members([ + [["18:00", "21:00"]], + [["18:00", "21:00"]], + [["18:00", "21:00"]], + ]), + }); + + expect(windows).toEqual([ + { + ...range("2026-08-24", "18:00", "21:00"), + tier: "ONE_SHORT", + userIds: [1, 2, 3], + }, + ]); + }); + + test("reports nothing when two players short", () => { + expect( + Availability.playableWindows({ + members: members([[["18:00", "21:00"]], [["18:00", "21:00"]]]), + }), + ).toEqual([]); + }); + + test("leaves out a window shorter than the minimum", () => { + expect( + Availability.playableWindows({ + members: members([ + [["19:00", "19:30"]], + [["19:00", "19:30"]], + [["19:00", "19:30"]], + [["19:00", "19:30"]], + ]), + }), + ).toEqual([]); + }); + + test("leaves out a ONE_SHORT window that already contains a FULL one", () => { + const windows = Availability.playableWindows({ + members: members([ + [["18:00", "23:00"]], + [["18:00", "23:00"]], + [["18:00", "23:00"]], + [["19:00", "22:00"]], + ]), + }); + + expect(windows).toEqual([ + { + ...range("2026-08-24", "19:00", "22:00"), + tier: "FULL", + userIds: [1, 2, 3, 4], + }, + ]); + }); + + test("keeps a ONE_SHORT window of a different day than the FULL one", () => { + const windows = Availability.playableWindows({ + members: [ + { + userId: 1, + ranges: [ + range("2026-08-24", "18:00", "22:00"), + range("2026-08-25", "18:00", "22:00"), + ], + }, + { + userId: 2, + ranges: [ + range("2026-08-24", "18:00", "22:00"), + range("2026-08-25", "18:00", "22:00"), + ], + }, + { + userId: 3, + ranges: [ + range("2026-08-24", "18:00", "22:00"), + range("2026-08-25", "18:00", "22:00"), + ], + }, + { userId: 4, ranges: [range("2026-08-24", "18:00", "22:00")] }, + ], + }); + + expect(windows).toEqual([ + { + ...range("2026-08-24", "18:00", "22:00"), + tier: "FULL", + userIds: [1, 2, 3, 4], + }, + { + ...range("2026-08-25", "18:00", "22:00"), + tier: "ONE_SHORT", + userIds: [1, 2, 3], + }, + ]); + }); + + test("reports a window crossing midnight in one piece", () => { + const windows = Availability.playableWindows({ + members: members([ + [["22:00", "02:00", "2026-08-25"]], + [["22:00", "02:00", "2026-08-25"]], + [["22:00", "02:00", "2026-08-25"]], + [["22:00", "02:00", "2026-08-25"]], + ]), + }); + + expect(windows).toEqual([ + { + ...range("2026-08-24", "22:00", "02:00", "2026-08-25"), + tier: "FULL", + userIds: [1, 2, 3, 4], + }, + ]); + }); + + test("does not join two windows separated by a gap", () => { + const windows = Availability.playableWindows({ + members: members([ + [ + ["18:00", "20:00"], + ["21:00", "23:00"], + ], + [ + ["18:00", "20:00"], + ["21:00", "23:00"], + ], + [ + ["18:00", "20:00"], + ["21:00", "23:00"], + ], + [ + ["18:00", "20:00"], + ["21:00", "23:00"], + ], + ]), + }); + + expect(windows.map((window) => window.tier)).toEqual(["FULL", "FULL"]); + expect(windows[0]).toMatchObject(range("2026-08-24", "18:00", "20:00")); + expect(windows[1]).toMatchObject(range("2026-08-24", "21:00", "23:00")); + }); +}); + +describe("Availability.snapMinutes", () => { + test.each([ + [0, 0], + [14, 0], + [15, 30], + [44, 30], + [46, 60], + [1439, 1440], + ])("snaps %i minutes to %i", (minutes, expected) => { + expect(Availability.snapMinutes(minutes)).toBe(expected); + }); +}); + +const TRACK = { trackStart: 14 * 60, trackEnd: 26 * 60 }; +const minuteRange = (start: number, end: number) => ({ start, end }); + +describe("Availability.timeToMinutes", () => { + test.each([ + ["00:00", 0], + ["09:30", 570], + ["23:59", 1439], + ])("resolves %s to %i minutes", (time, expected) => { + expect(Availability.timeToMinutes(time)).toBe(expected); + }); + + test("throws on a malformed time", () => { + expect(() => Availability.timeToMinutes("half past six")).toThrow(); + }); +}); + +describe("Availability.minutesToTime", () => { + test.each([ + { why: "midnight", minutes: 0, expected: "00:00" }, + { why: "an evening time", minutes: 1380, expected: "23:00" }, + { why: "a time past midnight", minutes: 1560, expected: "02:00" }, + ])("prints $why as $expected", ({ minutes, expected }) => { + expect(Availability.minutesToTime(minutes)).toBe(expected); + }); +}); + +describe("Availability.dayRangeFromTimes", () => { + test("keeps a same-day range as entered", () => { + expect(Availability.dayRangeFromTimes("18:00", "22:00")).toEqual( + minuteRange(1080, 1320), + ); + }); + + test("pushes an end earlier than the start past midnight", () => { + expect(Availability.dayRangeFromTimes("22:00", "02:00")).toEqual( + minuteRange(1320, 1560), + ); + }); + + test("treats an end equal to the start as an empty range", () => { + const result = Availability.dayRangeFromTimes("18:00", "18:00"); + + expect(Availability.mergedDayRanges([result])).toEqual([]); + }); +}); + +describe("Availability.mergedDayRanges", () => { + test("merges overlapping and touching ranges", () => { + expect( + Availability.mergedDayRanges([ + minuteRange(1200, 1320), + minuteRange(1080, 1230), + minuteRange(1320, 1380), + ]), + ).toEqual([minuteRange(1080, 1380)]); + }); + + test("keeps separated ranges apart and drops empty ones", () => { + expect( + Availability.mergedDayRanges([ + minuteRange(1260, 1380), + minuteRange(1080, 1140), + minuteRange(600, 600), + ]), + ).toEqual([minuteRange(1080, 1140), minuteRange(1260, 1380)]); + }); +}); + +describe("Availability.paintedRange", () => { + test("snaps both ends and orders a backwards drag", () => { + expect( + Availability.paintedRange({ + anchor: 1307, + cursor: 1114, + walls: [], + ...TRACK, + }), + ).toEqual(minuteRange(1110, 1320)); + }); + + test("grows a plain press to one step", () => { + expect( + Availability.paintedRange({ + anchor: 1085, + cursor: 1085, + walls: [], + ...TRACK, + }), + ).toEqual(minuteRange(1080, 1110)); + }); + + test("extends across a wall", () => { + expect( + Availability.paintedRange({ + anchor: 1080, + cursor: 1440, + walls: [minuteRange(1200, 1290)], + ...TRACK, + }), + ).toEqual(minuteRange(1080, 1440)); + }); + + test("returns null when the anchor is inside a wall", () => { + expect( + Availability.paintedRange({ + anchor: 1230, + cursor: 1440, + walls: [minuteRange(1200, 1290)], + ...TRACK, + }), + ).toBeNull(); + }); + + test("stays inside the track and starts before midnight", () => { + expect( + Availability.paintedRange({ + anchor: 1500, + cursor: 2000, + walls: [], + ...TRACK, + }), + ).toEqual(minuteRange(1410, 1560)); + }); + + test("a paint anchored past midnight grows from the day's last step", () => { + expect( + Availability.paintedRange({ + anchor: 1470, + cursor: 1470, + walls: [], + ...TRACK, + }), + ).toEqual(minuteRange(1410, 1500)); + }); +}); + +describe("Availability.movedRange", () => { + test("snaps the move to the entry step", () => { + expect( + Availability.movedRange({ + range: minuteRange(1080, 1200), + delta: 44, + ...TRACK, + }), + ).toEqual(minuteRange(1110, 1230)); + }); + + test("stops at the track edges", () => { + expect( + Availability.movedRange({ + range: minuteRange(1080, 1200), + delta: -1000, + ...TRACK, + }), + ).toEqual(minuteRange(840, 960)); + }); + + test("stops the start before midnight", () => { + expect( + Availability.movedRange({ + range: minuteRange(1350, 1380), + delta: 120, + ...TRACK, + }), + ).toEqual(minuteRange(1410, 1440)); + }); +}); + +describe("Availability.resizedRange", () => { + test("keeps at least one step when dragged past the other edge", () => { + expect( + Availability.resizedRange({ + range: minuteRange(1080, 1200), + edge: "end", + cursor: 900, + ...TRACK, + }), + ).toEqual(minuteRange(1080, 1110)); + }); + + test("stops the dragged edge at the track edges", () => { + expect( + Availability.resizedRange({ + range: minuteRange(1320, 1440), + edge: "start", + cursor: 500, + ...TRACK, + }), + ).toEqual(minuteRange(840, 1440)); + }); + + test("snaps the dragged edge", () => { + expect( + Availability.resizedRange({ + range: minuteRange(1080, 1200), + edge: "end", + cursor: 1307, + ...TRACK, + }), + ).toEqual(minuteRange(1080, 1320)); + }); + + test("stops the start edge before midnight", () => { + expect( + Availability.resizedRange({ + range: minuteRange(1380, 1560), + edge: "start", + cursor: 1500, + ...TRACK, + }), + ).toEqual(minuteRange(1410, 1560)); + }); +}); diff --git a/app/features/availability/core/Availability.ts b/app/features/availability/core/Availability.ts new file mode 100644 index 000000000..98552dba3 --- /dev/null +++ b/app/features/availability/core/Availability.ts @@ -0,0 +1,603 @@ +import { TZDate } from "@date-fns/tz"; +import { + addWeeks, + format, + getISOWeek, + isMonday, + isSunday, + startOfWeek, +} from "date-fns"; +import * as R from "remeda"; +import { + databaseTimestampToJavascriptTimestamp, + dateToDatabaseTimestamp, +} from "~/utils/dates"; +import invariant from "~/utils/invariant"; +import { AVAILABILITY } from "../availability-constants"; +import type { + BusyBlock, + DayTimeRange, + MemberAvailability, + PlayableWindow, + TimeRange, + WindowAvailability, +} from "../availability-types"; + +const MINUTE_IN_SECONDS = 60; +const DAY_MINUTES = 24 * 60; + +/** + * Database timestamp of the Monday 00:00 that starts the week `date` falls in, + * as the week is seen in `timezone`. + */ +export function weekStartsAt(date: Date, timezone: string) { + const zoned = new TZDate(date.getTime(), timezone); + + return dateToDatabaseTimestamp(startOfWeek(zoned, { weekStartsOn: 1 })); +} + +/** + * The week `date` falls in as a time range, `endsAt` being the Monday 00:00 that + * starts the next week. Not always 7×24h long: a week with a DST transition in + * it is an hour shorter or longer. + */ +export function weekRange(date: Date, timezone: string): TimeRange { + const zoned = new TZDate(date.getTime(), timezone); + const start = startOfWeek(zoned, { weekStartsOn: 1 }); + + return { + startsAt: dateToDatabaseTimestamp(start), + endsAt: dateToDatabaseTimestamp(addWeeks(start, 1)), + }; +} + +/** Whether `date` falls on the first day of its week (Monday), as the week is seen in `timezone`. */ +export function isFirstDayOfWeek(date: Date, timezone: string) { + return isMonday(new TZDate(date.getTime(), timezone)); +} + +/** Whether `date` falls on the last day of its week (Sunday), as the week is seen in `timezone`. */ +export function isLastDayOfWeek(date: Date, timezone: string) { + return isSunday(new TZDate(date.getTime(), timezone)); +} + +/** ISO week number of the week the timestamp falls in, as seen in `timezone`. */ +export function isoWeekNumber(timestamp: number, timezone: string) { + return getISOWeek(inTimezone(timestamp, timezone)); +} + +/** + * Whether a week reported to start at `weekStartsAt` is the week starting at + * `rangeStartsAt`: the two starts are closer than timezones can set them + * apart (hours, never days). + */ +export function isSameWeek(weekStartsAt: number, rangeStartsAt: number) { + return ( + Math.abs(weekStartsAt - rangeStartsAt) < + AVAILABILITY.WEEK_MATCH_MAX_DISTANCE_SECONDS + ); +} + +/** + * Database timestamp of the given wall clock time in `timezone`. `date` is + * `YYYY-MM-DD` and `time` is `HH:mm`, the shapes the availability tables and + * form fields use. + */ +export function localToTimestamp({ + date, + time, + timezone, +}: { + date: string; + time: string; + timezone: string; +}) { + const [year, month, day] = date.split("-").map(Number); + const [hours, minutes] = time.split(":").map(Number); + + invariant( + [year, month, day, hours, minutes].every((part) => Number.isFinite(part)), + `Malformed local time: ${date} ${time}`, + ); + + return dateToDatabaseTimestamp( + new TZDate(year, month - 1, day, hours, minutes, 0, timezone), + ); +} + +/** + * Database timestamp of the given minutes from midnight of `date` in + * `timezone`, the clock representation the schedule editor uses. Minutes past + * 1440 roll into the next day, so the end of a range crossing midnight + * converts like any other. On a DST transition day the clock simply rolls + * through the change, same as entering the time by hand would. + */ +export function dayMinutesToTimestamp({ + date, + minutes, + timezone, +}: { + date: string; + minutes: number; + timezone: string; +}) { + const [year, month, day] = date.split("-").map(Number); + + invariant( + [year, month, day, minutes].every((part) => Number.isFinite(part)), + `Malformed local time: ${date} +${minutes}min`, + ); + + return dateToDatabaseTimestamp( + new TZDate(year, month - 1, day, 0, minutes, 0, timezone), + ); +} + +/** + * `YYYY-MM-DD` of the timestamp in `timezone`. What day a slot belongs to + * depends on who is looking at it, so the day of a slot is always resolved from + * its timestamp rather than from the day its author entered it on. + */ +export function dateInTimezone(timestamp: number, timezone: string) { + return format(inTimezone(timestamp, timezone), "yyyy-MM-dd"); +} + +/** `HH:mm` of the timestamp in `timezone`. */ +export function timeInTimezone(timestamp: number, timezone: string) { + return format(inTimezone(timestamp, timezone), "HH:mm"); +} + +/** + * `YYYY-MM-DD` in the `to` timezone of a day saved as a date in the `from` + * timezone, mapped through that day's noon in case the viewer has since + * moved. How day notes find their viewer-local day. + */ +export function dateAcrossTimezones({ + date, + from, + to, +}: { + date: string; + from: string; + to: string; +}) { + return dateInTimezone( + localToTimestamp({ date, time: "12:00", timezone: from }), + to, + ); +} + +/** Whether the two ranges share any time at all. Ranges that merely touch do not overlap. */ +export function overlaps(one: TimeRange, other: TimeRange) { + return one.startsAt < other.endsAt && other.startsAt < one.endsAt; +} + +/** + * The given ranges sorted and merged, so that no two of them overlap or touch. + * Empty ranges are dropped. + */ +export function normalize(ranges: Array): Array { + const sorted = R.sortBy( + ranges.filter((range) => range.endsAt > range.startsAt), + (range) => range.startsAt, + ); + + const merged: Array = []; + for (const range of sorted) { + const previous = merged[merged.length - 1]; + + if (previous && range.startsAt <= previous.endsAt) { + previous.endsAt = Math.max(previous.endsAt, range.endsAt); + } else { + merged.push({ ...range }); + } + } + + return merged; +} + +/** + * Effective availability: what is left of `ranges` once every busy block is cut + * out of them. A commitment always wins over what the user reported. + */ +export function subtract( + ranges: Array, + busy: Array, +): Array { + let remaining = normalize(ranges); + + for (const block of normalize(busy)) { + const next: Array = []; + + for (const range of remaining) { + if (!overlaps(range, block)) { + next.push(range); + continue; + } + + if (range.startsAt < block.startsAt) { + next.push({ startsAt: range.startsAt, endsAt: block.startsAt }); + } + if (range.endsAt > block.endsAt) { + next.push({ startsAt: block.endsAt, endsAt: range.endsAt }); + } + } + + remaining = next; + } + + return remaining; +} + +/** + * The parts of the ranges that fall inside `window`, sorted and merged. Used to + * keep one week's view from picking up windows that belong to the next. + */ +export function clip( + ranges: Array, + window: TimeRange, +): Array { + return normalize(ranges).flatMap((range) => { + const startsAt = Math.max(range.startsAt, window.startsAt); + const endsAt = Math.min(range.endsAt, window.endsAt); + + return endsAt > startsAt ? [{ startsAt, endsAt }] : []; + }); +} + +/** + * How one person's schedule relates to an event's window. A busy block + * overlapping the window wins over anything reported — the person is committed + * elsewhere, whether or not their schedule is known. Otherwise the reported + * slots either cover the window (`available`, with the overlapping ranges as + * reported), cover part of it (`partial`, with the overlap clipped to the + * window so it reads as "which part"), miss it entirely (`unavailable`) or do + * not exist (`unknown`). + */ +export function availabilityInWindow({ + reported, + slots, + busy, + window, +}: { + reported: boolean; + slots: Array; + busy: Array; + window: TimeRange; +}): WindowAvailability { + const block = busy.find((candidate) => overlaps(candidate, window)); + if (block) return { status: "busy", block }; + + if (!reported) return { status: "unknown" }; + + const overlapping = normalize(slots).filter((range) => + overlaps(range, window), + ); + if (overlapping.length === 0) return { status: "unavailable" }; + + const covers = overlapping.some( + (range) => + range.startsAt <= window.startsAt && range.endsAt >= window.endsAt, + ); + + return covers + ? { status: "available", ranges: overlapping } + : { status: "partial", ranges: clip(overlapping, window) }; +} + +/** + * The windows the team could play in: spans + * where `minPlayers` of the members (`FULL`) or one fewer (`ONE_SHORT`) are all + * free from the first minute of the window to the last. Windows shorter than + * `minDurationMinutes` are left out, as is any `ONE_SHORT` window that already + * contains a `FULL` one. + */ +export function playableWindows({ + members, + minPlayers = AVAILABILITY.DEFAULT_MIN_PLAYERS, + minDurationMinutes = AVAILABILITY.MIN_WINDOW_MINUTES, +}: { + members: Array; + minPlayers?: number; + minDurationMinutes?: number; +}): Array { + const segments = availabilitySegments(members); + const minDuration = minDurationMinutes * MINUTE_IN_SECONDS; + + const full = maximalWindows({ segments, threshold: minPlayers }).filter( + (window) => window.endsAt - window.startsAt >= minDuration, + ); + + const oneShort = + minPlayers - 1 > 0 + ? maximalWindows({ segments, threshold: minPlayers - 1 }).filter( + (window) => + window.endsAt - window.startsAt >= minDuration && + !full.some( + (fullWindow) => + fullWindow.startsAt >= window.startsAt && + fullWindow.endsAt <= window.endsAt, + ), + ) + : []; + + return [ + ...full.map((window) => ({ ...window, tier: "FULL" as const })), + ...oneShort.map((window) => ({ ...window, tier: "ONE_SHORT" as const })), + ]; +} + +/** Rounds minutes counted from the start of a day track to the nearest step. */ +export function snapMinutes( + minutes: number, + step: number = AVAILABILITY.SLOT_STEP_MINUTES, +) { + return Math.round(minutes / step) * step; +} + +/** + * Splits the members' availability into the spans between every start and end + * of it, each with the members free for the whole span. + */ +function availabilitySegments(members: Array) { + const normalized = members.map((member) => ({ + userId: member.userId, + ranges: normalize(member.ranges), + })); + + const boundaries = R.pipe( + normalized.flatMap((member) => + member.ranges.flatMap((range) => [range.startsAt, range.endsAt]), + ), + R.unique(), + R.sortBy((timestamp) => timestamp), + ); + + return boundaries.slice(0, -1).map((startsAt, index) => { + const endsAt = boundaries[index + 1]; + + return { + startsAt, + endsAt, + userIds: normalized + .filter((member) => + member.ranges.some( + (range) => range.startsAt <= startsAt && range.endsAt >= endsAt, + ), + ) + .map((member) => member.userId), + }; + }); +} + +type AvailabilitySegment = ReturnType[number]; + +/** + * The longest possible windows over which at least `threshold` of the same + * members are free throughout. A window is only reported when no longer window + * contains it. + */ +function maximalWindows({ + segments, + threshold, +}: { + segments: Array; + threshold: number; +}) { + const windows: Array }> = []; + + for (const [index, segment] of segments.entries()) { + let userIds = segment.userIds; + if (userIds.length < threshold) continue; + + let end = index; + while (end + 1 < segments.length) { + const next = segments[end + 1]; + if (next.startsAt !== segments[end].endsAt) break; + + const shared = userIds.filter((userId) => next.userIds.includes(userId)); + if (shared.length < threshold) break; + + userIds = shared; + end += 1; + } + + const endsAt = segments[end].endsAt; + const previous = windows[windows.length - 1]; + if (previous && previous.endsAt >= endsAt) continue; + + windows.push({ startsAt: segment.startsAt, endsAt, userIds }); + } + + return windows; +} + +function inTimezone(timestamp: number, timezone: string) { + return new TZDate( + databaseTimestampToJavascriptTimestamp(timestamp), + timezone, + ); +} + +/** Minutes from midnight of a `HH:mm` time string. */ +export function timeToMinutes(time: string) { + const [hours, minutes] = time.split(":").map(Number); + + invariant( + Number.isFinite(hours) && Number.isFinite(minutes), + `Malformed time: ${time}`, + ); + + return hours * 60 + minutes; +} + +/** + * `HH:mm` on the clock at the given minutes from midnight. Minutes past 24h + * wrap around, so the end of a range crossing midnight prints as e.g. `02:00`. + */ +export function minutesToTime(minutes: number) { + const onClock = ((minutes % DAY_MINUTES) + DAY_MINUTES) % DAY_MINUTES; + + return `${String(Math.floor(onClock / 60)).padStart(2, "0")}:${String( + onClock % 60, + ).padStart(2, "0")}`; +} + +/** + * Editor day range of the given start and end times. An end earlier than the + * start means the range crosses midnight; an end equal to the start is an + * empty range (dropped by {@link mergedDayRanges}). + */ +export function dayRangeFromTimes(start: string, end: string): DayTimeRange { + const startMinutes = timeToMinutes(start); + const endMinutes = timeToMinutes(end); + + return { + start: startMinutes, + end: endMinutes >= startMinutes ? endMinutes : endMinutes + DAY_MINUTES, + }; +} + +/** + * The ranges of one day track sorted and merged so that no two of them overlap + * or touch. Empty ranges are dropped. + */ +export function mergedDayRanges( + ranges: Array, +): Array { + return normalize(ranges.map(toTimeRange)).map(toDayRange); +} + +interface TrackWindowArgs { + /** Left edge of the visible clock window, minutes from midnight. */ + trackStart: number; + /** Right edge of the visible clock window, minutes from midnight. */ + trackEnd: number; +} + +/** + * Latest minute a range may start on: the last step before midnight. A range + * belongs to the day it starts on, so a start past midnight would silently be + * another day's range — the track's post-midnight zone only extends ends. + */ +const MAX_RANGE_START = DAY_MINUTES - AVAILABILITY.SLOT_STEP_MINUTES; + +/** + * Range painted by dragging on an empty part of a day track from `anchor` to + * `cursor` (both minutes from midnight): ends snapped to the entry step, at + * least one step long and kept inside the track. The start is kept before + * midnight — a paint anchored past it grows leftwards from the day's last + * step. Painting cannot start on a wall (a commitment) but may extend across + * one — null when the anchor is inside a wall. + */ +export function paintedRange({ + anchor, + cursor, + walls, + trackStart, + trackEnd, +}: TrackWindowArgs & { + anchor: number; + cursor: number; + /** Blocks a paint cannot start on, i.e. the day's commitments. */ + walls: Array; +}): DayTimeRange | null { + if (insideWall(anchor, walls)) return null; + + const track = { start: trackStart, end: trackEnd }; + const from = clampMinutes(snapMinutes(anchor), track); + const to = clampMinutes(snapMinutes(cursor), track); + + let start = Math.min(from, to); + let end = Math.max(from, to); + + if (end - start < AVAILABILITY.SLOT_STEP_MINUTES) { + end = Math.min(start + AVAILABILITY.SLOT_STEP_MINUTES, trackEnd); + start = end - AVAILABILITY.SLOT_STEP_MINUTES; + } + + if (start > MAX_RANGE_START) { + start = MAX_RANGE_START; + end = Math.max(end, start + AVAILABILITY.SLOT_STEP_MINUTES); + } + + return { start, end }; +} + +/** + * `range` moved by `delta` minutes: the move is snapped to the entry step and + * stopped at the track edges, with the start kept before midnight. + */ +export function movedRange({ + range, + delta, + trackStart, + trackEnd, +}: TrackWindowArgs & { + range: DayTimeRange; + delta: number; +}): DayTimeRange { + const length = range.end - range.start; + if (trackEnd - trackStart < length) return range; + + const start = R.clamp(range.start + snapMinutes(delta), { + min: trackStart, + max: Math.min(trackEnd - length, MAX_RANGE_START), + }); + + return { start, end: start + length }; +} + +/** + * `range` with one edge dragged to `cursor`: snapped to the entry step, kept + * at least one step long and stopped at the track edges, with the start kept + * before midnight. + */ +export function resizedRange({ + range, + edge, + cursor, + trackStart, + trackEnd, +}: TrackWindowArgs & { + range: DayTimeRange; + edge: "start" | "end"; + cursor: number; +}): DayTimeRange { + if (edge === "start") { + const start = R.clamp(snapMinutes(cursor), { + min: trackStart, + max: Math.min( + range.end - AVAILABILITY.SLOT_STEP_MINUTES, + MAX_RANGE_START, + ), + }); + + return { start, end: range.end }; + } + + const end = R.clamp(snapMinutes(cursor), { + min: range.start + AVAILABILITY.SLOT_STEP_MINUTES, + max: trackEnd, + }); + + return { start: range.start, end }; +} + +const toTimeRange = (range: DayTimeRange): TimeRange => ({ + startsAt: range.start, + endsAt: range.end, +}); + +const toDayRange = (range: TimeRange): DayTimeRange => ({ + start: range.startsAt, + end: range.endsAt, +}); + +const clampMinutes = (minutes: number, range: DayTimeRange) => + R.clamp(minutes, { min: range.start, max: range.end }); + +const insideWall = (point: number, walls: Array) => + mergedDayRanges(walls).some( + (wall) => wall.start <= point && point < wall.end, + ); 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..e32ac799b --- /dev/null +++ b/app/features/availability/core/Commitments.server.test.ts @@ -0,0 +1,304 @@ +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 + 1.5 * 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 + 2.5 * 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("excludeTournamentId leaves that tournament's registration out, others stay", async () => { + const excluded = await TournamentFactory.create({ + authorId: organizerId(), + startTimes: [WEEK_STARTS_AT + 3 * DAY], + }); + await TournamentTeamFactory.create({ + tournamentId: excluded.id, + memberUserIds: [memberId()], + }); + const other = await TournamentFactory.create({ + authorId: organizerId(), + name: "Elsewhere Open", + startTimes: [WEEK_STARTS_AT + 4 * DAY], + bracketProgression: DOUBLE_ELIMINATION, + }); + await TournamentTeamFactory.create({ + tournamentId: other.id, + memberUserIds: [memberId()], + }); + + const blocks = ( + await Commitments.busyBlocksByUserIds({ + userIds: [memberId()], + ...WINDOW, + excludeTournamentId: excluded.id, + }) + ).get(memberId()); + + expect(blocks?.map((block) => block.name)).toEqual(["Elsewhere Open"]); + }); + + 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..979cf66f1 --- /dev/null +++ b/app/features/availability/core/Commitments.server.ts @@ -0,0 +1,102 @@ +import * as R from "remeda"; +import * as ScrimPostRepository from "~/features/scrims/ScrimPostRepository.server"; +import * as TournamentTeamRepository from "~/features/tournament/TournamentTeamRepository.server"; +import * as SeriesTeamCount from "~/features/tournament-organization/core/SeriesTeamCount.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"; +import { estimatedEndsAtWith } from "./TournamentDuration.server"; + +/** + * 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. `excludeTournamentId` leaves that tournament's registrations + * out, for surfaces asking "busy elsewhere" while looking at that tournament. + */ +export async function busyBlocksByUserIds({ + userIds, + startsAt, + endsAt, + excludeTournamentId, +}: { + userIds: Array; + startsAt: number; + endsAt: number; + excludeTournamentId?: number; +}): Promise>> { + if (userIds.length === 0) return new Map(); + + const registrations = + await TournamentTeamRepository.findAllRegistrationsByUserIds({ + userIds, + startsAt: startsAt - TournamentDuration.MAX_ESTIMATE_SECONDS, + endsAt, + excludeTournamentId, + }); + const scrims = await ScrimPostRepository.findAllAcceptedByUserIds({ + userIds, + startsAt: startsAt - AVAILABILITY.SCRIM_COMMITMENT_SECONDS, + endsAt, + }); + const teamEvents = await AvailabilityRepository.findAllTeamEventsByUserIds({ + userIds, + startsAt, + endsAt, + }); + const expectedTeamCount = await SeriesTeamCount.lookup(); + + 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: estimatedEndsAtWith( + { + ...registration, + minMembersPerTeam: registration.settings.minMembersPerTeam ?? 4, + bracketTypes: registration.settings.bracketProgression.map( + (bracket) => bracket.type, + ), + }, + expectedTeamCount, + ), + })), + ...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/FriendSchedule.server.test.ts b/app/features/availability/core/FriendSchedule.server.test.ts new file mode 100644 index 000000000..361dcc41d --- /dev/null +++ b/app/features/availability/core/FriendSchedule.server.test.ts @@ -0,0 +1,107 @@ +import { addWeeks } from "date-fns"; +import { beforeEach, describe, expect, test } from "vitest"; +import * as AvailabilityWeekFactory from "~/db/seed/factories/AvailabilityWeekFactory"; +import * as TeamEventFactory from "~/db/seed/factories/TeamEventFactory"; +import * as TeamFactory from "~/db/seed/factories/TeamFactory"; +import * as UserFactory from "~/db/seed/factories/UserFactory"; +import * as Availability from "./Availability"; +import * as FriendSchedule from "./FriendSchedule.server"; + +const users = UserFactory.pool(); +const friendId = () => users.id(1); +const otherId = () => users.id(2); + +const TIMEZONE = "Europe/Helsinki"; +const HOUR = 60 * 60; + +const currentWeekStartsAt = () => + Availability.weekStartsAt(new Date(), TIMEZONE); +const nextWeekStartsAt = () => + Availability.weekStartsAt(addWeeks(new Date(), 1), TIMEZONE); + +const weeksOf = async (userId: number) => { + const schedules = await FriendSchedule.findByUserIds({ + userIds: [friendId(), otherId()], + timezone: TIMEZONE, + }); + + return schedules.get(userId); +}; + +describe("FriendSchedule.findByUserIds", () => { + beforeEach(async () => { + await users.create(2); + }); + + test("leaves out a user who reported neither week", async () => { + await AvailabilityWeekFactory.create({ + userId: friendId(), + weekStartsAt: currentWeekStartsAt(), + timezone: TIMEZONE, + }); + + expect(await weeksOf(otherId())).toBeUndefined(); + }); + + test("marks the week they filled in as reported and the other one not", async () => { + await AvailabilityWeekFactory.create({ + userId: friendId(), + weekStartsAt: nextWeekStartsAt(), + timezone: TIMEZONE, + }); + + expect( + (await weeksOf(friendId()))?.map((week) => [week.week, week.reported]), + ).toEqual([ + ["current", false], + ["next", true], + ]); + }); + + test("buckets the reported ranges into the days they start on", async () => { + const wednesdayEvening = { + startsAt: currentWeekStartsAt() + 2 * 24 * HOUR + 18 * HOUR, + endsAt: currentWeekStartsAt() + 2 * 24 * HOUR + 22 * HOUR, + }; + await AvailabilityWeekFactory.create({ + userId: friendId(), + weekStartsAt: currentWeekStartsAt(), + timezone: TIMEZONE, + slots: [wednesdayEvening], + }); + + const days = (await weeksOf(friendId()))?.[0].days; + + expect(days?.flatMap((day) => day.ranges)).toEqual([wednesdayEvening]); + expect(days?.[2].ranges).toEqual([wednesdayEvening]); + }); + + test("cuts a commitment out of the reported ranges", async () => { + const slot = { + startsAt: currentWeekStartsAt() + 18 * HOUR, + endsAt: currentWeekStartsAt() + 22 * HOUR, + }; + await AvailabilityWeekFactory.create({ + userId: friendId(), + weekStartsAt: currentWeekStartsAt(), + timezone: TIMEZONE, + slots: [slot], + }); + const team = await TeamFactory.create({ + memberUserIds: [friendId(), otherId()], + }); + await TeamEventFactory.create({ + teamId: team.id, + authorId: friendId(), + name: "VoD review", + startsAt: slot.startsAt + HOUR, + endsAt: slot.endsAt, + }); + + const day = (await weeksOf(friendId()))?.[0].days[0]; + + expect(day?.ranges).toEqual([ + { startsAt: slot.startsAt, endsAt: slot.startsAt + HOUR }, + ]); + }); +}); diff --git a/app/features/availability/core/FriendSchedule.server.ts b/app/features/availability/core/FriendSchedule.server.ts new file mode 100644 index 000000000..24b6615db --- /dev/null +++ b/app/features/availability/core/FriendSchedule.server.ts @@ -0,0 +1,77 @@ +import { addWeeks } from "date-fns"; +import * as R from "remeda"; +import * as AvailabilityRepository from "../AvailabilityRepository.server"; +import { AVAILABILITY } from "../availability-constants"; +import type { ScheduleWeekView } from "../availability-types"; +import * as Availability from "./Availability"; +import * as Commitments from "./Commitments.server"; +import * as ScheduleWeek from "./ScheduleWeek"; + +/** + * The reportable weeks of the given users as the friends page's week modal + * shows them, keyed by user id: nothing but the time they are free to play, + * commitments already subtracted. Users who reported neither week are left out, + * so a missing key is what "no schedule to show" means — and the friends page + * both sorts and shows its calendar icon by that. + * + * Everyone asked about is a friend or a teammate of the viewer, which is what + * makes their schedule theirs to see; the caller owns that guarantee. + */ +export async function findByUserIds({ + userIds, + timezone, +}: { + userIds: Array; + timezone: string; +}): Promise>> { + const now = new Date(); + + const ranges = R.range(0, AVAILABILITY.WEEK_HORIZON).map((weekOffset) => + Availability.weekRange(addWeeks(now, weekOffset), timezone), + ); + const horizon = { + startsAt: ranges[0].startsAt, + endsAt: ranges[ranges.length - 1].endsAt, + }; + + const [reportedWeeks, busyByUserId] = await Promise.all([ + AvailabilityRepository.findAllWeeksByUserIds({ userIds, ...horizon }), + Commitments.busyBlocksByUserIds({ userIds, ...horizon }), + ]); + + const weeks = ranges.map((range, index) => ({ + range, + week: index === 0 ? ("current" as const) : ("next" as const), + weekNumber: ScheduleWeek.weekNumber(range, timezone), + days: ScheduleWeek.days(range, timezone), + })); + + return new Map( + userIds.flatMap((userId) => { + const busy = busyByUserId.get(userId) ?? []; + + const views = weeks.map((week): ScheduleWeekView => { + const row = ScheduleWeek.memberRow({ + userId, + days: week.days, + timezone, + reportedWeeks, + range: week.range, + busy, + }); + + return { + week: week.week, + weekNumber: week.weekNumber, + reported: row.reported, + days: week.days.map((day, dayIndex) => ({ + noonAt: day.noonAt, + ranges: row.days[dayIndex].ranges, + })), + }; + }); + + return views.some((view) => view.reported) ? [[userId, views]] : []; + }), + ); +} diff --git a/app/features/availability/core/MySchedule.server.ts b/app/features/availability/core/MySchedule.server.ts new file mode 100644 index 000000000..0815ad60b --- /dev/null +++ b/app/features/availability/core/MySchedule.server.ts @@ -0,0 +1,129 @@ +import { addWeeks, subWeeks } from "date-fns"; +import * as R from "remeda"; +import { getViewerTimezone } from "~/features/timezone/timezone-context.server"; +import type { SerializeFrom } from "~/utils/remix"; +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"; +import * as ScheduleWeek from "./ScheduleWeek"; + +export type MyScheduleData = SerializeFrom< + Awaited> +>; + +/** + * The user's own reported schedule for the editable weeks (current and next) + * in their timezone, as the wall-clock representation the schedule editor + * uses. Also carries the ranges of the week before the current one for the + * "Copy last week" prefill. + */ +export async function myScheduleData(userId: number) { + const timezone = getViewerTimezone() ?? "UTC"; + const now = new Date(); + + const lastWeekRange = Availability.weekRange(subWeeks(now, 1), timezone); + 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({ + range: Availability.weekRange(addWeeks(now, weekOffset), timezone), + timezone, + reportedWeeks, + }), + ); + + const lastWeek = editorWeek({ + range: lastWeekRange, + timezone, + reportedWeeks, + }); + + return { + weeks, + 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, + })), + }; +} + +type ReportedWeek = Awaited< + ReturnType +>[number]; + +function editorWeek({ + range, + timezone, + reportedWeeks, +}: { + range: TimeRange; + timezone: string; + reportedWeeks: Array; +}) { + const matchingWeek = reportedWeeks.find((week) => + Availability.isSameWeek(week.weekStartsAt, range.startsAt), + ); + + const days = ScheduleWeek.days(range, timezone).map(({ date }) => ({ + date, + ranges: Availability.mergedDayRanges( + (matchingWeek?.slots ?? []) + .filter( + (slot) => + Availability.dateInTimezone(slot.startsAt, timezone) === date, + ) + .map((slot) => slotToDayRange(slot, timezone)), + ), + note: matchingWeek ? noteOfDay(matchingWeek, date, timezone) : "", + })); + + return { + weekStartsAt: range.startsAt, + weekNumber: ScheduleWeek.weekNumber(range, timezone), + submitted: Boolean(matchingWeek), + days, + }; +} + +function slotToDayRange(slot: TimeRange, timezone: string): DayTimeRange { + const start = Availability.timeToMinutes( + Availability.timeInTimezone(slot.startsAt, timezone), + ); + + return { start, end: start + Math.round((slot.endsAt - slot.startsAt) / 60) }; +} + +function noteOfDay(week: ReportedWeek, date: string, timezone: string) { + return ( + week.dayNotes.find( + (note) => + Availability.dateAcrossTimezones({ + date: note.date, + from: week.timezone, + to: timezone, + }) === date, + )?.text ?? "" + ); +} diff --git a/app/features/availability/core/RegistrationAvailability.server.test.ts b/app/features/availability/core/RegistrationAvailability.server.test.ts new file mode 100644 index 000000000..f8939d9e8 --- /dev/null +++ b/app/features/availability/core/RegistrationAvailability.server.test.ts @@ -0,0 +1,89 @@ +import { addWeeks, subWeeks } from "date-fns"; +import { beforeEach, describe, expect, test } from "vitest"; +import * as AvailabilityWeekFactory from "~/db/seed/factories/AvailabilityWeekFactory"; +import * as UserFactory from "~/db/seed/factories/UserFactory"; +import { databaseTimestampToDate } from "~/utils/dates"; +import { AVAILABILITY } from "../availability-constants"; +import * as Availability from "./Availability"; +import * as RegistrationAvailability from "./RegistrationAvailability.server"; + +const users = UserFactory.pool(); +const playerId = () => users.id(1); + +const TIMEZONE = "UTC"; +const HOUR = 60 * 60; +const DAY = 24 * HOUR; + +const weekStartsAtIn = (weeksFromNow: number) => + Availability.weekStartsAt(addWeeks(new Date(), weeksFromNow), TIMEZONE); + +const tournamentStartingAt = (startsAt: number) => ({ + id: 1, + name: "In The Zone", + organizationId: null, + startsAt, + minMembersPerTeam: 4, + bracketTypes: ["single_elimination" as const], + teamCount: 8, +}); + +const availabilityFor = (startsAt: number) => + RegistrationAvailability.registrationAvailability({ + tournament: tournamentStartingAt(startsAt), + userIds: [playerId()], + timezone: TIMEZONE, + }); + +describe("RegistrationAvailability.registrationAvailability", () => { + beforeEach(async () => { + await users.create(1); + }); + + test("computes nothing for a tournament past the reportable horizon", async () => { + const startsAt = weekStartsAtIn(AVAILABILITY.WEEK_HORIZON) + 18 * HOUR; + + const result = await availabilityFor(startsAt); + + expect(result.window).toBeNull(); + expect(result.entries).toBeNull(); + expect(result.beyondHorizon?.opensAt).toBe( + Availability.weekStartsAt( + subWeeks(databaseTimestampToDate(startsAt), 1), + TIMEZONE, + ), + ); + }); + + test("computes availability for a tournament on the last day still within the horizon", async () => { + const startsAt = weekStartsAtIn(AVAILABILITY.WEEK_HORIZON) - HOUR; + + const result = await availabilityFor(startsAt); + + expect(result.beyondHorizon).toBeNull(); + expect(result.window?.startsAt).toBe(startsAt); + expect(result.entries).toHaveLength(1); + }); + + test("returns only the day notes falling inside the tournament's window", async () => { + const weekStartsAt = weekStartsAtIn(1); + const startsAt = weekStartsAt + 2 * DAY + 18 * HOUR; + const dateOfDay = (dayIndex: number) => + Availability.dateInTimezone( + weekStartsAt + dayIndex * DAY + DAY / 2, + TIMEZONE, + ); + await AvailabilityWeekFactory.create({ + userId: playerId(), + weekStartsAt, + timezone: TIMEZONE, + dayNotes: [ + { date: dateOfDay(2), text: "Have to leave by 21" }, + { date: dateOfDay(5), text: "Away for the weekend" }, + ], + }); + + const result = await availabilityFor(startsAt); + + expect(result.entries?.[0].notes).toEqual(["Have to leave by 21"]); + }); +}); diff --git a/app/features/availability/core/RegistrationAvailability.server.ts b/app/features/availability/core/RegistrationAvailability.server.ts new file mode 100644 index 000000000..ef3189cd6 --- /dev/null +++ b/app/features/availability/core/RegistrationAvailability.server.ts @@ -0,0 +1,110 @@ +import { addWeeks, subWeeks } from "date-fns"; +import type { Tables } from "~/db/tables"; +import { databaseTimestampToDate } from "~/utils/dates"; +import * as AvailabilityRepository from "../AvailabilityRepository.server"; +import { AVAILABILITY } from "../availability-constants"; +import type { TimeRange } from "../availability-types"; +import * as Availability from "./Availability"; +import * as Commitments from "./Commitments.server"; +import { estimatedEndsAt } from "./TournamentDuration.server"; + +export type RegistrationAvailability = Awaited< + ReturnType +>; + +/** + * Availability of the given users for a tournament's estimated window + * (start to {@link estimatedEndsAt}), for the registration + * page's availability panel. The tournament's own registrations do not count + * as being busy — the panel asks whether people can play this very event. + * + * When the event starts past the reportable horizon there is nothing to + * compute: every schedule would be unknown, so the result is only when + * schedules for the event's week open up (the Monday its week becomes the + * "next week"). + */ +export async function registrationAvailability({ + tournament, + userIds, + timezone, +}: { + tournament: { + id: number; + name: string; + organizationId: number | null; + startsAt: number; + minMembersPerTeam: number; + bracketTypes: Array; + teamCount: number; + }; + userIds: Array; + timezone: string; +}) { + const startDate = databaseTimestampToDate(tournament.startsAt); + + const horizon = Availability.weekRange( + addWeeks(new Date(), AVAILABILITY.WEEK_HORIZON - 1), + timezone, + ); + if (tournament.startsAt >= horizon.endsAt) { + return { + beyondHorizon: { + opensAt: Availability.weekStartsAt(subWeeks(startDate, 1), timezone), + }, + window: null, + entries: null, + }; + } + + const window: TimeRange = { + startsAt: tournament.startsAt, + endsAt: await estimatedEndsAt(tournament), + }; + + const [weeks, busyByUserId] = await Promise.all([ + AvailabilityRepository.findAllWeeksByUserIds({ userIds, ...window }), + Commitments.busyBlocksByUserIds({ + userIds, + ...window, + excludeTournamentId: tournament.id, + }), + ]); + + const windowDates = [ + Availability.dateInTimezone(window.startsAt, timezone), + Availability.dateInTimezone(window.endsAt - 1, timezone), + ]; + + const entries = userIds.map((userId) => { + const userWeeks = weeks.filter((week) => week.userId === userId); + + return { + userId, + availability: Availability.availabilityInWindow({ + reported: userWeeks.some( + (week) => + Availability.weekStartsAt(startDate, week.timezone) === + week.weekStartsAt, + ), + slots: userWeeks.flatMap((week) => week.slots), + busy: busyByUserId.get(userId) ?? [], + window, + }), + notes: userWeeks.flatMap((week) => + week.dayNotes + .filter((note) => + windowDates.includes( + Availability.dateAcrossTimezones({ + date: note.date, + from: week.timezone, + to: timezone, + }), + ), + ) + .map((note) => note.text), + ), + }; + }); + + return { beyondHorizon: null, window, entries }; +} diff --git a/app/features/availability/core/RosterSchedule.server.test.ts b/app/features/availability/core/RosterSchedule.server.test.ts new file mode 100644 index 000000000..29180b6f1 --- /dev/null +++ b/app/features/availability/core/RosterSchedule.server.test.ts @@ -0,0 +1,191 @@ +import { beforeEach, describe, expect, test } from "vitest"; +import * as AvailabilityWeekFactory from "~/db/seed/factories/AvailabilityWeekFactory"; +import * as TeamEventFactory from "~/db/seed/factories/TeamEventFactory"; +import * as TeamFactory from "~/db/seed/factories/TeamFactory"; +import * as UserFactory from "~/db/seed/factories/UserFactory"; +import { AVAILABILITY } from "../availability-constants"; +import * as Availability from "./Availability"; +import * as RosterSchedule from "./RosterSchedule.server"; + +const users = UserFactory.pool(); +const memberId = () => users.id(1); +const teammateId = () => users.id(2); + +const TIMEZONE = "Europe/Helsinki"; +const HOUR = 60 * 60; + +const currentWeekStartsAt = () => + Availability.weekStartsAt(new Date(), TIMEZONE); + +const dataOf = (userIds: Array) => + RosterSchedule.rosterScheduleData({ userIds, timezone: TIMEZONE }); + +const memberOf = async (userId: number) => + (await dataOf([userId])).members.find((member) => member.userId === userId); + +describe("RosterSchedule.rosterScheduleData", () => { + beforeEach(async () => { + await users.create(2); + }); + + test("lays out the current and the next week as seven days each", async () => { + const { weeks } = await dataOf([memberId()]); + + expect(weeks).toHaveLength(2); + expect(weeks[0].startsAt).toBe(currentWeekStartsAt()); + expect(weeks[1].startsAt).toBe(weeks[0].endsAt); + + for (const week of weeks) { + expect(week.days).toHaveLength(7); + expect(week.days[0].startsAt).toBe(week.startsAt); + expect(week.days[6].endsAt).toBe(week.endsAt); + } + }); + + test("reports which of the weeks the member has filled in", async () => { + await AvailabilityWeekFactory.create({ + userId: memberId(), + weekStartsAt: currentWeekStartsAt(), + timezone: TIMEZONE, + }); + + expect((await memberOf(memberId()))?.reportedWeekStarts).toEqual([ + currentWeekStartsAt(), + ]); + }); + + test("cuts a commitment out of the reported availability", async () => { + const slot = { + startsAt: currentWeekStartsAt() + 18 * HOUR, + endsAt: currentWeekStartsAt() + 22 * HOUR, + }; + await AvailabilityWeekFactory.create({ + userId: memberId(), + weekStartsAt: currentWeekStartsAt(), + timezone: TIMEZONE, + slots: [slot], + }); + const team = await TeamFactory.create({ + memberUserIds: [memberId(), teammateId()], + }); + await TeamEventFactory.create({ + teamId: team.id, + authorId: memberId(), + name: "VoD review", + startsAt: slot.startsAt + HOUR, + endsAt: slot.startsAt + 2 * HOUR, + }); + + const member = await memberOf(memberId()); + + expect(member?.ranges).toEqual([ + { startsAt: slot.startsAt, endsAt: slot.startsAt + HOUR }, + { startsAt: slot.startsAt + 2 * HOUR, endsAt: slot.endsAt }, + ]); + }); + + test("returns a member with nothing reported as an empty week", async () => { + expect(await memberOf(memberId())).toEqual({ + userId: memberId(), + reportedWeekStarts: [], + ranges: [], + }); + }); +}); + +describe("RosterSchedule.windowSchedules", () => { + const window = (id: number, from: number, to: number) => ({ + id, + startsAt: currentWeekStartsAt() + from * HOUR, + endsAt: currentWeekStartsAt() + to * HOUR, + }); + + const schedulesOf = async ( + windows: Array>, + userIds: Array = [memberId()], + ) => RosterSchedule.windowSchedules({ windows, userIds }); + + beforeEach(async () => { + await users.create(2); + }); + + test("reports what the member has free inside the window", async () => { + await AvailabilityWeekFactory.create({ + userId: memberId(), + weekStartsAt: currentWeekStartsAt(), + timezone: TIMEZONE, + slots: [ + { + startsAt: currentWeekStartsAt() + 18 * HOUR, + endsAt: currentWeekStartsAt() + 22 * HOUR, + }, + ], + }); + + const [schedules] = await schedulesOf([window(1, 20, 23)]); + + expect(schedules.members).toEqual([ + { + userId: memberId(), + reported: true, + ranges: [ + { + startsAt: currentWeekStartsAt() + 20 * HOUR, + endsAt: currentWeekStartsAt() + 22 * HOUR, + }, + ], + busy: [], + }, + ]); + }); + + test("cuts a commitment out of the availability and reports it", async () => { + await AvailabilityWeekFactory.create({ + userId: memberId(), + weekStartsAt: currentWeekStartsAt(), + timezone: TIMEZONE, + slots: [ + { + startsAt: currentWeekStartsAt() + 18 * HOUR, + endsAt: currentWeekStartsAt() + 22 * HOUR, + }, + ], + }); + const team = await TeamFactory.create({ + memberUserIds: [memberId(), teammateId()], + }); + await TeamEventFactory.create({ + teamId: team.id, + authorId: memberId(), + name: "VoD review", + startsAt: currentWeekStartsAt() + 19 * HOUR, + endsAt: currentWeekStartsAt() + 20 * HOUR, + }); + + const [schedules] = await schedulesOf([window(1, 18, 22)]); + + expect(schedules.members[0].ranges).toEqual([ + { + startsAt: currentWeekStartsAt() + 18 * HOUR, + endsAt: currentWeekStartsAt() + 19 * HOUR, + }, + { + startsAt: currentWeekStartsAt() + 20 * HOUR, + endsAt: currentWeekStartsAt() + 22 * HOUR, + }, + ]); + expect(schedules.members[0].busy).toHaveLength(1); + }); + + test("marks a week the member never filled in as not reported", async () => { + const [schedules] = await schedulesOf([window(1, 18, 20)]); + + expect(schedules.members[0].reported).toBe(false); + }); + + test("leaves out a window past the reportable horizon", async () => { + const beyond = 24 * 7 * (AVAILABILITY.WEEK_HORIZON + 1); + + expect(await schedulesOf([window(1, beyond, beyond + 2)])).toEqual([]); + }); +}); diff --git a/app/features/availability/core/RosterSchedule.server.ts b/app/features/availability/core/RosterSchedule.server.ts new file mode 100644 index 000000000..04b9ba914 --- /dev/null +++ b/app/features/availability/core/RosterSchedule.server.ts @@ -0,0 +1,185 @@ +import { addWeeks } from "date-fns"; +import * as R from "remeda"; +import { + databaseTimestampToDate, + dateToDatabaseTimestamp, +} from "~/utils/dates"; +import type { SerializeFrom } from "~/utils/remix"; +import * as AvailabilityRepository from "../AvailabilityRepository.server"; +import { AVAILABILITY } from "../availability-constants"; +import type { TimeRange, WindowSchedule } from "../availability-types"; +import * as Availability from "./Availability"; +import * as Commitments from "./Commitments.server"; +import * as ScheduleWeek from "./ScheduleWeek"; + +const DAY_SECONDS = 24 * 60 * 60; + +export type RosterScheduleData = SerializeFrom< + Awaited> +>; + +/** + * Effective availability of the given users over the reportable horizon, laid + * out as the viewer-local weeks and days the schedule surfaces render on. + * + * Which of these users make up a roster is only known in the browser (the + * scrim post form's team select, its pick-up member search), so the roster's + * shared free time is not resolved here — the members come out one by one and + * {@link Availability.playableWindows} merges the picked ones client side. + */ +export async function rosterScheduleData({ + userIds, + timezone, +}: { + userIds: Array; + timezone: string; +}) { + const now = new Date(); + 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, ...horizon }), + Commitments.busyBlocksByUserIds({ userIds, ...horizon }), + ]); + + const weeks = R.range(0, AVAILABILITY.WEEK_HORIZON).map((weekOffset) => + weekView({ + range: Availability.weekRange(addWeeks(now, weekOffset), timezone), + timezone, + }), + ); + + return { + /** Server clock, so that the picker's cutoff of past windows renders the same before and after hydration. */ + now: dateToDatabaseTimestamp(now), + weeks, + members: userIds.map((userId) => { + const memberWeeks = reportedWeeks.filter( + (week) => week.userId === userId, + ); + const busy = busyByUserId.get(userId) ?? []; + + return { + userId, + reportedWeekStarts: weeks + .filter((week) => + memberWeeks.some((memberWeek) => + Availability.isSameWeek(memberWeek.weekStartsAt, week.startsAt), + ), + ) + .map((week) => week.startsAt), + ranges: Availability.subtract( + Availability.clip( + memberWeeks.flatMap((week) => week.slots), + horizon, + ), + busy, + ), + }; + }), + }; +} + +function weekView({ range, timezone }: { range: TimeRange; timezone: string }) { + const dates = ScheduleWeek.days(range, timezone); + const dayStartsAt = (dayIndex: number) => + dayIndex === 7 + ? range.endsAt + : Availability.localToTimestamp({ + date: dates[dayIndex].date, + time: "00:00", + timezone, + }); + + return { + startsAt: range.startsAt, + endsAt: range.endsAt, + weekNumber: ScheduleWeek.weekNumber(range, timezone), + days: R.range(0, 7).map((dayIndex) => { + const startsAt = dayStartsAt(dayIndex); + + return { + startsAt, + endsAt: dayStartsAt(dayIndex + 1), + noonAt: startsAt + DAY_SECONDS / 2, + }; + }), + }; +} + +/** + * What the given users' schedules say about each of the given windows: what + * they reported inside it, the commitments overriding that and whether they + * filled in the week it falls in at all. + * + * The windows are resolved in one go so that a page showing many of them (the + * scrim browsing page's fit indicators) reads the schedules once. Windows past + * the reportable horizon are left out — nothing could be known about them. + */ +export async function windowSchedules({ + windows, + userIds, +}: { + windows: Array; + userIds: Array; +}) { + // the horizon's last week starts at the current week's start at the latest, + // so nothing inside it reaches this far + const horizonEndsAt = dateToDatabaseTimestamp( + addWeeks(new Date(), AVAILABILITY.WEEK_HORIZON), + ); + const withinHorizon = windows.filter( + (window) => window.startsAt < horizonEndsAt, + ); + + if (withinHorizon.length === 0 || userIds.length === 0) return []; + + const range = { + startsAt: Math.min(...withinHorizon.map((window) => window.startsAt)), + endsAt: Math.max(...withinHorizon.map((window) => window.endsAt)), + }; + + const [reportedWeeks, busyByUserId] = await Promise.all([ + AvailabilityRepository.findAllWeeksByUserIds({ userIds, ...range }), + Commitments.busyBlocksByUserIds({ userIds, ...range }), + ]); + + return withinHorizon.map((window) => ({ + id: window.id, + members: userIds.map((userId): WindowSchedule => { + const memberWeeks = reportedWeeks.filter( + (week) => week.userId === userId, + ); + const busy = (busyByUserId.get(userId) ?? []).filter((block) => + Availability.overlaps(block, window), + ); + + return { + userId, + // which week a window falls in is a question about the member's own + // clock, the same one they filled the week in on + reported: memberWeeks.some( + (week) => + Availability.weekStartsAt( + databaseTimestampToDate(window.startsAt), + week.timezone, + ) === week.weekStartsAt, + ), + ranges: Availability.clip( + Availability.subtract( + memberWeeks.flatMap((week) => week.slots), + busy, + ), + window, + ), + busy, + }; + }), + })); +} diff --git a/app/features/availability/core/ScheduleWeek.ts b/app/features/availability/core/ScheduleWeek.ts new file mode 100644 index 000000000..39e17a9bf --- /dev/null +++ b/app/features/availability/core/ScheduleWeek.ts @@ -0,0 +1,124 @@ +import * as R from "remeda"; +import type { BusyBlock, TimeRange } from "../availability-types"; +import * as Availability from "./Availability"; + +const DAY_SECONDS = 24 * 60 * 60; + +/** One day of a schedule week, as the viewer's timezone places it. */ +export interface ScheduleWeekDay { + /** `YYYY-MM-DD` in the viewer's timezone */ + date: string; + noonAt: number; +} + +/** A week of reported availability, in the shape the repository returns it. */ +export interface ReportedWeek { + userId: number; + weekStartsAt: number; + timezone: string; + slots: Array; + dayNotes: Array<{ date: string; text: string }>; +} + +/** One member's week as the read-only schedule surfaces render it. */ +export interface MemberWeek { + userId: number; + /** Whether they filled the week in at all. */ + reported: boolean; + days: Array<{ ranges: Array; busy: Array }>; + notes: Array<{ dayIndex: number; text: string }>; +} + +/** The seven days a week is laid out on in the viewer's timezone, Monday first. */ +export function days( + range: TimeRange, + timezone: string, +): Array { + return R.range(0, 7).map((dayIndex) => { + const noonAt = range.startsAt + dayIndex * DAY_SECONDS + DAY_SECONDS / 2; + + return { date: Availability.dateInTimezone(noonAt, timezone), noonAt }; + }); +} + +/** The week's ISO number, as its heading names it. */ +export function weekNumber(range: TimeRange, timezone: string) { + return Availability.isoWeekNumber(range.startsAt + DAY_SECONDS / 2, timezone); +} + +/** + * One member's week bucketed into the viewer's days: what they are effectively + * free for, the commitments taking time back and the notes they left. + * + * Slots are placed on the viewer-local day they start on, wherever their + * author's week put them — the adjacent weeks' spillover included. What a + * commitment takes back is cut out first: the days show when the member is + * actually free. + */ +export function memberRow({ + userId, + days, + timezone, + reportedWeeks, + range, + busy, +}: { + userId: number; + days: Array; + timezone: string; + reportedWeeks: Array; + range: TimeRange; + busy: Array; +}): MemberWeek { + const busyOfDay = (day: ScheduleWeekDay) => + busy.filter( + (block) => + Availability.dateInTimezone(block.startsAt, timezone) === day.date, + ); + + const memberWeeks = reportedWeeks.filter((week) => week.userId === userId); + const matchingWeek = memberWeeks.find((week) => + Availability.isSameWeek(week.weekStartsAt, range.startsAt), + ); + + if (!matchingWeek) { + return { + userId, + reported: false, + days: days.map((day) => ({ + ranges: [] as Array, + busy: busyOfDay(day), + })), + notes: [], + }; + } + + const slots = Availability.subtract( + memberWeeks.flatMap((week) => week.slots), + busy, + ); + + return { + userId, + reported: true, + 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.dateAcrossTimezones({ + date: note.date, + from: week.timezone, + to: timezone, + }); + const dayIndex = days.findIndex((day) => day.date === noteDate); + + return dayIndex === -1 ? [] : [{ dayIndex, text: note.text }]; + }), + ), + }; +} diff --git a/app/features/availability/core/TournamentDuration.server.ts b/app/features/availability/core/TournamentDuration.server.ts new file mode 100644 index 000000000..9e9bf3063 --- /dev/null +++ b/app/features/availability/core/TournamentDuration.server.ts @@ -0,0 +1,38 @@ +import type { Tables } from "~/db/tables"; +import * as SeriesTeamCount from "~/features/tournament-organization/core/SeriesTeamCount.server"; +import * as TournamentDuration from "./TournamentDuration"; + +interface EstimatedTournament { + name: string; + organizationId: number | null; + startsAt: number; + minMembersPerTeam: number; + bracketTypes: Array; + /** Teams registered so far. */ + teamCount: number; +} + +/** + * When a tournament is estimated to end: its start plus + * {@link TournamentDuration.estimateSeconds}, sized by the count the event is + * expected to draw rather than the one registered so far. Every surface showing + * or blocking out a tournament's window goes through this so the two agree. + */ +export async function estimatedEndsAt(tournament: EstimatedTournament) { + return estimatedEndsAtWith(tournament, await SeriesTeamCount.lookup()); +} + +/** {@link estimatedEndsAt} for callers estimating many tournaments off one resolved lookup. */ +export function estimatedEndsAtWith( + tournament: EstimatedTournament, + expectedTeamCount: (tournament: EstimatedTournament) => number, +) { + return ( + tournament.startsAt + + TournamentDuration.estimateSeconds({ + minMembersPerTeam: tournament.minMembersPerTeam, + bracketTypes: tournament.bracketTypes, + teamCount: expectedTeamCount(tournament), + }) + ); +} 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..ea3171282 --- /dev/null +++ b/app/features/availability/core/TournamentDuration.ts @@ -0,0 +1,73 @@ +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. Only for a tournament played in one + * sitting, the numbers being measured over whole events. + * + * 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. Callers pass what the + * event is *expected* to draw, see `SeriesTeamCount.lookup`. + * - 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. 84.7% of 4v4 tournaments end within their window. + */ +export function estimateSeconds({ + minMembersPerTeam, + bracketTypes, + teamCount, +}: { + minMembersPerTeam: number; + bracketTypes: Array; + /** Teams the tournament is expected to draw, not necessarily the registered count. */ + 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 new file mode 100644 index 000000000..30a7af45c --- /dev/null +++ b/app/features/availability/loaders/t.$customUrl.schedule.server.ts @@ -0,0 +1,186 @@ +import { addWeeks } from "date-fns"; +import type { LoaderFunctionArgs } from "react-router"; +import * as R from "remeda"; +import * as v from "valibot"; +import { getUser } from "~/features/auth/core/user.server"; +import { resolveNotifications } from "~/features/notifications/core/resolve.server"; +import * as TeamRepository from "~/features/team/TeamRepository.server"; +import { teamParamsSchema } from "~/features/team/team-schemas.server"; +import { getMemberRoleType, isTeamMember } from "~/features/team/team-utils"; +import { getViewerTimezone } from "~/features/timezone/timezone-context.server"; +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 { + BusyBlock, + PlayableWindowTier, + TimeRange, +} from "../availability-types"; +import * as Availability from "../core/Availability"; +import * as Commitments from "../core/Commitments.server"; +import * as ScheduleWeek from "../core/ScheduleWeek"; + +export type TeamScheduleLoaderData = SerializeFrom; + +export const loader = async ({ params }: LoaderFunctionArgs) => { + const { customUrl } = v.parse(teamParamsSchema, params); + + const team = notFoundIfNullish( + await TeamRepository.findByCustomUrl(customUrl), + ); + + const user = getUser(); + if (!user || !isTeamMember({ team, user })) { + return { weeks: null }; + } + + await resolveNotifications({ + userIds: [user.id], + type: "TEAM_EVENT_ADDED", + meta: { teamCustomUrl: team.customUrl }, + }); + + const members = team.members.filter( + (member) => member.role !== "CHEERLEADER", + ); + const timezone = getViewerTimezone() ?? "UTC"; + const now = new Date(); + + const horizon = { + startsAt: Availability.weekRange(now, timezone).startsAt, + endsAt: Availability.weekRange( + addWeeks(now, AVAILABILITY.WEEK_HORIZON - 1), + timezone, + ).endsAt, + }; + const [reportedWeeks, busyByUserId, teamEvents] = await Promise.all([ + AvailabilityRepository.findAllWeeksByUserIds({ + userIds: members.map((member) => member.id), + ...horizon, + }), + Commitments.busyBlocksByUserIds({ + userIds: members.map((member) => member.id), + ...horizon, + }), + AvailabilityRepository.findTeamEventsByTeamId({ + teamId: team.id, + ...horizon, + }), + ]); + + const playerIds = members + .filter((member) => getMemberRoleType(member) !== "OTHER") + .map((member) => member.id); + + return { + weeks: R.range(0, AVAILABILITY.WEEK_HORIZON).map((weekOffset) => + weekView({ + range: Availability.weekRange(addWeeks(now, weekOffset), timezone), + timezone, + memberIds: members.map((member) => member.id), + playerIds, + reportedWeeks, + busyByUserId, + teamEvents, + }), + ), + }; +}; + +type TeamEventRow = Awaited< + ReturnType +>[number]; + +function weekView({ + range, + timezone, + memberIds, + playerIds, + reportedWeeks, + busyByUserId, + teamEvents, +}: { + range: TimeRange; + timezone: string; + memberIds: Array; + playerIds: Array; + reportedWeeks: Array; + busyByUserId: Map>; + teamEvents: Array; +}) { + const minPlayers = Math.min( + AVAILABILITY.DEFAULT_MIN_PLAYERS, + playerIds.length, + ); + + const windows = Availability.playableWindows({ + members: playerIds.map((userId) => ({ + userId, + ranges: Availability.subtract( + Availability.clip( + reportedWeeks + .filter((week) => week.userId === userId) + .flatMap((week) => week.slots), + range, + ), + busyByUserId.get(userId) ?? [], + ), + })), + minPlayers, + }).map((window) => R.omit(window, ["userIds"])); + + const days = ScheduleWeek.days(range, timezone).map((day) => ({ + ...day, + windowTier: bestWindowTierOfDay({ date: day.date, windows, timezone }), + })); + + const members = memberIds.map((userId) => + ScheduleWeek.memberRow({ + userId, + days, + timezone, + reportedWeeks, + range, + busy: busyByUserId.get(userId) ?? [], + }), + ); + + return { + startsAt: range.startsAt, + weekNumber: ScheduleWeek.weekNumber(range, timezone), + days, + members, + windows, + minPlayers, + teamEvents: teamEvents.filter( + (event) => + event.startsAt >= range.startsAt && event.startsAt < range.endsAt, + ), + }; +} + +/** + * Tier of the best playable window starting on the given viewer-local day, the + * same day a window renders its grid ranges on. + */ +function bestWindowTierOfDay({ + date, + windows, + timezone, +}: { + date: string; + windows: Array; + timezone: string; +}): PlayableWindowTier | null { + const tiers = windows + .filter( + (window) => + Availability.dateInTimezone(window.startsAt, timezone) === date, + ) + .map((window) => window.tier); + + if (tiers.includes("FULL")) return "FULL"; + if (tiers.includes("ONE_SHORT")) return "ONE_SHORT"; + return null; +} diff --git a/app/features/availability/routes/t.$customUrl.schedule.module.css b/app/features/availability/routes/t.$customUrl.schedule.module.css new file mode 100644 index 000000000..5a8850a20 --- /dev/null +++ b/app/features/availability/routes/t.$customUrl.schedule.module.css @@ -0,0 +1,196 @@ +.header { + display: flex; + flex-wrap: wrap; + align-items: center; + justify-content: space-between; + gap: var(--s-2); +} + +.heading { + font-size: var(--font-md); + color: var(--color-text); +} + +/* Lets the grid size against the full content area instead of the page width + (the team layout renders its
    in breakout mode), capped at the wide + page width and centered back under the normal-width column. */ +.gridScroll { + overflow-x: auto; + + :global([data-main-breakout]) & { + width: min(100cqw, 72rem); + margin-inline: calc(50% - min(50cqw, 36rem)); + } +} + +.grid { + width: 100%; + border-collapse: collapse; + font-size: var(--font-xs); + + & th, + & td { + padding: var(--s-1-5) var(--s-2); + text-align: left; + vertical-align: top; + white-space: nowrap; + } + + /* the member column centers between the row lines while the day cells stay + a top-aligned list */ + & tbody th[scope="row"] { + vertical-align: middle; + } + + & tbody tr { + border-top: var(--border-style); + } +} + +.dayHeader { + font-size: var(--font-2xs); + font-weight: var(--weight-semi); + color: var(--color-text-high); +} + +.memberCell { + position: sticky; + left: 0; + background-color: var(--color-bg); + font-weight: var(--weight-semi); + max-width: 10rem; + overflow: hidden; + text-overflow: ellipsis; + + /* block-level so the link centers by the cell's vertical-align alone, + without the descender gap an inline box leaves under the baseline */ + & .memberLink { + display: flex; + } +} + +.noteFlag { + color: var(--color-text-accent); +} + +.summary { + display: flex; + flex-direction: column; + gap: var(--s-1); + font-size: var(--font-xs); +} + +.summaryRow { + display: flex; + flex-wrap: wrap; + align-items: baseline; + gap: var(--s-1-5); +} + +.summaryLabel { + font-weight: var(--weight-semi); +} + +.tierDot { + display: inline-block; + vertical-align: middle; + align-self: center; + flex-shrink: 0; + width: 0.6rem; + height: 0.6rem; + border-radius: 50%; + background-color: var(--color-success-low); + border: 1px solid var(--color-success); + + &.tierDotFull { + background-color: var(--color-success); + } +} + +.dayDot { + margin-inline-end: var(--s-1); +} + +.windowList { + display: inline-flex; + flex-wrap: wrap; + gap: var(--s-1) var(--s-2); +} + +.window { + white-space: nowrap; +} + +.notes { + display: flex; + flex-direction: column; + gap: var(--s-1); + padding: 0; + list-style: none; + font-size: var(--font-xs); +} + +.note { + display: flex; + align-items: baseline; + gap: var(--s-1-5); + + & .noteFlag { + align-self: center; + flex-shrink: 0; + } +} + +.noteDay, +.noteAuthor { + font-weight: var(--weight-semi); + color: var(--color-text-high); + white-space: nowrap; +} + +.events { + display: flex; + flex-direction: column; + gap: var(--s-2); + padding-block: var(--s-3); +} + +.eventsHeader { + display: flex; + align-items: center; + justify-content: space-between; + gap: var(--s-2); +} + +.eventsHeading { + font-size: var(--font-sm); + color: var(--color-text); +} + +.eventsList { + display: flex; + flex-direction: column; + gap: var(--s-1); + padding: 0; + list-style: none; + font-size: var(--font-xs); +} + +.event { + display: flex; + align-items: center; + gap: var(--s-2); +} + +.eventDay, +.eventTime { + font-weight: var(--weight-semi); + color: var(--color-text-high); + white-space: nowrap; +} + +.eventName { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} diff --git a/app/features/availability/routes/t.$customUrl.schedule.tsx b/app/features/availability/routes/t.$customUrl.schedule.tsx new file mode 100644 index 000000000..7cd07514b --- /dev/null +++ b/app/features/availability/routes/t.$customUrl.schedule.tsx @@ -0,0 +1,407 @@ +import clsx from "clsx"; +import { isSameDay } from "date-fns"; +import { Flag, Plus, Trash } from "lucide-react"; +import * as React from "react"; +import { useTranslation } from "react-i18next"; +import { useLoaderData, useMatches } from "react-router"; +import * as R from "remeda"; +import { ActionButton } from "~/components/ActionButton"; +import { Alert } from "~/components/Alert"; +import { SendouButton } from "~/components/elements/Button"; +import { SendouDialog } from "~/components/elements/Dialog"; +import { FormMessage } from "~/components/FormMessage"; +import { UserLink } from "~/components/UserLink"; +import { TeamGoBackButton } from "~/features/team/components/TeamGoBackButton"; +import type { TeamLoaderData } from "~/features/team/loaders/t.$customUrl.server"; +import { getMemberRoleType } from "~/features/team/team-utils"; +import { timezoneMiddleware } from "~/features/timezone/timezone-middleware.server"; +import { SendouForm } from "~/form/SendouForm"; +import { useDateTimeFormat } from "~/hooks/intl/useDateTimeFormat"; +import { useHasPermission } from "~/modules/permissions/hooks"; +import { useSearchParamsTyped } from "~/modules/search-params/hooks"; +import { databaseTimestampToDate } from "~/utils/dates"; +import invariant from "~/utils/invariant"; +import type { SendouRouteHandle } from "~/utils/remix.server"; +import { action } from "../actions/t.$customUrl.schedule.server"; +import { + addTeamEventSchema, + teamScheduleActionSchema, +} from "../availability-schemas"; +import { scheduleWeekSearchParams } from "../availability-search-params"; +import { ScheduleDayCell } from "../components/ScheduleDayCell"; +import { WeekToggle } from "../components/WeekToggle"; +import type { TeamScheduleLoaderData } from "../loaders/t.$customUrl.schedule.server"; +import { loader } from "../loaders/t.$customUrl.schedule.server"; + +export { action, loader }; + +import type { Route } from "./+types/t.$customUrl.schedule"; +import styles from "./t.$customUrl.schedule.module.css"; + +export const middleware: Route.MiddlewareFunction[] = [timezoneMiddleware]; + +export const handle: SendouRouteHandle = { + i18n: ["schedule"], +}; + +type WeekData = NonNullable[number]; +type MemberWeekRow = WeekData["members"][number]; +type TeamMember = TeamLoaderData["team"]["members"][number]; + +export default function TeamSchedulePage() { + const { t } = useTranslation(["schedule"]); + const data = useLoaderData(); + + return ( +
    + + {data.weeks ? ( + + ) : ( +
    + {t("schedule:team.hidden")} +
    + )} +
    + ); +} + +function ScheduleWeeks({ weeks }: { weeks: Array }) { + const { t } = useTranslation(["schedule"]); + const [{ week }, setParams] = useSearchParamsTyped(scheduleWeekSearchParams); + const { formatter: headingFormatter } = useDateTimeFormat({ + month: "short", + day: "numeric", + }); + + const shownWeek = week === "next" ? weeks[1] : weeks[0]; + + return ( +
    +
    +

    + {t("schedule:team.weekHeading", { week: shownWeek.weekNumber })} ·{" "} + {headingFormatter.formatRange( + shownWeek.days[0].noonAt, + shownWeek.days[6].noonAt, + )} +

    + setParams({ week: value })} + /> +
    + + + + +
    + ); +} + +function ScheduleGrid({ week }: { week: WeekData }) { + const { t } = useTranslation(["team"]); + const members = useTeamMembers(); + const { formatter: dayFormatter } = useDateTimeFormat({ + weekday: "short", + day: "numeric", + }); + + const rows = week.members.flatMap((row) => { + const member = members.find((member) => member.id === row.userId); + + return member ? [{ ...row, member }] : []; + }); + const playerRows = rows.filter( + ({ member }) => getMemberRoleType(member) !== "OTHER", + ); + const otherRows = rows.filter( + ({ member }) => getMemberRoleType(member) === "OTHER", + ); + + const renderRow = (row: MemberWeekRow & { member: TeamMember }) => ( + + + + + {row.days.map((day, dayIndex) => ( + + ))} + + ); + + return ( +
    + + + + + ))} + + + + {playerRows.map(renderRow)} + {otherRows.length > 0 ? ( + + + + ) : null} + {otherRows.map(renderRow)} + +
    + {week.days.map((day, dayIndex) => ( + + {day.windowTier ? ( + + ) : null} + {dayFormatter.format(day.noonAt)} +
    + {t("team:roster.sections.other")} +
    +
    + ); +} + +function ScheduleCell({ + row, + day, + dayIndex, +}: { + row: MemberWeekRow; + day: MemberWeekRow["days"][number]; + dayIndex: number; +}) { + const note = row.notes.find((note) => note.dayIndex === dayIndex); + + return ( + + + + ); +} + +function PlayableWindowsSummary({ week }: { week: WeekData }) { + const { t } = useTranslation(["schedule"]); + + const fullWindows = week.windows.filter((window) => window.tier === "FULL"); + const oneShortWindows = week.windows.filter( + (window) => window.tier === "ONE_SHORT", + ); + + return ( +
    +
    + + + {t("schedule:team.canPlay", { players: week.minPlayers })} + + +
    + {week.minPlayers > 1 && oneShortWindows.length > 0 ? ( +
    + + + {t("schedule:team.withSub", { players: week.minPlayers - 1 })} + + +
    + ) : null} +
    + ); +} + +function WindowList({ windows }: { windows: WeekData["windows"] }) { + const { t } = useTranslation(["schedule"]); + const { formatter: windowFormatter } = useDateTimeFormat({ + weekday: "short", + hour: "numeric", + minute: "2-digit", + }); + + if (windows.length === 0) { + return {t("schedule:team.noWindows")}; + } + + return ( + + {windows.map((window) => ( + + {windowFormatter.formatRange(window.startsAt, window.endsAt)} + + ))} + + ); +} + +function WeekNotes({ week }: { week: WeekData }) { + const members = useTeamMembers(); + const { formatter: dayFormatter } = useDateTimeFormat({ weekday: "short" }); + + const notes = R.sortBy( + week.members.flatMap((row) => + row.notes.map((note) => ({ ...note, userId: row.userId })), + ), + (note) => note.dayIndex, + ); + + if (notes.length === 0) return null; + + return ( +
      + {notes.map((note) => ( +
    • + + + {dayFormatter.format(week.days[note.dayIndex].noonAt)} + + + {members.find((member) => member.id === note.userId)?.username} + + {note.text} +
    • + ))} +
    + ); +} + +function TeamEvents({ week }: { week: WeekData }) { + const { t } = useTranslation(["schedule"]); + const team = useTeam(); + const canEdit = useHasPermission(team, "EDIT"); + const [addDialogOpen, setAddDialogOpen] = React.useState(false); + const { formatter: dayFormatter } = useDateTimeFormat({ + weekday: "short", + day: "numeric", + }); + const { formatter: timeFormatter } = useDateTimeFormat({ + hour: "numeric", + minute: "2-digit", + }); + + if (week.teamEvents.length === 0 && !canEdit) return null; + + return ( +
    +
    +

    {t("schedule:events.title")}

    + {canEdit ? ( + } + onPress={() => setAddDialogOpen(true)} + data-testid="add-team-event-button" + > + {t("schedule:events.add")} + + ) : null} +
    + {week.teamEvents.length === 0 ? ( +
    {t("schedule:events.none")}
    + ) : ( +
      + {week.teamEvents.map((event) => ( +
    • + + {dayFormatter.format(databaseTimestampToDate(event.startsAt))} + + + {isSameDay( + databaseTimestampToDate(event.startsAt), + databaseTimestampToDate(event.endsAt), + ) + ? timeFormatter.formatRange( + databaseTimestampToDate(event.startsAt), + databaseTimestampToDate(event.endsAt), + ) + : `${timeFormatter.format(databaseTimestampToDate(event.startsAt))} – ${timeFormatter.format(databaseTimestampToDate(event.endsAt))}`} + + {event.name} + {canEdit ? ( + } + aria-label={t("schedule:events.delete")} + testId={`delete-team-event-${event.id}`} + confirm={{ + dialogHeading: t("schedule:events.deleteConfirm", { + name: event.name, + }), + }} + /> + ) : null} +
    • + ))} +
    + )} + {addDialogOpen ? ( + setAddDialogOpen(false)} /> + ) : null} +
    + ); +} + +function AddTeamEventDialog({ close }: { close: () => void }) { + const { t } = useTranslation(["schedule"]); + + return ( + + + {({ FormField }) => ( + <> + + + + + {t("schedule:events.membersWillSee")} + + + )} + + + ); +} + +function useTeam() { + const [, parentRoute] = useMatches(); + invariant(parentRoute); + const layoutData = parentRoute.loaderData as TeamLoaderData; + + return layoutData.team; +} + +function useTeamMembers() { + return useTeam().members; +} diff --git a/app/features/calendar/calendar-search-params.test.ts b/app/features/calendar/calendar-search-params.test.ts index 689184b2f..81a6a956e 100644 --- a/app/features/calendar/calendar-search-params.test.ts +++ b/app/features/calendar/calendar-search-params.test.ts @@ -78,7 +78,15 @@ describe("calendarSearchParams", () => { describe("calendarEventsSearchParams", () => { test("round-trips", () => { assertRoundTrips(calendarEventsSearchParams, { - view: [null, "registered", "hosting", "scrims", "saved", "organization"], + view: [ + null, + "registered", + "hosting", + "scrims", + "team", + "saved", + "organization", + ], }); }); diff --git a/app/features/calendar/calendar-search-params.ts b/app/features/calendar/calendar-search-params.ts index a46491c69..9dd7475ae 100644 --- a/app/features/calendar/calendar-search-params.ts +++ b/app/features/calendar/calendar-search-params.ts @@ -14,6 +14,7 @@ export const VIEW_FILTERS = [ "registered", "hosting", "scrims", + "team", "saved", "organization", ] as const; diff --git a/app/features/calendar/loaders/events.server.ts b/app/features/calendar/loaders/events.server.ts index 358bf59ae..48ad1b9af 100644 --- a/app/features/calendar/loaders/events.server.ts +++ b/app/features/calendar/loaders/events.server.ts @@ -1,8 +1,11 @@ import { requireUser } from "~/features/auth/core/user.server"; +import { myScheduleData } from "~/features/availability/core/MySchedule.server"; import * as ShowcaseTournaments from "~/features/front-page/core/ShowcaseTournaments.server"; import * as ScrimPostRepository from "~/features/scrims/ScrimPostRepository.server"; import { + findUpcomingTeamEvents, scrimToSidebarEvent, + teamEventToSidebarEvent, tournamentToSidebarEvent, } from "~/features/sidebar/core/sidebar.server"; import * as SavedCalendarEventRepository from "~/features/tournament/SavedCalendarEventRepository.server"; @@ -13,19 +16,17 @@ export type EventsLoaderData = typeof loader; export const loader = async () => { const user = requireUser(); - const [ - tournamentsData, - scrimsData, - savedTournaments, - upcomingTournaments, - userOrganizations, - ] = await Promise.all([ - ShowcaseTournaments.categorizedTournamentsByUserId(user.id), - ScrimPostRepository.findUserScrims(user.id), - SavedCalendarEventRepository.findAllUpcomingByUserId(user.id), - ShowcaseTournaments.upcomingTournaments(), - TournamentOrganizationRepository.findByUserId(user.id), - ]); + const tournamentsData = + await ShowcaseTournaments.categorizedTournamentsByUserId(user.id); + const scrimsData = await ScrimPostRepository.findUserScrims(user.id); + const savedTournaments = + await SavedCalendarEventRepository.findAllUpcomingByUserId(user.id); + const upcomingTournaments = await ShowcaseTournaments.upcomingTournaments(); + const userOrganizations = await TournamentOrganizationRepository.findByUserId( + user.id, + ); + const mySchedule = await myScheduleData(user.id); + const teamEvents = await findUpcomingTeamEvents(user.id); const registered = tournamentsData.participatingFor .map(tournamentToSidebarEvent) @@ -39,6 +40,8 @@ export const loader = async () => { .map(scrimToSidebarEvent) .sort((a, b) => a.startsAt - b.startsAt); + const team = teamEvents.map(teamEventToSidebarEvent); + const saved = savedTournaments .map(tournamentToSidebarEvent) .sort((a, b) => a.startsAt - b.startsAt); @@ -54,5 +57,5 @@ export const loader = async () => { .map(tournamentToSidebarEvent) .sort((a, b) => a.startsAt - b.startsAt); - return { registered, hosting, scrims, saved, organization }; + return { registered, hosting, scrims, team, saved, organization, mySchedule }; }; diff --git a/app/features/calendar/routes/events.tsx b/app/features/calendar/routes/events.tsx index f7fa833a4..8082f50c6 100644 --- a/app/features/calendar/routes/events.tsx +++ b/app/features/calendar/routes/events.tsx @@ -4,6 +4,10 @@ import { EmptyState } from "~/components/EmptyState"; import { EventsList } from "~/components/EventsList"; import { Main } from "~/components/Main"; import { SubNav, SubNavLink } from "~/components/SubNav"; +import { action } from "~/features/availability/actions/events.server"; +import { scheduleWeekSearchParams } from "~/features/availability/availability-search-params"; +import { MySchedule } from "~/features/availability/components/MySchedule"; +import { timezoneMiddleware } from "~/features/timezone/timezone-middleware.server"; import { useSearchParam } from "~/modules/search-params/hooks"; import { metaTags, ogPageImage } from "~/utils/remix"; import type { SendouRouteHandle } from "~/utils/remix.server"; @@ -14,9 +18,14 @@ import { type ViewFilter, } from "../calendar-search-params"; import type { EventsLoaderData } from "../loaders/events.server"; +import { loader } from "../loaders/events.server"; + +export { action, loader }; + +import type { Route } from "./+types/events"; import styles from "./events.module.css"; -export { loader } from "../loaders/events.server"; +export const middleware: Route.MiddlewareFunction[] = [timezoneMiddleware]; export const meta: MetaFunction = (args) => { return metaTags({ @@ -27,13 +36,14 @@ export const meta: MetaFunction = (args) => { }; export const handle: SendouRouteHandle = { - i18n: ["calendar"], + i18n: ["calendar", "schedule"], }; export default function EventsPage() { const { t } = useTranslation(["calendar"]); const data = useLoaderData(); const [viewParam] = useSearchParam(calendarEventsSearchParams, "view"); + const [week] = useSearchParam(scheduleWeekSearchParams, "week"); const defaultFilter = VIEW_FILTERS.find((key) => data[key].length > 0) ?? "registered"; @@ -43,6 +53,7 @@ export default function EventsPage() { registered: `${t("calendar:events.view.registered")} (${data.registered.length})`, hosting: `${t("calendar:events.view.hosting")} (${data.hosting.length})`, scrims: `${t("calendar:events.view.scrims")} (${data.scrims.length})`, + team: `${t("calendar:events.view.team")} (${data.team.length})`, saved: `${t("calendar:events.view.saved")} (${data.saved.length})`, organization: `${t("calendar:events.view.organization")} (${data.organization.length})`, }; @@ -52,36 +63,51 @@ export default function EventsPage() { const hasNoEventsAtAll = VIEW_FILTERS.every((key) => data[key].length === 0); return ( -
    -
    -

    {t("calendar:events.title")}

    - {hasNoEventsAtAll ? null : ( - - {VIEW_FILTERS.map((value) => ( - - {viewLabels[value]} - - ))} - +
    + {/* keyed on the week so a revalidation across Monday midnight resets + the editor instead of leaving it holding the rolled-over week */} + +
    +
    +

    {t("calendar:events.title")}

    + {hasNoEventsAtAll ? null : ( + + {VIEW_FILTERS.map((value) => ( + + {viewLabels[value]} + + ))} + + )} +
    + {hasNoEventsAtAll ? ( + + {t("calendar:events.emptyAll")}{" "} + + {t("calendar:events.findOnCalendar")} + + + ) : shownEvents.length === 0 ? ( + + {t("calendar:events.empty")} + + ) : ( + )}
    - {hasNoEventsAtAll ? ( - - {t("calendar:events.emptyAll")}{" "} - {t("calendar:events.findOnCalendar")} - - ) : shownEvents.length === 0 ? ( - {t("calendar:events.empty")} - ) : ( - - )}
    ); } diff --git a/app/features/components-showcase/components-showcase.module.css b/app/features/components-showcase/components-showcase.module.css index ada31b8d2..eeb0e8674 100644 --- a/app/features/components-showcase/components-showcase.module.css +++ b/app/features/components-showcase/components-showcase.module.css @@ -35,3 +35,7 @@ .trophyExampleLarge { width: 200px; } + +.scheduleNarrow { + max-width: 360px; +} diff --git a/app/features/components-showcase/routes/components.tsx b/app/features/components-showcase/routes/components.tsx index 14c673891..5b1f3ef86 100644 --- a/app/features/components-showcase/routes/components.tsx +++ b/app/features/components-showcase/routes/components.tsx @@ -52,6 +52,11 @@ import { SubNav, SubNavLink } from "~/components/SubNav"; import { Table } from "~/components/Table"; import { TierPill } from "~/components/TierPill"; import { WeaponSelect } from "~/components/WeaponSelect"; +import type { + AvailabilityEditorWeek, + EditorCommitment, +} from "~/features/availability/availability-types"; +import { WeekAvailabilityEditor } from "~/features/availability/components/WeekAvailabilityEditor"; import { ChangelogGraphic, type ChangelogGraphicEntry, @@ -85,7 +90,7 @@ import { EXAMPLE_TROPHY_MODEL } from "../example-trophy-model"; import { formFieldsShowcaseSchema } from "../form-examples-schema"; export const handle: SendouRouteHandle = { - i18n: ["user", "q", "calendar", "tournament"], + i18n: ["user", "q", "calendar", "tournament", "schedule"], }; export const SECTIONS = [ @@ -153,6 +158,7 @@ export const SECTIONS = [ { title: "Tier Pills", id: "tier-pills", component: TierPillSection }, { title: "Game Selects", id: "game-selects", component: GameSelectSection }, { title: "Form Fields", id: "form-fields", component: FormFieldsSection }, + { title: "Schedule", id: "schedule", component: ScheduleSection }, { title: "Miscellaneous", id: "miscellaneous", component: MiscSection }, ] as const; @@ -3012,6 +3018,85 @@ function FormFieldsSection({ id }: { id: string }) { ); } +const SCHEDULE_EXAMPLE_WEEK: AvailabilityEditorWeek = [ + { date: "2026-08-24", ranges: [{ start: 18 * 60, end: 22 * 60 }], note: "" }, + { date: "2026-08-25", ranges: [], note: "" }, + { + date: "2026-08-26", + ranges: [{ start: 19 * 60, end: 23 * 60 }], + note: "Have to stop earlier, work trip next morning", + }, + { date: "2026-08-27", ranges: [{ start: 18 * 60, end: 22 * 60 }], note: "" }, + { date: "2026-08-28", ranges: [], note: "" }, + { date: "2026-08-29", ranges: [{ start: 12 * 60, end: 26 * 60 }], note: "" }, + { date: "2026-08-30", ranges: [{ start: 18 * 60, end: 22 * 60 }], note: "" }, +]; + +const SCHEDULE_EXAMPLE_COMMITMENTS: Array = [ + { + date: "2026-08-26", + range: { start: 20 * 60, end: 21 * 60 + 30 }, + name: "VoD review vs. FTWin", + }, + { + date: "2026-08-30", + range: { start: 12 * 60, end: 18 * 60 }, + name: "In The Zone 42", + }, +]; + +function ScheduleSection({ id }: { id: string }) { + const [week, setWeek] = useState(SCHEDULE_EXAMPLE_WEEK); + const rangeCount = week.reduce((acc, day) => acc + day.ranges.length, 0); + + return ( +
    + Schedule + +
    +
    +
    Week availability editor
    + +
    + setWeek(SCHEDULE_EXAMPLE_WEEK)} + > + Reset + + + toastQueue.add({ + message: `Saved week with ${rangeCount} time ranges`, + variant: "success", + }) + } + > + Save week + +
    +
    + + +
    + +
    +
    +
    +
    + ); +} + function MiscSection({ id }: { id: string }) { const [rangeValue, setRangeValue] = useState(50); const [colorValue, setColorValue] = useState("#3b82f6"); diff --git a/app/features/friends/loaders/friends.server.ts b/app/features/friends/loaders/friends.server.ts index 0d0ffbe5a..4f7262d07 100644 --- a/app/features/friends/loaders/friends.server.ts +++ b/app/features/friends/loaders/friends.server.ts @@ -1,5 +1,7 @@ import * as R from "remeda"; import { requireUser } from "~/features/auth/core/user.server"; +import * as FriendSchedule from "~/features/availability/core/FriendSchedule.server"; +import { getViewerTimezone } from "~/features/timezone/timezone-context.server"; import { userPage } from "~/utils/urls"; import * as FriendRepository from "../FriendRepository.server"; import { friendActivitySortValue } from "../friends-constants"; @@ -13,20 +15,24 @@ export type FriendsLoaderData = typeof loader; export const loader = async () => { const user = requireUser(); - const [ - friendsWithActivity, - pendingRequests, - incomingRequests, - streamedSendouQMatches, - ] = await Promise.all([ - FriendRepository.findByUserIdWithActivity(user.id), - FriendRepository.findPendingSentRequests(user.id), - FriendRepository.findPendingReceivedRequests(user.id), - resolveSendouQMatchStreams(), - ]); - + const friendsWithActivity = await FriendRepository.findByUserIdWithActivity( + user.id, + ); const unique = R.uniqueBy(friendsWithActivity, (f) => f.id); + const [pendingRequests, incomingRequests, streamedSendouQMatches, schedules] = + await Promise.all([ + FriendRepository.findPendingSentRequests(user.id), + FriendRepository.findPendingReceivedRequests(user.id), + resolveSendouQMatchStreams(), + // everyone listed is a friend or a teammate, which is what makes their + // schedule theirs to see + FriendSchedule.findByUserIds({ + userIds: unique.map((f) => f.id), + timezone: getViewerTimezone() ?? "UTC", + }), + ]); + const friends = R.sortBy( unique .filter((f) => f.friendshipId !== null) @@ -58,9 +64,11 @@ export const loader = async () => { tournamentId: activity.tournamentId ?? friend.tournamentId, streamUrl: activity.streamUrl, friendshipCreatedAt: friend.friendshipCreatedAt, + schedule: schedules.get(friend.id) ?? null, }; }), [(friend) => friendActivitySortValue(friend.activityType), "desc"], + [(friend) => (friend.schedule ? 1 : 0), "desc"], [(friend) => friend.friendshipCreatedAt ?? 0, "desc"], ); @@ -93,9 +101,11 @@ export const loader = async () => { matchId: activity.matchId, tournamentId: activity.tournamentId ?? tm.tournamentId, streamUrl: activity.streamUrl, + schedule: schedules.get(tm.id) ?? null, }; }), [(tm) => friendActivitySortValue(tm.activityType), "desc"], + [(tm) => (tm.schedule ? 1 : 0), "desc"], ); return { diff --git a/app/features/friends/routes/friends.module.css b/app/features/friends/routes/friends.module.css index 734ecb8b4..c2f34dfe4 100644 --- a/app/features/friends/routes/friends.module.css +++ b/app/features/friends/routes/friends.module.css @@ -29,3 +29,19 @@ gap: var(--s-2); margin-block-end: var(--s-2); } + +.friendRow { + display: grid; + grid-template-columns: minmax(0, 1fr) auto; + align-items: end; + gap: var(--s-1-5); +} + +.scheduleSlot { + display: flex; + width: 18px; + margin-block-end: var(--s-1); + & > button { + height: auto; + } +} diff --git a/app/features/friends/routes/friends.tsx b/app/features/friends/routes/friends.tsx index 9b6499205..448ab350c 100644 --- a/app/features/friends/routes/friends.tsx +++ b/app/features/friends/routes/friends.tsx @@ -1,11 +1,14 @@ +import { CalendarDays } from "lucide-react"; import * as React from "react"; import { useTranslation } from "react-i18next"; import { Link, type MetaFunction, useLoaderData } from "react-router"; import { ActionButton } from "~/components/ActionButton"; import { Avatar } from "~/components/Avatar"; import { Divider } from "~/components/Divider"; +import { SendouButton } from "~/components/elements/Button"; import { Main } from "~/components/Main"; import { SubNav, SubNavLink } from "~/components/SubNav"; +import { ScheduleWeekDialog } from "~/features/availability/components/ScheduleWeekDialog"; import { SendouForm } from "~/form/SendouForm"; import { markFriendRequestsSeen } from "~/hooks/useUnseenFriendRequests"; import { useSearchParam } from "~/modules/search-params/hooks"; @@ -39,7 +42,7 @@ export const meta: MetaFunction = (args) => { }; export const handle: SendouRouteHandle = { - i18n: ["friends"], + i18n: ["friends", "schedule"], }; export default function FriendsPage() { @@ -221,7 +224,7 @@ function FriendsListSection() { ) : (
    {shownItems.map((item) => ( - + ))}
    )} @@ -230,6 +233,58 @@ function FriendsListSection() { ); } +function FriendRow({ item }: { item: ShownItem }) { + return ( +
    + +
    + {item.schedule ? ( + + ) : null} +
    +
    + ); +} + +function ScheduleButton({ + userId, + username, + weeks, +}: { + userId: number; + username: string; + weeks: NonNullable; +}) { + const { t } = useTranslation(["schedule"]); + const [dialogOpen, setDialogOpen] = React.useState(false); + + return ( + <> + } + aria-label={t("schedule:friends.availabilityOf", { name: username })} + testId={`friend-schedule-button-${userId}`} + onPress={() => setDialogOpen(true)} + /> + {dialogOpen ? ( + setDialogOpen(false)} + /> + ) : null} + + ); +} + +type ShownItem = ReturnType[number]; + function resolveShownItems( filter: ViewFilter, data: Awaited>, @@ -243,9 +298,10 @@ function resolveShownItems( ...data.teamMembers.filter((tm) => !friendIds.has(tm.id)), ]; - return combined.sort((a, b) => { - const aActive = a.subtitle ? 1 : 0; - const bActive = b.subtitle ? 1 : 0; - return bActive - aActive; - }); + // same order the loader sorted each group in: active first, then the ones + // who shared a schedule + const sortValue = (item: (typeof combined)[number]) => + (item.subtitle ? 2 : 0) + (item.schedule ? 1 : 0); + + return combined.sort((a, b) => sortValue(b) - sortValue(a)); } diff --git a/app/features/layout/core/layout.server.ts b/app/features/layout/core/layout.server.ts index 4bc7b0acc..b4dd08477 100644 --- a/app/features/layout/core/layout.server.ts +++ b/app/features/layout/core/layout.server.ts @@ -12,7 +12,7 @@ import { GIT_COMMIT } from "~/utils/git-commit"; export async function resolveLayoutData(user: AuthenticatedUser | undefined) { return { loggedInUserId: user?.id ?? null, - sidebar: await resolveSidebarData(user?.id ?? null), + sidebar: await resolveSidebarData(user), buildCommit: GIT_COMMIT, }; } diff --git a/app/features/notifications/core/notify.server.ts b/app/features/notifications/core/notify.server.ts index fcf4212db..a2886933c 100644 --- a/app/features/notifications/core/notify.server.ts +++ b/app/features/notifications/core/notify.server.ts @@ -39,6 +39,8 @@ const NOTIFICATION_URGENCY: Record = { SCRIM_AUTO_DELETED: "normal", COMMISSIONS_CLOSED: "normal", FRIEND_REQUEST_RECEIVED: "normal", + TEAM_EVENT_ADDED: "normal", + SCHEDULE_TEAM_REMINDER: "normal", }; /** How long a push notification is held back before sending. Anything marking the notification as seen during this window (the user addressing what it is about, opening the notification list, `defaultSeenUserIds`) cancels the push for that user. */ diff --git a/app/features/notifications/core/resolve.server.ts b/app/features/notifications/core/resolve.server.ts index 4b07b3dcd..1a2ce4987 100644 --- a/app/features/notifications/core/resolve.server.ts +++ b/app/features/notifications/core/resolve.server.ts @@ -50,6 +50,8 @@ const RESOLUTION_TRIGGERS = { COMMISSIONS_CLOSED: null, FRIEND_REQUEST_RECEIVED: "accepts or declines the request, or the sender cancels it", + TEAM_EVENT_ADDED: "visits the team's schedule page", + SCHEDULE_TEAM_REMINDER: "saves any week of their own schedule", } as const satisfies Record; type ResolvableNotificationType = { diff --git a/app/features/notifications/notifications-types.ts b/app/features/notifications/notifications-types.ts index 016ec8c2d..2955fcc8a 100644 --- a/app/features/notifications/notifications-types.ts +++ b/app/features/notifications/notifications-types.ts @@ -106,7 +106,16 @@ export type Notification = tournamentName: string; accepterUsername: string; } - >; + > + | NotificationItem< + "TEAM_EVENT_ADDED", + { + eventName: string; + teamName: string; + teamCustomUrl: string; + } + > + | NotificationItem<"SCHEDULE_TEAM_REMINDER">; type NotificationItem< T extends string, diff --git a/app/features/notifications/notifications-utils.ts b/app/features/notifications/notifications-utils.ts index 8488128be..0d8f659d1 100644 --- a/app/features/notifications/notifications-utils.ts +++ b/app/features/notifications/notifications-utils.ts @@ -5,6 +5,7 @@ import { userSeasonsPage } from "~/features/user-page/user-page-urls"; import { assertUnreachable } from "~/utils/types"; import { badgePage, + EVENTS_PAGE, FRIENDS_PAGE, NEW_TROPHY_PAGE, PLUS_VOTING_PAGE, @@ -13,6 +14,7 @@ import { scrimPage, scrimsPage, sendouQMatchPage, + teamSchedulePage, tournamentRegisterPage, tournamentSubsPage, tournamentTeamPage, @@ -62,6 +64,10 @@ export const notificationNavIcon = (type: Notification["type"]) => { return "scrims"; case "FRIEND_REQUEST_RECEIVED": return "sendou_love"; + case "TEAM_EVENT_ADDED": + return "t"; + case "SCHEDULE_TEAM_REMINDER": + return "calendar"; default: assertUnreachable(type); } @@ -137,6 +143,12 @@ export const notificationLink = ( case "TO_LIKE_ACCEPTED": { return tournamentSubsPage(notification.meta.tournamentId); } + case "TEAM_EVENT_ADDED": { + return teamSchedulePage(notification.meta.teamCustomUrl); + } + case "SCHEDULE_TEAM_REMINDER": { + return EVENTS_PAGE; + } default: assertUnreachable(notification); } diff --git a/app/features/scrims/ScrimPostRepository.server.ts b/app/features/scrims/ScrimPostRepository.server.ts index 18e88b56d..5ac77c36a 100644 --- a/app/features/scrims/ScrimPostRepository.server.ts +++ b/app/features/scrims/ScrimPostRepository.server.ts @@ -1,5 +1,5 @@ import { addHours, sub } from "date-fns"; -import type { Insertable, NotNull } from "kysely"; +import { type Insertable, type NotNull, sql } from "kysely"; import type { Tables, TablesInsertable } from "~/db/tables"; import { actorId, actorIdOrNull } from "~/features/auth/core/user.server"; import * as ChatRepository from "~/features/chat/ChatRepository.server"; @@ -566,6 +566,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/scrims/actions/scrims.new.server.ts b/app/features/scrims/actions/scrims.new.server.ts index 44243c0ce..c49faf939 100644 --- a/app/features/scrims/actions/scrims.new.server.ts +++ b/app/features/scrims/actions/scrims.new.server.ts @@ -9,19 +9,19 @@ import { dateToDatabaseTimestamp } from "~/utils/dates"; import invariant from "~/utils/invariant"; import { errorToast, errorToastIfFalsy } from "~/utils/remix.server"; import { toDBBoolean } from "~/utils/sql"; -import { assertUnreachable } from "~/utils/types"; import { scrimsPage } from "~/utils/urls"; import * as SQGroupRepository from "../../sendouq/SQGroupRepository.server"; import * as TeamRepository from "../../team/TeamRepository.server"; import { getMemberRoleType } from "../../team/team-utils"; import * as ScrimPickupRosterRepository from "../ScrimPickupRosterRepository.server"; import * as ScrimPostRepository from "../ScrimPostRepository.server"; -import { LUTI_DIVS, SCRIM } from "../scrims-constants"; import { - type fromSchema, - type RANGE_END_OPTIONS, - scrimsNewFormSchema, -} from "../scrims-schemas"; + LUTI_DIVS, + RANGE_END_MINUTES, + type RangeEndOption, + SCRIM, +} from "../scrims-constants"; +import { type fromSchema, scrimsNewFormSchema } from "../scrims-schemas"; import type { LutiDiv } from "../scrims-types"; import { serializeLutiDiv } from "../scrims-utils"; @@ -193,25 +193,9 @@ async function validatePickupAllUnbanned(userIds: number[]) { function resolveRangeEndToDate( startDate: Date, - rangeEnd: (typeof RANGE_END_OPTIONS)[number], + rangeEnd: RangeEndOption, ): Date { - switch (rangeEnd) { - case "+30min": - return add(startDate, { minutes: 30 }); - case "+1hour": - return add(startDate, { hours: 1 }); - case "+1.5hours": - return add(startDate, { hours: 1, minutes: 30 }); - case "+2hours": - return add(startDate, { hours: 2 }); - case "+2.5hours": - return add(startDate, { hours: 2, minutes: 30 }); - case "+3hours": - return add(startDate, { hours: 3 }); - default: { - assertUnreachable(rangeEnd); - } - } + return add(startDate, { minutes: RANGE_END_MINUTES[rangeEnd] }); } function resolveDivs( diff --git a/app/features/scrims/components/ScrimAvailability.module.css b/app/features/scrims/components/ScrimAvailability.module.css new file mode 100644 index 000000000..e6e2b52d5 --- /dev/null +++ b/app/features/scrims/components/ScrimAvailability.module.css @@ -0,0 +1,53 @@ +.stripe { + width: 100%; + height: auto; + display: flex; + align-items: center; + gap: var(--s-2); + padding: var(--s-1) var(--s-4); + margin-block-start: auto; + border-block-start: var(--border-style); + border-radius: 0; + font-size: var(--font-2xs); + font-weight: var(--weight-semi); + + &:hover { + background-color: var(--color-bg-high); + } +} + +.stripeTeam { + color: var(--color-text-high); + text-transform: uppercase; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.stripeCount { + margin-inline-start: auto; + color: var(--color-text); + white-space: nowrap; +} + +.popover { + display: flex; + flex-direction: column; + gap: var(--s-3); + max-width: 20rem; +} + +.rowsSection { + display: flex; + flex-direction: column; + gap: var(--s-3); +} + +.rows { + display: flex; + flex-direction: column; + gap: var(--s-2-5); + list-style: none; + padding: 0; + margin: 0; +} diff --git a/app/features/scrims/components/ScrimAvailability.tsx b/app/features/scrims/components/ScrimAvailability.tsx new file mode 100644 index 000000000..eb084e326 --- /dev/null +++ b/app/features/scrims/components/ScrimAvailability.tsx @@ -0,0 +1,145 @@ +import { useTranslation } from "react-i18next"; +import { useLoaderData } from "react-router"; +import { SendouButton } from "~/components/elements/Button"; +import { SendouPopover } from "~/components/elements/Popover"; +import { + AvailabilityMemberRow, + type AvailabilityPanelUser, + AvailabilityStatusDots, + AvailabilitySummary, + AvailabilityWindowText, + availabilityRowStatus, +} from "~/features/availability/components/RegistrationAvailabilityPanel"; +import * as Scrim from "../core/Scrim"; +import type { loader as scrimsLoader } from "../loaders/scrims.server"; +import type { ScrimPost } from "../scrims-types"; +import { requestStarts } from "../scrims-utils"; +import styles from "./ScrimAvailability.module.css"; + +export interface ScrimRosterFit { + team: { id: number; name: string }; + roster: Array; + fit: Scrim.RosterFit; +} + +/** + * How one of the viewer's teams fits a post they could request, resolved from + * the schedules the browsing page loaded. `teamId` picks the team (their main + * one by default) and `at` narrows the fit to one start inside the post's + * flexibility instead of the best one on offer. + * + * Null whenever there is nothing to show: no team, a post past the reportable + * horizon, or a week nobody filled in. + */ +export function useRosterFit({ + post, + teamId, + at, +}: { + post: ScrimPost; + teamId?: number; + at?: number | null; +}): ScrimRosterFit | null { + const data = useLoaderData(); + + const team = + teamId !== undefined + ? data.teams.find((team) => team.id === teamId) + : (data.teams.find((team) => team.isMainTeam) ?? data.teams[0]); + const schedules = data.availability.windows.find( + (window) => window.id === post.id, + ); + if (!team || !schedules) return null; + + const roster = Scrim.teamPlayers(team.members); + const fit = Scrim.rosterFit({ + starts: at ? [at] : requestStarts({ post, now: data.availability.now }), + members: roster.flatMap((member) => { + const schedule = schedules.members.find( + (schedule) => schedule.userId === member.id, + ); + + return schedule ? [schedule] : []; + }), + }); + if (!fit) return null; + + return { team, roster, fit }; +} + +/** + * The post card's fit indicator: a stripe above the card's actions saying how + * much of the viewer's roster could play it, the who and when a click away. + * + * Left out when none of them could — a row of zeroes down the page is noise, + * and the request button says all there is to say then. + */ +export function ScrimFitStripe({ post }: { post: ScrimPost }) { + const { t } = useTranslation(["schedule"]); + const fit = useRosterFit({ post }); + + if (!fit || fit.fit.availableCount === 0) return null; + + return ( + + {fit.team.name} + + + {t("schedule:scrims.availableOfRoster", { + amount: fit.fit.availableCount, + total: fit.roster.length, + })} + + + } + > +
    + + +
    + + ); +} + +/** The roster's members and how each of them relates to the scrim being requested. */ +export function ScrimAvailabilityRows({ fit }: { fit: ScrimRosterFit }) { + const entryByUserId = new Map( + fit.fit.entries.map((entry) => [entry.userId, entry]), + ); + + return ( +
    +
      + {fit.roster.map((member) => ( + + ))} +
    + + availabilityRowStatus(entryByUserId.get(member.id)), + )} + /> +
    + ); +} + +/** How each of the roster relates to the scrim, in roster order. */ +function rosterStatuses(fit: ScrimRosterFit) { + const entryByUserId = new Map( + fit.fit.entries.map((entry) => [entry.userId, entry]), + ); + + return fit.roster.map((member) => + availabilityRowStatus(entryByUserId.get(member.id)), + ); +} diff --git a/app/features/scrims/components/ScrimCard.tsx b/app/features/scrims/components/ScrimCard.tsx index c5101b2ee..bc2687617 100644 --- a/app/features/scrims/components/ScrimCard.tsx +++ b/app/features/scrims/components/ScrimCard.tsx @@ -37,6 +37,7 @@ import { scrimsActionSchema } from "../scrims-schemas"; import { scrimsSearchParams } from "../scrims-search-params"; import type { ScrimPost, ScrimPostRequest } from "../scrims-types"; import { formatFlexTimeDisplay } from "../scrims-utils"; +import { ScrimFitStripe } from "./ScrimAvailability"; import styles from "./ScrimCard.module.css"; import { ScrimRequestModal } from "./ScrimRequestModal"; @@ -142,6 +143,10 @@ export function ScrimPostCard({ {post.text ? : null} + {action === "REQUEST" || action === "VIEW_REQUEST" ? ( + + ) : null} +
    diff --git a/app/features/scrims/components/ScrimRequestModal.tsx b/app/features/scrims/components/ScrimRequestModal.tsx index fac497a47..3c7be3d7a 100644 --- a/app/features/scrims/components/ScrimRequestModal.tsx +++ b/app/features/scrims/components/ScrimRequestModal.tsx @@ -3,16 +3,21 @@ import { useLoaderData } from "react-router"; import { Divider } from "~/components/Divider"; import { SendouDialog } from "~/components/elements/Dialog"; import { FormMessage } from "~/components/FormMessage"; +import { AvailabilityWindowText } from "~/features/availability/components/RegistrationAvailabilityPanel"; import type { CustomFieldRenderProps } from "~/form"; -import { SendouForm } from "~/form/SendouForm"; +import { SendouForm, useFormValue } from "~/form/SendouForm"; import { useDateTimeFormat } from "~/hooks/intl/useDateTimeFormat"; import { nullFilledArray } from "~/utils/arrays"; -import { databaseTimestampToDate } from "~/utils/dates"; +import { + databaseTimestampToDate, + dateToDatabaseTimestamp, +} from "~/utils/dates"; import type { loader as scrimsLoader } from "../loaders/scrims.server"; import { SCRIM } from "../scrims-constants"; import { scrimRequestFormSchema } from "../scrims-schemas"; import type { ScrimPost } from "../scrims-types"; import { generateTimeOptions } from "../scrims-utils"; +import { ScrimAvailabilityRows, useRosterFit } from "./ScrimAvailability"; import { WithFormField } from "./WithFormField"; export function ScrimRequestModal({ @@ -29,15 +34,25 @@ export function ScrimRequestModal({ minute: "numeric", }); - const timeOptions = post.rangeEndsAt + // only the starts still on offer: the server clips the roster schedules to + // them, and defaulting to a time already past would show the whole roster + // as unavailable. Once every start has passed the full list stays on offer. + const allTimeOptions = post.rangeEndsAt ? generateTimeOptions( databaseTimestampToDate(post.startsAt), databaseTimestampToDate(post.rangeEndsAt), - ).map((timestamp) => ({ - value: String(timestamp), - label: timeFormatter.format(new Date(timestamp)) ?? "", - })) + ) : []; + const upcomingTimeOptions = allTimeOptions.filter( + (timestamp) => + dateToDatabaseTimestamp(new Date(timestamp)) >= data.availability.now, + ); + const timeOptions = ( + upcomingTimeOptions.length > 0 ? upcomingTimeOptions : allTimeOptions + ).map((timestamp) => ({ + value: String(timestamp), + label: timeFormatter.format(new Date(timestamp)) ?? "", + })); return ( @@ -77,6 +92,7 @@ export function ScrimRequestModal({ {post.rangeEndsAt ? ( ) : null} + {t("scrims:autoCancelInfo")} @@ -85,3 +101,32 @@ export function ScrimRequestModal({ ); } + +/** How the roster the request is made with fits the exact slot being asked for. */ +function ScrimRequestAvailability({ post }: { post: ScrimPost }) { + const { t } = useTranslation(["schedule"]); + const from = useFormValue("from") as + | { mode: "TEAM"; teamId: number } + | { mode: "PICKUP" } + | null; + const at = useFormValue("at") as string | null; + + const teamId = from?.mode === "TEAM" ? from.teamId : undefined; + const fit = useRosterFit({ + post, + teamId, + at: at ? dateToDatabaseTimestamp(new Date(Number(at))) : null, + }); + + if (teamId === undefined || !fit) return null; + + return ( +
    +
    + {t("schedule:registration.title")} +
    + + +
    + ); +} diff --git a/app/features/scrims/components/ScrimSchedulePicker.module.css b/app/features/scrims/components/ScrimSchedulePicker.module.css new file mode 100644 index 000000000..fdfb884ad --- /dev/null +++ b/app/features/scrims/components/ScrimSchedulePicker.module.css @@ -0,0 +1,128 @@ +/* The tracks need more room than the form column gives them, so the picker + sizes against the whole page container and centers back under the column. */ +.picker { + display: flex; + flex-direction: column; + gap: var(--s-3); + width: min(100cqw, 48rem); + margin-inline: calc(50% - min(50cqw, 24rem)); +} + +.header { + display: flex; + flex-wrap: wrap; + align-items: center; + justify-content: space-between; + gap: var(--s-2); +} + +.heading { + font-size: var(--font-sm); + font-weight: var(--weight-bold); +} + +.slotBar { + container: bar / inline-size; + position: absolute; + top: 3px; + bottom: 3px; + display: flex; + align-items: center; + justify-content: center; + overflow: hidden; + padding: 0; + background-color: var(--color-success); + border: 1px solid var(--color-success); + border-radius: var(--radius-field); + cursor: pointer; + z-index: 1; + + &:focus-visible { + outline: var(--focus-ring); + } + + &.oneShort { + background-color: var(--color-success-low); + } + + &.picked { + box-shadow: + 0 0 0 2px var(--color-bg), + 0 0 0 4px var(--color-text); + } +} + +/* inside a slot the team is only partly complete for, the part it is: where picking it starts */ +.slotFull { + position: absolute; + top: 0; + bottom: 0; + background-color: var(--color-success); + pointer-events: none; +} + +/* the pill keeps the times legible whichever tier's green is under them */ +.slotLabel { + display: none; + max-width: 100%; + padding-inline: var(--s-1); + background-color: var(--color-bg); + border-radius: var(--radius-full); + font-size: var(--font-3xs); + color: var(--color-text); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + pointer-events: none; + z-index: 1; +} + +@container bar (min-width: 9rem) { + .slotLabel { + display: block; + } +} + +.legend { + display: flex; + flex-wrap: wrap; + gap: var(--s-3); + font-size: var(--font-3xs); + color: var(--color-text-high); +} + +.legendItem { + display: flex; + align-items: center; + gap: var(--s-1); +} + +.slotSwatch { + width: 14px; + height: 10px; + background-color: var(--color-success); + border: 1px solid var(--color-success); + border-radius: var(--radius-field); + + &.oneShort { + background-color: var(--color-success-low); + } +} + +.unknown { + font-size: var(--font-2xs); + color: var(--color-text-high); +} + +.slotChip { + &.oneShort { + border-style: dashed; + opacity: 0.85; + } + + &.picked { + box-shadow: + 0 0 0 2px var(--color-bg), + 0 0 0 4px var(--color-text); + } +} diff --git a/app/features/scrims/components/ScrimSchedulePicker.tsx b/app/features/scrims/components/ScrimSchedulePicker.tsx new file mode 100644 index 000000000..4aa116b37 --- /dev/null +++ b/app/features/scrims/components/ScrimSchedulePicker.tsx @@ -0,0 +1,388 @@ +import clsx from "clsx"; +import * as React from "react"; +import { useTranslation } from "react-i18next"; +import * as R from "remeda"; +import { useUser } from "~/features/auth/core/user"; +import type { + DayTimeRange, + TimeRange, +} from "~/features/availability/availability-types"; +import { + ClockAxis, + type ClockWindow, + TrackTicks, + useClockWindow, +} from "~/features/availability/components/ScheduleTracks"; +import trackStyles from "~/features/availability/components/ScheduleTracks.module.css"; +import { WeekToggle } from "~/features/availability/components/WeekToggle"; +import * as Availability from "~/features/availability/core/Availability"; +import type { RosterScheduleData } from "~/features/availability/core/RosterSchedule.server"; +import { useDateTimeFormat } from "~/hooks/intl/useDateTimeFormat"; +import { + databaseTimestampToDate, + dateToDatabaseTimestamp, +} from "~/utils/dates"; +import * as Scrim from "../core/Scrim"; +import type { ScrimsNewLoaderData } from "../loaders/scrims.new.server"; +import { SCRIM } from "../scrims-constants"; +import styles from "./ScrimSchedulePicker.module.css"; + +const MINUTE_IN_SECONDS = 60; + +type Week = RosterScheduleData["weeks"][number]; +type Day = Week["days"][number]; +/** The "With" field as the form holds it while it is being filled in. */ +type FromValue = + | { mode: "TEAM"; teamId: number } + | { mode: "PICKUP"; users: Array }; + +interface DaySlot extends Scrim.PickableSlot { + /** The slot on its day's track, in minutes from that day's midnight. */ + range: DayTimeRange; + /** The part of it the whole team is free for, on the same track. */ + fullRange: DayTimeRange | null; +} + +/** + * The roster's merged free time as a week of day tracks, one click on which + * fills in the post's start and start-time flexibility. Which roster is merged + * follows the "With" field, so this only appears once a team or a full pick-up + * has been picked. + * + * Only ever a prefill: the start inputs stay authoritative, and a start the + * schedules do not cover is warned about, never blocked. + */ +export function ScrimSchedulePicker({ + schedule, + scheduleUsers, + teams, + from, + at, + onPick, +}: { + schedule: RosterScheduleData; + scheduleUsers: ScrimsNewLoaderData["scheduleUsers"]; + teams: ScrimsNewLoaderData["teams"]; + from: FromValue; + at: Date | undefined; + onPick: (pick: { at: Date; rangeEnd: string | null }) => void; +}) { + const user = useUser(); + + const roster = rosterUserIds({ from, teams, viewerId: user?.id }); + if (roster.length < SCRIM.MIN_MEMBERS_PER_TEAM) return null; + + return ( + team.members), + ...scheduleUsers, + ...(user ? [user] : []), + ]} + roster={roster} + at={at} + onPick={onPick} + /> + ); +} + +function RosterTimeline({ + schedule, + names, + roster, + at, + onPick, +}: { + schedule: RosterScheduleData; + /** Everyone whose name the timeline may need, the viewer included. */ + names: Array<{ id: number; username: string }>; + roster: Array; + at: Date | undefined; + onPick: (pick: { at: Date; rangeEnd: string | null }) => void; +}) { + const { t } = useTranslation(["schedule"]); + const [weekIndex, setWeekIndex] = React.useState(0); + const { formatter: dayFormatter } = useDateTimeFormat({ + weekday: "short", + day: "numeric", + }); + const { formatter: timeFormatter } = useDateTimeFormat({ + hour: "numeric", + minute: "2-digit", + }); + + const week = schedule.weeks[weekIndex]; + const memberById = new Map( + schedule.members.map((member) => [member.userId, member]), + ); + const minPlayers = Math.min(SCRIM.MIN_MEMBERS_PER_TEAM, roster.length); + + // only what can still be posted for: a start earlier today, let alone + // earlier this week, is not a start the form would accept + const pickableWeek = { + startsAt: Math.max(week.startsAt, schedule.now), + endsAt: week.endsAt, + }; + const slots = Scrim.pickableSlots({ + members: roster.map((userId) => ({ + userId, + ranges: Availability.clip( + memberById.get(userId)?.ranges ?? [], + pickableWeek, + ), + })), + minPlayers, + }); + + const dayRows = week.days.map((day) => ({ + day, + slots: slotsOfDay({ slots, day }), + })); + const clockWindow = useClockWindow({ + fitTo: dayRows.flatMap((row) => row.slots.map((slot) => slot.range)), + }); + + const nameById = new Map(names.map((member) => [member.id, member.username])); + const namesOf = (userIds: Array) => + userIds.flatMap((userId) => { + const username = nameById.get(userId); + + return username ? [username] : []; + }); + const unknownUserIds = roster.filter( + (userId) => + !memberById.get(userId)?.reportedWeekStarts.includes(week.startsAt), + ); + const unknownNamed = namesOf(unknownUserIds); + const unknownUnnamed = unknownUserIds.length - unknownNamed.length; + + const pickedAt = at ? dateToDatabaseTimestamp(at) : null; + + const pick = (slot: Scrim.PickableSlot) => + onPick({ + at: databaseTimestampToDate(slot.pick.startsAt), + rangeEnd: slot.pick.rangeEnd, + }); + + const rangeText = (range: TimeRange) => + `${timeFormatter.format(range.startsAt)} – ${timeFormatter.format(range.endsAt)}`; + + const dayRow = ({ day, slots: daySlots }: (typeof dayRows)[number]) => { + return ( + +
    + {dayFormatter.format(day.noonAt)} +
    +
    + + {daySlots.map((slot) => ( + pick(slot)} + /> + ))} +
    + {/* keeps the day rows in step with the axis row's "later" expander */} +
    + + ); + }; + + return ( +
    +
    +

    {t("schedule:picker.title")}

    + setWeekIndex(value === "next" ? 1 : 0)} + /> +
    +
    +
    + + {dayRows.map(dayRow)} +
    +
    + {dayRows.map(({ day, slots: daySlots }) => { + return ( +
    +
    + {dayFormatter.format(day.noonAt)} +
    + {daySlots.length === 0 ? ( + + ) : ( +
    + {daySlots.map((slot) => ( + + ))} +
    + )} +
    + ); + })} +
    +
    + + {unknownUserIds.length > 0 ? ( +
    + {t("schedule:picker.noSchedule", { + users: [ + ...unknownNamed, + ...(unknownUnnamed > 0 + ? [t("schedule:picker.andOthers", { amount: unknownUnnamed })] + : []), + ].join(", "), + })} +
    + ) : null} +
    + ); +} + +function SlotBar({ + clockWindow, + slot, + label, + members, + isPicked, + onPick, +}: { + clockWindow: ClockWindow; + slot: DaySlot; + label: string; + /** Who is free for the whole slot, named on hover. */ + members: string; + isPicked: boolean; + onPick: () => void; +}) { + const barStart = clockWindow.pct(slot.range.start); + const barEnd = clockWindow.pct(slot.range.end); + if (barEnd <= barStart) return null; + + const withinBar = (minutes: number) => + ((clockWindow.pct(minutes) - barStart) / (barEnd - barStart)) * 100; + return ( + + ); +} + +function Legend({ minPlayers }: { minPlayers: number }) { + const { t } = useTranslation(["schedule"]); + + return ( +
    + + + {t("schedule:picker.legend.full", { players: minPlayers })} + + {minPlayers > 1 ? ( + + + {t("schedule:picker.legend.oneShort", { players: minPlayers - 1 })} + + ) : null} +
    + ); +} + +function rosterUserIds({ + from, + teams, + viewerId, +}: { + from: FromValue; + teams: ScrimsNewLoaderData["teams"]; + viewerId?: number; +}): Array { + if (!viewerId) return []; + + if (from.mode === "PICKUP") { + return R.unique([ + viewerId, + ...from.users.filter((userId) => typeof userId === "number"), + ]); + } + + const team = teams.find((team) => team.id === from.teamId); + if (!team) return []; + + return R.unique([ + viewerId, + ...Scrim.teamPlayers(team.members).map((member) => member.id), + ]); +} + +function slotsOfDay({ + slots, + day, +}: { + slots: Array; + day: Day; +}): Array { + return slots + .filter((slot) => withinDay(slot.startsAt, day)) + .map((slot) => ({ + ...slot, + range: dayRange(slot, day), + fullRange: slot.fullSpan ? dayRange(slot.fullSpan, day) : null, + })); +} + +const withinDay = (timestamp: number, day: Day) => + timestamp >= day.startsAt && timestamp < day.endsAt; + +const dayRange = (range: TimeRange, day: Day) => ({ + start: (range.startsAt - day.startsAt) / MINUTE_IN_SECONDS, + end: (range.endsAt - day.startsAt) / MINUTE_IN_SECONDS, +}); diff --git a/app/features/scrims/core/Scrim.test.ts b/app/features/scrims/core/Scrim.test.ts index 116f36717..b237f0851 100644 --- a/app/features/scrims/core/Scrim.test.ts +++ b/app/features/scrims/core/Scrim.test.ts @@ -6,10 +6,15 @@ import { applyFilters, isTrackingLocked, participantIdsListFromAccepted, + pickableSlots, + rosterFit, sideDisplayName, sideOfUser, + teamPlayers, } from "./Scrim"; +const HOUR = 60 * 60; + type MockUser = { id: number }; type MockRequest = { isAccepted: boolean; users: MockUser[] }; @@ -607,3 +612,218 @@ describe("isTrackingLocked", () => { ).toBe(false); }); }); + +const freeFrom = (userId: number, startsAt: number, endsAt: number) => ({ + userId, + ranges: [{ startsAt, endsAt }], +}); + +describe("pickableSlots", () => { + const evening = (hours: number) => hours * HOUR; + + test("starts a slot the whole team is free for at its own start", () => { + const members = [1, 2, 3, 4].map((userId) => + freeFrom(userId, evening(18), evening(23)), + ); + + expect(pickableSlots({ members, minPlayers: 4 })).toEqual([ + { + startsAt: evening(18), + endsAt: evening(23), + userIds: [1, 2, 3, 4], + tier: "FULL", + fullSpan: null, + pick: { startsAt: evening(18), rangeEnd: "+3hours" }, + }, + ]); + }); + + test("starts a mixed slot where the whole team becomes free", () => { + const members = [ + freeFrom(1, evening(18), evening(23)), + freeFrom(2, evening(18), evening(23)), + freeFrom(3, evening(18), evening(23)), + freeFrom(4, evening(20), evening(23)), + ]; + + expect(pickableSlots({ members, minPlayers: 4 })).toEqual([ + { + startsAt: evening(18), + endsAt: evening(23), + userIds: [1, 2, 3], + tier: "ONE_SHORT", + fullSpan: { + startsAt: evening(20), + endsAt: evening(23), + tier: "FULL", + userIds: [1, 2, 3, 4], + }, + pick: { startsAt: evening(20), rangeEnd: "+2hours" }, + }, + ]); + }); + + test("leaves an hour of the slot to play, capped at the longest flexibility", () => { + const twoHours = [1, 2, 3, 4].map((userId) => + freeFrom(userId, evening(18), evening(20)), + ); + + expect(pickableSlots({ members: twoHours, minPlayers: 4 })[0].pick).toEqual( + { startsAt: evening(18), rangeEnd: "+1hour" }, + ); + }); + + test("gives an hour long slot no flexibility at all", () => { + const oneHour = [1, 2, 3, 4].map((userId) => + freeFrom(userId, evening(18), evening(19)), + ); + + expect(pickableSlots({ members: oneHour, minPlayers: 4 })[0].pick).toEqual({ + startsAt: evening(18), + rangeEnd: null, + }); + }); + + test("shows the longest whole-team span when the slot contains several", () => { + const members = [ + freeFrom(1, evening(18), evening(23)), + freeFrom(2, evening(18), evening(23)), + freeFrom(3, evening(18), evening(23)), + { + userId: 4, + ranges: [ + { startsAt: evening(18), endsAt: evening(19) }, + { startsAt: evening(20), endsAt: evening(23) }, + ], + }, + ]; + + const [slot] = pickableSlots({ members, minPlayers: 4 }); + + expect(slot.fullSpan).toEqual({ + startsAt: evening(20), + endsAt: evening(23), + tier: "FULL", + userIds: [1, 2, 3, 4], + }); + expect(slot.pick.startsAt).toBe(evening(20)); + }); + + test("has no slots when the team is more than one player short", () => { + const members = [ + freeFrom(1, evening(18), evening(21)), + freeFrom(2, evening(18), evening(21)), + freeFrom(3, evening(21), evening(23)), + freeFrom(4, evening(21), evening(23)), + ]; + + expect(pickableSlots({ members, minPlayers: 4 })).toEqual([]); + }); +}); + +describe("teamPlayers", () => { + const player = { id: 1, role: "FRONTLINE" as const, roleType: null }; + const coach = { id: 2, role: "COACH" as const, roleType: null }; + + test("leaves the non-players out", () => { + const members = [ + player, + { ...coach, id: 3 }, + ...[4, 5, 6].map((id) => ({ ...player, id })), + ]; + + expect(teamPlayers(members).map((member) => member.id)).toEqual([ + 1, 4, 5, 6, + ]); + }); + + test("keeps everyone when the players alone could not field a team", () => { + const members = [player, { ...player, id: 2 }, { ...player, id: 3 }, coach]; + + expect(teamPlayers(members)).toHaveLength(4); + }); +}); + +describe("rosterFit", () => { + const evening = (hours: number) => hours * HOUR; + const free = (userId: number, startsAt: number, endsAt: number) => ({ + userId, + reported: true, + ranges: [{ startsAt, endsAt }], + busy: [], + }); + + test("measures the fit at the start the most of the roster is free for", () => { + const members = [ + free(1, evening(18), evening(23)), + free(2, evening(18), evening(23)), + free(3, evening(18), evening(23)), + free(4, evening(20), evening(23)), + ]; + + const fit = rosterFit({ + starts: [evening(18), evening(19), evening(20)], + members, + }); + + expect(fit?.startsAt).toBe(evening(20)); + expect(fit?.availableCount).toBe(4); + expect(fit?.window).toEqual({ + startsAt: evening(20), + endsAt: evening(21.5), + }); + }); + + test("gives the earliest of equally good starts", () => { + const members = [1, 2, 3, 4].map((userId) => + free(userId, evening(18), evening(23)), + ); + + expect( + rosterFit({ starts: [evening(18), evening(19)], members })?.startsAt, + ).toBe(evening(18)); + }); + + test("leaves a member free for only part of the scrim out of the count", () => { + const members = [ + free(1, evening(18), evening(23)), + free(2, evening(18), evening(18.5)), + ]; + + const fit = rosterFit({ starts: [evening(18)], members }); + + expect(fit?.availableCount).toBe(1); + expect(fit?.entries[1].availability.status).toBe("partial"); + }); + + test("reports a member committed elsewhere as busy", () => { + const members = [ + { + ...free(1, evening(18), evening(23)), + busy: [ + { + startsAt: evening(19), + endsAt: evening(21), + type: "tournament" as const, + name: "ITZ", + }, + ], + }, + ]; + + expect( + rosterFit({ starts: [evening(19)], members })?.entries[0].availability + .status, + ).toBe("busy"); + }); + + test("returns null when nobody filled in the week", () => { + const members = [1, 2].map((userId) => ({ + ...free(userId, evening(18), evening(23)), + reported: false, + ranges: [], + })); + + expect(rosterFit({ starts: [evening(18)], members })).toBeNull(); + }); +}); diff --git a/app/features/scrims/core/Scrim.ts b/app/features/scrims/core/Scrim.ts index 98b572575..6a61ec4b6 100644 --- a/app/features/scrims/core/Scrim.ts +++ b/app/features/scrims/core/Scrim.ts @@ -1,9 +1,29 @@ import { format, isWeekend } from "date-fns"; import * as R from "remeda"; import type { Tables } from "~/db/tables"; +import { AVAILABILITY } from "~/features/availability/availability-constants"; +import type { + MemberAvailability, + PlayableWindowTier, + TimeRange, + WindowAvailabilityEntry, + WindowSchedule, +} from "~/features/availability/availability-types"; +import * as Availability from "~/features/availability/core/Availability"; +import type { + MemberRole, + MemberRoleType, +} from "~/features/team/team-constants"; +import { getMemberRoleType } from "~/features/team/team-utils"; import { databaseTimestampToDate } from "~/utils/dates"; import { logger } from "~/utils/logger"; -import { LUTI_DIVS, SCRIM_TRACKING_AUTO_LOCK_HOURS } from "../scrims-constants"; +import { + LUTI_DIVS, + RANGE_END_MINUTES, + type RangeEndOption, + SCRIM, + SCRIM_TRACKING_AUTO_LOCK_HOURS, +} from "../scrims-constants"; import type { ScrimFilters, ScrimPost, ScrimSide } from "../scrims-types"; /** Returns true if the original poster has accepted any of the requests. */ @@ -214,6 +234,138 @@ export function lastReportedMap< ); } +export interface PickableSlot extends TimeRange { + tier: PlayableWindowTier; + /** Members free for the whole slot. */ + userIds: Array; + /** The part of the slot the whole team is free for, when that is only part of it. */ + fullSpan: TimeRange | null; + /** What picking the slot fills the post's start and start-time flexibility with. */ + pick: { startsAt: number; rangeEnd: RangeEndOption | null }; +} + +/** + * The roster's shared free time as the slots a scrim post can be picked from: + * maximal spans where the team is at most one player short, the `ONE_SHORT` + * ones being the "grab a sub" case. + */ +export function pickableSlots({ + members, + minPlayers, +}: { + members: Array; + minPlayers: number; +}): Array { + const spansFreeFor = (playerCount: number) => + Availability.playableWindows({ + members, + minPlayers: playerCount, + }).filter((window) => window.tier === "FULL"); + + const fullSpans = spansFreeFor(minPlayers); + + return spansFreeFor(Math.max(1, minPlayers - 1)).map((slot) => { + // the longest one: a slot can contain several whole-team spans + const fullSpan = R.firstBy( + fullSpans.filter( + (span) => span.startsAt >= slot.startsAt && span.endsAt <= slot.endsAt, + ), + [(span) => span.endsAt - span.startsAt, "desc"], + ); + const wholeSlotIsFull = + fullSpan?.startsAt === slot.startsAt && fullSpan?.endsAt === slot.endsAt; + + return { + startsAt: slot.startsAt, + endsAt: slot.endsAt, + userIds: slot.userIds, + tier: wholeSlotIsFull ? "FULL" : "ONE_SHORT", + fullSpan: wholeSlotIsFull ? null : (fullSpan ?? null), + pick: startPick({ slot, at: fullSpan?.startsAt ?? slot.startsAt }), + }; + }); +} + +/** + * The members a scrim is played with: the team's players, or its whole roster + * when there are not enough players on it to field a team. + */ +export function teamPlayers< + T extends { role: MemberRole | null; roleType: MemberRoleType | null }, +>(members: Array): Array { + const players = members.filter( + (member) => getMemberRoleType(member) !== "OTHER", + ); + + return players.length >= SCRIM.MIN_MEMBERS_PER_TEAM ? players : members; +} + +export interface RosterFit { + /** The start the fit is measured at, the best one of those offered. */ + startsAt: number; + /** The scrim played from that start, its length assumed. */ + window: TimeRange; + entries: Array; + /** How many of the roster are free for the whole window. */ + availableCount: number; +} + +/** + * How well a roster fits a scrim post: the start among `starts` the most of + * them are free for, and how each member relates to a scrim played from it. + * Ties go to the earliest start. + * + * Null when nobody on the roster filled in the week the post falls in — a fit + * nothing is known about is not worth showing. + */ +export function rosterFit({ + starts, + members, +}: { + starts: Array; + members: Array; +}): RosterFit | null { + if (members.length === 0 || members.every((member) => !member.reported)) { + return null; + } + + const fits = starts.map((startsAt) => fitAt({ startsAt, members })); + + return R.firstBy(fits, [(fit) => fit.availableCount, "desc"]) ?? null; +} + +function fitAt({ + startsAt, + members, +}: { + startsAt: number; + members: Array; +}): RosterFit { + const window = { + startsAt, + endsAt: startsAt + AVAILABILITY.SCRIM_COMMITMENT_SECONDS, + }; + + const entries = members.map((member) => ({ + userId: member.userId, + availability: Availability.availabilityInWindow({ + reported: member.reported, + slots: member.ranges, + busy: member.busy, + window, + }), + })); + + return { + startsAt, + window, + entries, + availableCount: entries.filter( + (entry) => entry.availability.status === "available", + ).length, + }; +} + /** Splits a "HH:mm" time range into segments, breaking a range that crosses midnight (e.g. 23:00 -> 01:00) into two. */ function timeRangeToSegments(start: string, end: string) { return end < start @@ -223,3 +375,26 @@ function timeRangeToSegments(start: string, end: string) { ] : [{ start, end }]; } + +function startPick({ slot, at }: { slot: TimeRange; at: number }) { + const lastStartsAt = slot.endsAt - AVAILABILITY.MIN_WINDOW_MINUTES * 60; + const startsAt = R.clamp(at, { + min: slot.startsAt, + max: Math.max(slot.startsAt, lastStartsAt), + }); + const flexMinutes = + Math.min(lastStartsAt - startsAt, SCRIM.MAX_TIME_RANGE_MS / 1000) / 60; + + return { startsAt, rangeEnd: longestRangeEndWithin(flexMinutes) }; +} + +function longestRangeEndWithin(minutes: number): RangeEndOption | null { + const fitting = R.entries(RANGE_END_MINUTES).filter( + ([, optionMinutes]) => optionMinutes <= minutes, + ); + + return ( + R.firstBy(fitting, [([, optionMinutes]) => optionMinutes, "desc"])?.[0] ?? + null + ); +} diff --git a/app/features/scrims/loaders/scrims.new.server.ts b/app/features/scrims/loaders/scrims.new.server.ts index b45ab1fa8..234ed720b 100644 --- a/app/features/scrims/loaders/scrims.new.server.ts +++ b/app/features/scrims/loaders/scrims.new.server.ts @@ -1,5 +1,9 @@ +import * as R from "remeda"; import * as AssociationRepository from "~/features/associations/AssociationRepository.server"; import { requireUser } from "~/features/auth/core/user.server"; +import * as RosterSchedule from "~/features/availability/core/RosterSchedule.server"; +import * as SQGroupRepository from "~/features/sendouq/SQGroupRepository.server"; +import { getViewerTimezone } from "~/features/timezone/timezone-context.server"; import type { SerializeFrom } from "~/utils/remix"; import * as TeamRepository from "../../team/TeamRepository.server"; import * as ScrimPickupRosterRepository from "../ScrimPickupRosterRepository.server"; @@ -9,9 +13,34 @@ export type ScrimsNewLoaderData = SerializeFrom; export const loader = async () => { const user = requireUser(); + const [teams, friendsAndTeammates] = await Promise.all([ + TeamRepository.findAllByMemberUserId(user.id), + SQGroupRepository.findFriendsAndTeammates(user.id), + ]); + + // everyone the post could be made with whose schedule the author may see: + // their teams' rosters and their friends, the same visibility rule the rest + // of the schedule surfaces follow + const scheduleUserIds = R.unique([ + user.id, + ...teams.flatMap((team) => team.members.map((member) => member.id)), + ...friendsAndTeammates.friends.map((friend) => friend.id), + ]); + return { - teams: await TeamRepository.findAllByMemberUserId(user.id), + teams, associations: await AssociationRepository.findByMemberUserId(user.id), recentPickupRosters: await ScrimPickupRosterRepository.findAllOwnRecent(), + schedule: await RosterSchedule.rosterScheduleData({ + userIds: scheduleUserIds, + timezone: getViewerTimezone() ?? "UTC", + }), + scheduleUsers: R.uniqueBy( + friendsAndTeammates.friends.map((friend) => ({ + id: friend.id, + username: friend.username, + })), + (friend) => friend.id, + ), }; }; diff --git a/app/features/scrims/loaders/scrims.server.ts b/app/features/scrims/loaders/scrims.server.ts index 70d578157..5bd14300b 100644 --- a/app/features/scrims/loaders/scrims.server.ts +++ b/app/features/scrims/loaders/scrims.server.ts @@ -3,12 +3,15 @@ import * as R from "remeda"; import * as AssociationsRepository from "~/features/associations/AssociationRepository.server"; import * as Association from "~/features/associations/core/Association"; import { getUser } from "~/features/auth/core/user.server"; +import * as RosterSchedule from "~/features/availability/core/RosterSchedule.server"; import * as UserCardRepository from "~/features/user-card/UserCardRepository.server"; +import { dateToDatabaseTimestamp } from "~/utils/dates"; import * as TeamRepository from "../../team/TeamRepository.server"; import * as Scrim from "../core/Scrim"; import * as ScrimPostRepository from "../ScrimPostRepository.server"; import { scrimsSearchParams } from "../scrims-search-params"; -import { dividePosts } from "../scrims-utils"; +import type { ScrimPost } from "../scrims-types"; +import { dividePosts, postSpan } from "../scrims-utils"; export const loader = async ({ request }: LoaderFunctionArgs) => { const user = getUser(); @@ -55,12 +58,19 @@ export const loader = async ({ request }: LoaderFunctionArgs) => { ]), ); + const dividedPosts = dividePosts(posts, user?.id); + const teams = user ? await TeamRepository.findAllByMemberUserId(user.id) : []; + return { ...(await UserCardRepository.findAllByUserIds({ userIds: cardUserIds, })), - posts: dividePosts(posts, user?.id), - teams: user ? await TeamRepository.findAllByMemberUserId(user.id) : [], + posts: dividedPosts, + teams, + availability: await rosterAvailability({ + posts: dividedPosts.neutral, + teams, + }), filters, canSaveAsDefault: user != null && @@ -70,3 +80,35 @@ export const loader = async ({ request }: LoaderFunctionArgs) => { ), }; }; + +/** + * How the viewer's teams relate to the posts they could request: the material + * the fit indicators on the post cards and in the request dialog are resolved + * from, one entry per post. + */ +async function rosterAvailability({ + posts, + teams, +}: { + posts: Array; + teams: Awaited>; +}) { + const userIds = R.unique( + teams.flatMap((team) => + Scrim.teamPlayers(team.members).map((member) => member.id), + ), + ); + const now = dateToDatabaseTimestamp(new Date()); + + return { + /** Server clock, so that the shown fit does not change on hydration. */ + now, + windows: await RosterSchedule.windowSchedules({ + windows: posts.map((post) => ({ + id: post.id, + ...postSpan({ post, now }), + })), + userIds, + }), + }; +} diff --git a/app/features/scrims/routes/scrims.new.tsx b/app/features/scrims/routes/scrims.new.tsx index 34635ad17..db0d40de8 100644 --- a/app/features/scrims/routes/scrims.new.tsx +++ b/app/features/scrims/routes/scrims.new.tsx @@ -17,6 +17,7 @@ import type { SendouRouteHandle } from "~/utils/remix.server"; import { FormMessage } from "../../../components/FormMessage"; import { Main } from "../../../components/Main"; import { action } from "../actions/scrims.new.server"; +import { ScrimSchedulePicker } from "../components/ScrimSchedulePicker"; import { WithFormField } from "../components/WithFormField"; import { loader, type ScrimsNewLoaderData } from "../loaders/scrims.new.server"; import { SCRIM } from "../scrims-constants"; @@ -34,7 +35,7 @@ export const meta: MetaFunction = (args) => { }; export const handle: SendouRouteHandle = { - i18n: "scrims", + i18n: ["scrims", "schedule"], }; type FormFields = v.InferOutput; @@ -87,6 +88,8 @@ export default function NewScrimPage() { )} + + @@ -117,6 +120,28 @@ export default function NewScrimPage() { ); } +function SchedulePicker() { + const data = useLoaderData(); + const { values, setValue } = useFormFieldContext(); + + const from = values.from as FormFields["from"] | null; + if (!from) return null; + + return ( + { + setValue("at", at); + setValue("rangeEnd", rangeEnd); + }} + /> + ); +} + function BaseVisibilityFormField({ associations, name, diff --git a/app/features/scrims/routes/scrims.tsx b/app/features/scrims/routes/scrims.tsx index 4a8c4ff24..882ee511e 100644 --- a/app/features/scrims/routes/scrims.tsx +++ b/app/features/scrims/routes/scrims.tsx @@ -49,7 +49,7 @@ import styles from "./scrims.module.css"; export type NewRequestFormFields = v.InferOutput; export const handle: SendouRouteHandle = { - i18n: ["calendar", "scrims", "user", "q"], + i18n: ["calendar", "schedule", "scrims", "user", "q"], breadcrumb: () => ({ imgPath: navIconUrl("scrims"), href: scrimsPage(), diff --git a/app/features/scrims/scrims-constants.ts b/app/features/scrims/scrims-constants.ts index d3ca410b5..a84b0bfbf 100644 --- a/app/features/scrims/scrims-constants.ts +++ b/app/features/scrims/scrims-constants.ts @@ -13,6 +13,18 @@ export const LUTI_DIVS = [ "11", ] as const; +/** Start-time flexibility a scrim post can be given, as minutes added to its start. */ +export const RANGE_END_MINUTES = { + "+30min": 30, + "+1hour": 60, + "+1.5hours": 90, + "+2hours": 120, + "+2.5hours": 150, + "+3hours": 180, +} as const; + +export type RangeEndOption = keyof typeof RANGE_END_MINUTES; + export const SCRIM = { MAX_PICKUP_SIZE_EXCLUDING_OWNER: 5, MAX_SAVED_PICKUP_ROSTERS: 5, diff --git a/app/features/scrims/scrims-schemas.ts b/app/features/scrims/scrims-schemas.ts index ea9efdc93..ca6d2d7f3 100644 --- a/app/features/scrims/scrims-schemas.ts +++ b/app/features/scrims/scrims-schemas.ts @@ -271,15 +271,6 @@ export const scrimIdActionSchema = v.union([ const MAX_SCRIM_POST_TEXT_LENGTH = 500; -export const RANGE_END_OPTIONS = [ - "+30min", - "+1hour", - "+1.5hours", - "+2hours", - "+2.5hours", - "+3hours", -] as const; - export const scrimRequestFormSchema = v.object({ _action: stringConstant("NEW_REQUEST"), scrimPostId: idConstant(), diff --git a/app/features/scrims/scrims-utils.test.ts b/app/features/scrims/scrims-utils.test.ts index 914324c5e..5e05ee41a 100644 --- a/app/features/scrims/scrims-utils.test.ts +++ b/app/features/scrims/scrims-utils.test.ts @@ -1,10 +1,13 @@ import { describe, expect, test } from "vitest"; import { MapPool } from "~/features/map-list-generator/core/map-pool"; +import { dateToDatabaseTimestamp } from "~/utils/dates"; import { formatFlexTimeDisplay, generateTimeOptions, parseLutiDivFromName, parseMapPoolInput, + postSpan, + requestStarts, } from "./scrims-utils"; describe("parseLutiDivFromName", () => { @@ -323,3 +326,52 @@ describe("parseMapPoolInput", () => { ); }); }); + +describe("requestStarts", () => { + const at = (time: string) => + dateToDatabaseTimestamp(new Date(`2025-01-15T${time}:00`)); + const post = { startsAt: at("19:00"), rangeEndsAt: at("20:30") }; + + test("offers every half hour of the post's flexibility", () => { + expect(requestStarts({ post, now: at("12:00") })).toEqual([ + at("19:00"), + at("19:30"), + at("20:00"), + at("20:30"), + ]); + }); + + test("drops the starts already gone by", () => { + expect(requestStarts({ post, now: at("19:45") })).toEqual([ + at("20:00"), + at("20:30"), + ]); + }); + + test("offers now for a post with no flexibility", () => { + expect( + requestStarts({ + post: { startsAt: at("18:00"), rangeEndsAt: null }, + now: at("19:00"), + }), + ).toEqual([at("19:00")]); + }); + + test("offers now once the whole flexibility has passed", () => { + expect(requestStarts({ post, now: at("21:00") })).toEqual([at("21:00")]); + }); +}); + +describe("postSpan", () => { + const at = (time: string) => + dateToDatabaseTimestamp(new Date(`2025-01-15T${time}:00`)); + + test("reaches from the earliest start to the end of a scrim from the latest", () => { + expect( + postSpan({ + post: { startsAt: at("19:00"), rangeEndsAt: at("20:30") }, + now: at("12:00"), + }), + ).toEqual({ startsAt: at("19:00"), endsAt: at("22:00") }); + }); +}); diff --git a/app/features/scrims/scrims-utils.ts b/app/features/scrims/scrims-utils.ts index 381dd6f33..962bc0481 100644 --- a/app/features/scrims/scrims-utils.ts +++ b/app/features/scrims/scrims-utils.ts @@ -1,7 +1,12 @@ import { differenceInMinutes } from "date-fns"; import * as R from "remeda"; +import { AVAILABILITY } from "~/features/availability/availability-constants"; +import type { TimeRange } from "~/features/availability/availability-types"; import { MapPool } from "~/features/map-list-generator/core/map-pool"; -import { databaseTimestampToDate } from "~/utils/dates"; +import { + databaseTimestampToDate, + dateToDatabaseTimestamp, +} from "~/utils/dates"; import * as Scrim from "./core/Scrim"; import { LUTI_DIVS } from "./scrims-constants"; import type { LutiDiv, ScrimPost } from "./scrims-types"; @@ -74,6 +79,50 @@ export const serializeLutiDiv = (div: LutiDiv): number => { return Number(div); }; +/** + * The starts a request for the post can still be made for: its start, the half + * hours inside its start-time flexibility and the end of that flexibility, + * with the ones already past dropped. A post with no flexibility left offers + * `now` — "looking now" is what it means. + */ +export function requestStarts({ + post, + now, +}: { + post: Pick; + now: number; +}): Array { + const starts = post.rangeEndsAt + ? generateTimeOptions( + databaseTimestampToDate(post.startsAt), + databaseTimestampToDate(post.rangeEndsAt), + ).map((timestamp) => dateToDatabaseTimestamp(new Date(timestamp))) + : [post.startsAt]; + + const upcoming = starts.filter((startsAt) => startsAt >= now); + + return upcoming.length > 0 ? upcoming : [now]; +} + +/** + * The whole span the post's scrim could take up: from the earliest start still + * on offer to the end of a scrim played from the latest one. + */ +export function postSpan({ + post, + now, +}: { + post: Pick; + now: number; +}): TimeRange { + const starts = requestStarts({ post, now }); + + return { + startsAt: starts[0], + endsAt: starts[starts.length - 1] + AVAILABILITY.SCRIM_COMMITMENT_SECONDS, + }; +} + export function generateTimeOptions(startDate: Date, endDate: Date): number[] { const timestamps = new Set(); diff --git a/app/features/sendouq/SQGroupRepository.server.ts b/app/features/sendouq/SQGroupRepository.server.ts index 088dc0b61..576e07744 100644 --- a/app/features/sendouq/SQGroupRepository.server.ts +++ b/app/features/sendouq/SQGroupRepository.server.ts @@ -535,6 +535,8 @@ export async function findFriendsAndTeammates(userId: number) { ...commonUserSelect(eb), "User.inGameName", "TeamMemberWithSecondary.teamId", + "TeamMemberWithSecondary.role", + "TeamMemberWithSecondary.roleType", ]) .where( "TeamMemberWithSecondary.teamId", @@ -562,6 +564,8 @@ export async function findFriendsAndTeammates(userId: number) { ...commonUserSelect(eb), "User.inGameName", sql`null`.as("teamId"), + sql`null`.as("role"), + sql`null`.as("roleType"), ]), ) .execute(); diff --git a/app/features/sidebar/core/sidebar.server.ts b/app/features/sidebar/core/sidebar.server.ts index 91fa39663..61ccee313 100644 --- a/app/features/sidebar/core/sidebar.server.ts +++ b/app/features/sidebar/core/sidebar.server.ts @@ -1,8 +1,11 @@ import { cachified } from "@epic-web/cachified"; -import { addDays } from "date-fns"; +import { addDays, addWeeks } from "date-fns"; import { href } from "react-router"; import * as R from "remeda"; import * as ExternalStreamRepository from "~/features/admin/ExternalStreamRepository.server"; +import type { AuthenticatedUser } from "~/features/auth/core/user.server"; +import * as AvailabilityRepository from "~/features/availability/AvailabilityRepository.server"; +import * as Availability from "~/features/availability/core/Availability"; import { userIsBanned } from "~/features/ban/core/banned.server"; import type { ShowcaseCalendarEvent } from "~/features/calendar/calendar-types"; import { @@ -27,6 +30,7 @@ import type { SidebarScrim } from "~/features/scrims/ScrimPostRepository.server" import * as ScrimPostRepository from "~/features/scrims/ScrimPostRepository.server"; import { scrimsSearchParams } from "~/features/scrims/scrims-search-params"; import { getSendouQSidebarStreams } from "~/features/sendouq-streams/core/streams.server"; +import { getViewerTimezone } from "~/features/timezone/timezone-context.server"; import type { TournamentTierNumber } from "~/features/tournament/core/tiering"; import * as SavedCalendarEventRepository from "~/features/tournament/SavedCalendarEventRepository.server"; import { cache, ttl } from "~/utils/cache.server"; @@ -36,6 +40,7 @@ import { BLANK_IMAGE_URL, discordAvatarUrl, navIconUrl, + teamSchedulePage, twitchUrl, userPage, } from "~/utils/urls"; @@ -49,7 +54,7 @@ export type SidebarEvent = { /** Whose avatar the event shows instead of a logo of its own. */ user: CommonUser | null; startsAt: number; - type: "tournament" | "scrim"; + type: "tournament" | "scrim" | "teamEvent"; scrimStatus?: "booked" | "looking" | "requestPending"; }; @@ -75,7 +80,9 @@ const UPCOMING_TOURNAMENT_WINDOW_DAYS = 3; const SENDOUQ_QUOTA = 2; const TOURNAMENT_SUB_QUOTA = 2; -export async function resolveSidebarData(userId: number | null) { +export async function resolveSidebarData(user: AuthenticatedUser | undefined) { + const userId = user?.id ?? null; + if (!userId) { return { events: [] as SidebarEvent[], @@ -83,24 +90,22 @@ export async function resolveSidebarData(userId: number | null) { streams: await combinedStreamsCached(), savedTournamentIds: [] as number[], incomingFriendRequestIds: [] as number[], + scheduleNudge: false, }; } - const [ - tournamentsData, - scrimsData, - friendsWithActivity, - savedTournaments, - incomingFriendRequestIds, - streamedSendouQMatches, - ] = await Promise.all([ - ShowcaseTournaments.categorizedTournamentsByUserId(userId), - ScrimPostRepository.findUserScrims(userId), - FriendRepository.findByUserIdWithActivity(userId), - SavedCalendarEventRepository.findAllUpcomingByUserId(userId), - FriendRepository.findPendingReceivedRequestIds(userId), - resolveSendouQMatchStreams(), - ]); + const tournamentsData = + await ShowcaseTournaments.categorizedTournamentsByUserId(userId); + const scrimsData = await ScrimPostRepository.findUserScrims(userId); + const friendsWithActivity = + await FriendRepository.findByUserIdWithActivity(userId); + const savedTournaments = + await SavedCalendarEventRepository.findAllUpcomingByUserId(userId); + const incomingFriendRequestIds = + await FriendRepository.findPendingReceivedRequestIds(userId); + const streamedSendouQMatches = await resolveSendouQMatchStreams(); + const teamEvents = await findUpcomingTeamEvents(userId); + const scheduleNudge = await showScheduleNudge(user); const seenTournamentIds = new Set(); const tournamentEvents: SidebarEvent[] = [ @@ -123,7 +128,16 @@ export async function resolveSidebarData(userId: number | null) { const scrimEvents: SidebarEvent[] = scrimsData.map(scrimToSidebarEvent); - const events = [...tournamentEvents, ...savedEvents, ...scrimEvents] + const teamEventEvents: SidebarEvent[] = teamEvents.map( + teamEventToSidebarEvent, + ); + + const events = [ + ...tournamentEvents, + ...savedEvents, + ...scrimEvents, + ...teamEventEvents, + ] .sort((a, b) => a.startsAt - b.startsAt) .slice(0, MAX_EVENTS_VISIBLE); @@ -137,9 +151,38 @@ export async function resolveSidebarData(userId: number | null) { streams: await combinedStreamsCached(), savedTournamentIds, incomingFriendRequestIds, + scheduleNudge, }; } +/** + * Whether to prompt the user to report next week: they are on its last day, it + * is still empty, and they have not waved the prompt away for this week. + */ +async function showScheduleNudge(user: AuthenticatedUser | undefined) { + if (!user) return false; + + const timezone = getViewerTimezone() ?? "UTC"; + const now = new Date(); + + if (!Availability.isLastDayOfWeek(now, timezone)) return false; + + const weekStartsAt = Availability.weekStartsAt(addWeeks(now, 1), timezone); + const dismissedAt = user.preferences?.scheduleNudgeDismissedWeekStartsAt; + + if ( + dismissedAt !== undefined && + Availability.isSameWeek(dismissedAt, weekStartsAt) + ) { + return false; + } + + return !(await AvailabilityRepository.hasReportedWeek({ + userId: user.id, + weekStartsAt, + })); +} + function combinedStreamsCached(): Promise { return cachified({ key: COMBINED_STREAMS_KEY, @@ -416,6 +459,39 @@ export function tournamentToSidebarEvent( }; } +const TEAM_EVENT_WINDOW_DAYS = 14; + +/** Team events shown on the sidebar and the personal calendar page: ongoing ones and those starting within the next two weeks. */ +export function findUpcomingTeamEvents(userId: number) { + const now = new Date(); + + return AvailabilityRepository.findAllUpcomingTeamEventsByUserId({ + userId, + startsAt: dateToDatabaseTimestamp(now), + endsAt: dateToDatabaseTimestamp(addDays(now, TEAM_EVENT_WINDOW_DAYS)), + }); +} + +type UpcomingTeamEvent = Awaited< + ReturnType +>[number]; + +const TEAM_ICON_URL = `${navIconUrl("t")}.avif`; + +export function teamEventToSidebarEvent( + event: UpcomingTeamEvent, +): SidebarEvent { + return { + id: event.id, + name: event.name, + url: teamSchedulePage(event.teamCustomUrl), + logoUrl: event.teamAvatarUrl ?? TEAM_ICON_URL, + user: null, + startsAt: event.startsAt, + type: "teamEvent" as const, + }; +} + const SCRIMS_ICON_URL = `${navIconUrl("scrims")}.avif`; export function scrimToSidebarEvent(s: SidebarScrim): SidebarEvent { diff --git a/app/features/team/routes/t.$customUrl.index.tsx b/app/features/team/routes/t.$customUrl.index.tsx index ccd5c92de..aa3afc976 100644 --- a/app/features/team/routes/t.$customUrl.index.tsx +++ b/app/features/team/routes/t.$customUrl.index.tsx @@ -1,4 +1,5 @@ import { + CalendarDays, LogOut, Menu, SquarePen, @@ -115,13 +116,26 @@ function ActionButtons() { const team = layoutData.team; const canManageRoster = useHasPermission(team, "MANAGE_ROSTER"); const canEditTeam = useHasPermission(team, "EDIT"); + const isMember = isTeamMember({ user, team }); - if (!isTeamMember({ user, team }) && !canManageRoster && !canEditTeam) { + if (!isMember && !canManageRoster && !canEditTeam) { return null; } return (
    + {isMember ? ( + } + testId="team-schedule-button" + > + {t("team:actionButtons.schedule")} + + ) : null} {canManageRoster ? ( -
    - +
    +
    +
    + +
    + +
    - -
    ); } diff --git a/app/features/tournament-bracket/routes/to.$id.brackets.tsx b/app/features/tournament-bracket/routes/to.$id.brackets.tsx index fbbdd42d2..bf7b3e096 100644 --- a/app/features/tournament-bracket/routes/to.$id.brackets.tsx +++ b/app/features/tournament-bracket/routes/to.$id.brackets.tsx @@ -1,7 +1,6 @@ import { sub } from "date-fns"; import { Check, - Clipboard, Eye, EyeOff, Map as MapIcon, @@ -29,6 +28,7 @@ import { SendouTabPanel, SendouTabs, } from "~/components/elements/Tabs"; +import { InviteLinkInput } from "~/components/InviteLinkInput"; import { LocaleTimeRange } from "~/components/LocaleTimeRange"; import { useUser } from "~/features/auth/core/user"; import { useTopicRevalidation } from "~/features/chat/chat-hooks"; @@ -37,7 +37,6 @@ import { TournamentProvider, useTournament, } from "~/features/tournament/tournament-context"; -import { useCopyToClipboard } from "~/hooks/useCopyToClipboard"; import { useHydrated } from "~/hooks/useHydrated"; import { useIsomorphicLayoutEffect } from "~/hooks/useIsomorphicLayoutEffect"; import { useSearchParam } from "~/modules/search-params/hooks"; @@ -438,7 +437,6 @@ function MapPreparer({ function AddSubsPopOver() { const { t } = useTranslation(["common", "tournament"]); - const { copyToClipboard, copySuccess } = useCopyToClipboard(); const tournament = useTournament(); const user = useUser(); const data = useLoaderData(); @@ -465,19 +463,7 @@ function AddSubsPopOver() { {subsAvailableToAdd > 0 ? ( <> -
    {t("tournament:actions.shareLink", { inviteLink })}
    -
    - : } - onPress={() => copyToClipboard(inviteLink)} - variant="minimal" - className="tiny" - data-testid="copy-invite-link-button" - > - {t("common:actions.copyToClipboard")} - -
    + ) : null} diff --git a/app/features/tournament-organization/TournamentOrganizationRepository.server.ts b/app/features/tournament-organization/TournamentOrganizationRepository.server.ts index 2a67ae5b6..2fbcde250 100644 --- a/app/features/tournament-organization/TournamentOrganizationRepository.server.ts +++ b/app/features/tournament-organization/TournamentOrganizationRepository.server.ts @@ -1,5 +1,5 @@ import { isFuture } from "date-fns"; -import { type ExpressionBuilder, sql } from "kysely"; +import { type ExpressionBuilder, type NotNull, sql } from "kysely"; import * as R from "remeda"; import { db } from "~/db/sql"; import type { DB, Tables, TablesInsertable } from "~/db/tables"; @@ -373,6 +373,61 @@ export async function findEventsByMonth({ return events.map(mapEvent); } +/** Every tournament series of every organization. */ +export function findAllSeries() { + return db + .selectFrom("TournamentOrganizationSeries") + .select([ + "TournamentOrganizationSeries.organizationId", + "TournamentOrganizationSeries.substringMatches", + "TournamentOrganizationSeries.tierHistory", + ]) + .execute(); +} + +/** + * How many teams each organization's already started tournaments drew within the + * given window, oldest first. Counts what the tournament's own page shows: + * placeholder teams excluded, dropped out ones included. + */ +export function findAllOrganizedTournamentTeamCounts({ + startedAfter, +}: { + startedAfter: number; +}) { + return db + .selectFrom("CalendarEvent") + .innerJoin( + "CalendarEventDate", + "CalendarEventDate.eventId", + "CalendarEvent.id", + ) + .select((eb) => [ + "CalendarEvent.name", + "CalendarEvent.organizationId", + eb.fn.min("CalendarEventDate.startsAt").as("startsAt"), + eb + .selectFrom("TournamentTeam") + .select(({ fn }) => fn.countAll().as("count")) + .whereRef( + "TournamentTeam.tournamentId", + "=", + "CalendarEvent.tournamentId", + ) + .where("TournamentTeam.isPlaceholder", "=", 0) + .as("teamCount"), + ]) + .$narrowType<{ organizationId: NotNull; teamCount: NotNull }>() + .where("CalendarEvent.organizationId", "is not", null) + .where("CalendarEvent.tournamentId", "is not", null) + .where("CalendarEvent.hidden", "=", 0) + .where("CalendarEventDate.startsAt", ">=", startedAfter) + .where("CalendarEventDate.startsAt", "<=", databaseTimestampNow()) + .groupBy("CalendarEvent.id") + .orderBy("startsAt", "asc") + .execute(); +} + export function findAllUnfinalizedEvents(organizationId: number) { return db .selectFrom("Tournament") @@ -885,13 +940,6 @@ export function deleteById(organizationId: number) { .execute(); } -export function findAllSeriesWithTierHistory() { - return db - .selectFrom("TournamentOrganizationSeries") - .select(["organizationId", "substringMatches", "tierHistory"]) - .execute(); -} - export async function updateSeriesTierHistory({ organizationId, eventName, diff --git a/app/features/tournament-organization/core/SeriesTeamCount.server.test.ts b/app/features/tournament-organization/core/SeriesTeamCount.server.test.ts new file mode 100644 index 000000000..b1ffc1d84 --- /dev/null +++ b/app/features/tournament-organization/core/SeriesTeamCount.server.test.ts @@ -0,0 +1,178 @@ +import { subDays } from "date-fns"; +import * as R from "remeda"; +import { beforeEach, describe, expect, test } from "vitest"; +import * as TournamentFactory from "~/db/seed/factories/TournamentFactory"; +import * as TournamentOrganizationFactory from "~/db/seed/factories/TournamentOrganizationFactory"; +import * as TournamentTeamFactory from "~/db/seed/factories/TournamentTeamFactory"; +import * as UserFactory from "~/db/seed/factories/UserFactory"; +import { cache } from "~/utils/cache.server"; +import { dateToDatabaseTimestamp } from "~/utils/dates"; +import * as SeriesTeamCount from "./SeriesTeamCount.server"; + +const users = UserFactory.pool(); +const authorId = () => users.id(1); + +const SERIES_NAME = "Swim or Sink"; + +describe("SeriesTeamCount.lookup", () => { + beforeEach(async () => { + // the counts are cached for the process, but every test seeds its own + cache.clear(); + await users.create(6); + }); + + test("raises the registered count to the median of the series' recent editions", async () => { + const organizationId = await createOrganization(); + await createEdition({ organizationId, startedDaysAgo: 21, teamCount: 2 }); + await createEdition({ organizationId, startedDaysAgo: 14, teamCount: 6 }); + await createEdition({ organizationId, startedDaysAgo: 7, teamCount: 4 }); + + const expectedTeamCount = await SeriesTeamCount.lookup(); + + expect( + expectedTeamCount({ + organizationId, + name: `${SERIES_NAME} 4`, + teamCount: 1, + }), + ).toBe(4); + }); + + test("keeps the registered count when it is already above the series median", async () => { + const organizationId = await createOrganization(); + await createEdition({ organizationId, startedDaysAgo: 7, teamCount: 2 }); + + const expectedTeamCount = await SeriesTeamCount.lookup(); + + expect( + expectedTeamCount({ + organizationId, + name: `${SERIES_NAME} 2`, + teamCount: 5, + }), + ).toBe(5); + }); + + test("counts only the latest editions of the series", async () => { + const organizationId = await createOrganization(); + await createEdition({ organizationId, startedDaysAgo: 28, teamCount: 6 }); + await createEdition({ organizationId, startedDaysAgo: 21, teamCount: 6 }); + await createEdition({ organizationId, startedDaysAgo: 14, teamCount: 1 }); + await createEdition({ organizationId, startedDaysAgo: 7, teamCount: 1 }); + + const expectedTeamCount = await SeriesTeamCount.lookup(); + + expect( + expectedTeamCount({ + organizationId, + name: `${SERIES_NAME} 5`, + teamCount: 0, + }), + ).toBe(1); + }); + + test("ignores editions that have not started yet", async () => { + const organizationId = await createOrganization(); + await createEdition({ organizationId, startedDaysAgo: 7, teamCount: 2 }); + await createEdition({ organizationId, startedDaysAgo: -7, teamCount: 6 }); + + const expectedTeamCount = await SeriesTeamCount.lookup(); + + expect( + expectedTeamCount({ + organizationId, + name: `${SERIES_NAME} 3`, + teamCount: 0, + }), + ).toBe(2); + }); + + test("ignores the organization's tournaments outside the series", async () => { + const organizationId = await createOrganization(); + await createEdition({ organizationId, startedDaysAgo: 7, teamCount: 2 }); + await createEdition({ + organizationId, + name: "One off invitational", + startedDaysAgo: 5, + teamCount: 6, + }); + + const expectedTeamCount = await SeriesTeamCount.lookup(); + + expect( + expectedTeamCount({ + organizationId, + name: `${SERIES_NAME} 3`, + teamCount: 0, + }), + ).toBe(2); + }); + + test("returns the registered count for a tournament of no organization", async () => { + const organizationId = await createOrganization(); + await createEdition({ organizationId, startedDaysAgo: 7, teamCount: 6 }); + + const expectedTeamCount = await SeriesTeamCount.lookup(); + + expect( + expectedTeamCount({ + organizationId: null, + name: `${SERIES_NAME} 2`, + teamCount: 1, + }), + ).toBe(1); + }); + + test("returns the registered count when the series has no edition yet", async () => { + const organizationId = await createOrganization(); + + const expectedTeamCount = await SeriesTeamCount.lookup(); + + expect( + expectedTeamCount({ + organizationId, + name: `${SERIES_NAME} 1`, + teamCount: 1, + }), + ).toBe(1); + }); +}); + +async function createOrganization() { + const organization = await TournamentOrganizationFactory.create( + { ownerId: authorId() }, + { + series: [ + { name: SERIES_NAME, description: null, showLeaderboard: false }, + ], + }, + ); + + return organization.id; +} + +async function createEdition({ + organizationId, + name = SERIES_NAME, + startedDaysAgo, + teamCount, +}: { + organizationId: number; + name?: string; + startedDaysAgo: number; + teamCount: number; +}) { + const tournament = await TournamentFactory.create({ + authorId: authorId(), + name, + organizationId, + startTimes: [dateToDatabaseTimestamp(subDays(new Date(), startedDaysAgo))], + }); + + for (const idx of R.range(0, teamCount)) { + await TournamentTeamFactory.create({ + tournamentId: tournament.id, + memberUserIds: [users.id(idx + 1)], + }); + } +} diff --git a/app/features/tournament-organization/core/SeriesTeamCount.server.ts b/app/features/tournament-organization/core/SeriesTeamCount.server.ts new file mode 100644 index 000000000..dccb26015 --- /dev/null +++ b/app/features/tournament-organization/core/SeriesTeamCount.server.ts @@ -0,0 +1,93 @@ +import { cachified } from "@epic-web/cachified"; +import { subDays } from "date-fns"; +import * as R from "remeda"; +import { cache, IN_MILLISECONDS, ttl } from "~/utils/cache.server"; +import { dateToDatabaseTimestamp } from "~/utils/dates"; +import * as TournamentOrganizationRepository from "../TournamentOrganizationRepository.server"; + +const CACHE_KEY = "series-team-counts"; +/** How old an edition can be and still say something about the next one. */ +const LOOKBACK_DAYS = 90; +/** How many of a series' latest editions the typical count is taken from. */ +const EDITIONS_CONSIDERED = 3; + +interface Tournament { + organizationId: number | null; + name: string; + /** Teams registered so far. */ + teamCount: number; +} + +interface SeriesTeamCounts { + substringMatches: Array; + teamCounts: Array; +} + +/** + * Resolves the team count a tournament is *expected* to draw: its registered + * count raised to the median of the last {@link EDITIONS_CONSIDERED} editions of + * its series, never lowered. + */ +export async function lookup() { + const seriesByOrganizationId = await cachedSeriesTeamCounts(); + + return (tournament: Tournament) => { + if (!tournament.organizationId) return tournament.teamCount; + + const series = seriesByOrganizationId.get(tournament.organizationId); + if (!series) return tournament.teamCount; + + const nameLower = tournament.name.toLowerCase(); + const match = series.find((candidate) => + candidate.substringMatches.some((substring) => + nameLower.includes(substring.toLowerCase()), + ), + ); + if (!match) return tournament.teamCount; + + return Math.max( + tournament.teamCount, + R.median(match.teamCounts) ?? tournament.teamCount, + ); + }; +} + +function cachedSeriesTeamCounts() { + return cachified({ + key: CACHE_KEY, + cache, + ttl: ttl(IN_MILLISECONDS.TWO_HOURS), + getFreshValue: seriesTeamCounts, + }); +} + +async function seriesTeamCounts() { + const [series, tournaments] = await Promise.all([ + TournamentOrganizationRepository.findAllSeries(), + TournamentOrganizationRepository.findAllOrganizedTournamentTeamCounts({ + startedAfter: dateToDatabaseTimestamp(subDays(new Date(), LOOKBACK_DAYS)), + }), + ]); + + const result = new Map>(); + for (const row of series) { + const teamCounts = tournaments + .filter( + (tournament) => + tournament.organizationId === row.organizationId && + row.substringMatches.some((substring) => + tournament.name.toLowerCase().includes(substring.toLowerCase()), + ), + ) + .slice(-EDITIONS_CONSIDERED) + .map((tournament) => tournament.teamCount); + + if (teamCounts.length === 0) continue; + + const existing = result.get(row.organizationId) ?? []; + existing.push({ substringMatches: row.substringMatches, teamCounts }); + result.set(row.organizationId, existing); + } + + return result; +} diff --git a/app/features/tournament-organization/core/tentativeTiers.server.ts b/app/features/tournament-organization/core/tentativeTiers.server.ts index 8bb0c7fe7..423118808 100644 --- a/app/features/tournament-organization/core/tentativeTiers.server.ts +++ b/app/features/tournament-organization/core/tentativeTiers.server.ts @@ -8,8 +8,7 @@ interface SeriesMatch { } async function loadCache(): Promise> { - const rows = - await TournamentOrganizationRepository.findAllSeriesWithTierHistory(); + const rows = await TournamentOrganizationRepository.findAllSeries(); const result = new Map(); for (const row of rows) { diff --git a/app/features/tournament/TournamentTeamRepository.server.ts b/app/features/tournament/TournamentTeamRepository.server.ts index 0b5d27184..b6babdb0f 100644 --- a/app/features/tournament/TournamentTeamRepository.server.ts +++ b/app/features/tournament/TournamentTeamRepository.server.ts @@ -989,6 +989,68 @@ 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. `excludeTournamentId` + * leaves one tournament's own registrations out, for surfaces asking "busy + * elsewhere" while looking at that tournament. + */ +export function findAllRegistrationsByUserIds({ + userIds, + startsAt, + endsAt, + excludeTournamentId, +}: { + userIds: Array; + startsAt: number; + endsAt: number; + excludeTournamentId?: 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", + "CalendarEvent.organizationId", + "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) + .$if(typeof excludeTournamentId === "number", (qb) => + qb.where("Tournament.id", "!=", excludeTournamentId!), + ) + .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/app/features/tournament/actions/to.$id.register.server.ts b/app/features/tournament/actions/to.$id.register.server.ts index 1f220b7a6..c4ffd071d 100644 --- a/app/features/tournament/actions/to.$id.register.server.ts +++ b/app/features/tournament/actions/to.$id.register.server.ts @@ -1,4 +1,5 @@ import type { ActionFunction } from "react-router"; +import * as R from "remeda"; import * as ChatSystemMessage from "~/features/chat/ChatSystemMessage.server"; import * as ShowcaseTournaments from "~/features/front-page/core/ShowcaseTournaments.server"; import { MapPool } from "~/features/map-list-generator/core/map-pool"; @@ -6,8 +7,10 @@ import { notify } from "~/features/notifications/core/notify.server"; import { resolveNotifications } from "~/features/notifications/core/resolve.server"; import * as SQGroupRepository from "~/features/sendouq/SQGroupRepository.server"; import * as TeamRepository from "~/features/team/TeamRepository.server"; +import { getMemberRoleType } from "~/features/team/team-utils"; import * as SavedCalendarEventRepository from "~/features/tournament/SavedCalendarEventRepository.server"; import * as TournamentTeamRepository from "~/features/tournament/TournamentTeamRepository.server"; +import type { Tournament } from "~/features/tournament-bracket/core/Tournament"; import { clearTournamentDataCache, tournamentFromParams, @@ -16,7 +19,7 @@ import * as TournamentLFGRepository from "~/features/tournament-lfg/TournamentLF import * as UserRepository from "~/features/user-page/UserRepository.server"; import { parseFormDataWithImages } from "~/form/parse.server"; import { logger } from "~/utils/logger"; -import { errorToastIfFalsy } from "~/utils/remix.server"; +import { errorToastIfFalsy, successToast } from "~/utils/remix.server"; import { toDBBoolean } from "~/utils/sql"; import { assertUnreachable } from "~/utils/types"; import { registerSchema } from "../tournament-schemas.server"; @@ -25,6 +28,8 @@ import { validateCounterPickMapPool, } from "../tournament-utils"; import { + fulfillsSendouQParticipation, + isBannedByOrganization, requireNotBannedByOrganization, requireSendouQParticipationIfNeeded, } from "../tournament-utils.server"; @@ -304,44 +309,83 @@ export const action: ActionFunction = async ({ request, params }) => { userId: data.userId, }); - ChatSystemMessage.notifyRoomsChanged([ - ...(await TournamentLFGRepository.leaveLfg({ - userId: data.userId, - tournamentId, - })), - ...(await TournamentTeamRepository.join({ - userId: data.userId, - newTeamId: ownTeam.id, - })), - ]); - - await SavedCalendarEventRepository.unsaveByUserId({ - userId: data.userId, + await addPlayerToOwnTeam({ + tournament, tournamentId, - }); - - ShowcaseTournaments.addToCached({ - tournamentId, - type: "participant", + ownTeam, + adder: user, userId: data.userId, }); + await ShowcaseTournaments.refreshCachedTournamentCounts(tournamentId); - if (!tournament.isTest && !tournament.isDraft) { - notify({ - userIds: [data.userId], - notification: { - type: "TO_ADDED_TO_TEAM", - meta: { - adderUsername: user.username, - tournamentId, - teamName: ownTeam.name, - tournamentName: tournament.ctx.name, - tournamentTeamId: ownTeam.id, - }, - pictureUrl: tournament.ctx.logoUrl, - }, + break; + } + case "ADD_TEAM_PLAYERS": { + errorToastIfFalsy(ownTeam, "You are not registered to this tournament"); + errorToastIfFalsy(tournament.registrationOpen, "Registration is closed"); + + const friendPlayers = await SQGroupRepository.findFriendsAndTeammates( + user.id, + ); + errorToastIfFalsy( + friendPlayers.teams.some((team) => team.id === data.teamId), + "Team id does not match any of the teams you are in", + ); + + const candidates = friendPlayers.friends.filter( + (friendPlayer) => + friendPlayer.teamId === data.teamId && + getMemberRoleType(friendPlayer) !== "OTHER" && + tournament.ctx.teams.every( + (team) => !team.memberUserIds.includes(friendPlayer.id), + ) && + (!tournament.ctx.settings.requireInGameNames || + friendPlayer.inGameName), + ); + errorToastIfFalsy(candidates.length > 0, "No players to add"); + + const spotsLeft = + tournament.maxMembersPerTeam - ownTeam.memberUserIds.length; + errorToastIfFalsy(spotsLeft > 0, "Team is already at max capacity"); + + let addedCount = 0; + const skippedReasons: Array = []; + for (const candidate of candidates) { + if (addedCount >= spotsLeft) break; + + const reason = await ineligibleReason({ + tournament, + userId: candidate.id, }); + if (reason) { + skippedReasons.push(reason); + continue; + } + + await addPlayerToOwnTeam({ + tournament, + tournamentId, + ownTeam, + adder: user, + userId: candidate.id, + }); + addedCount++; + } + + errorToastIfFalsy( + addedCount > 0, + `No players could be added. ${skippedSummary(skippedReasons)}`.trim(), + ); + + await ShowcaseTournaments.refreshCachedTournamentCounts(tournamentId); + + if (skippedReasons.length > 0) { + clearTournamentDataCache(tournamentId); + + return successToast( + `Added ${addedCount} player(s). ${skippedSummary(skippedReasons)}`, + ); } break; @@ -385,3 +429,93 @@ export const action: ActionFunction = async ({ request, params }) => { return null; }; + +type IneligibleReason = + | "no friend code" + | "banned by the organization" + | "not enough SendouQ participation"; + +/** Why the "add all" bulk add has to pass a candidate over, or `null` if they can be added. */ +async function ineligibleReason({ + tournament, + userId, +}: { + tournament: Tournament; + userId: number; +}): Promise { + if (!(await UserRepository.findLeanById(userId))?.friendCode) { + return "no friend code"; + } + if (await isBannedByOrganization({ tournament, userId })) { + return "banned by the organization"; + } + if (!(await fulfillsSendouQParticipation({ tournament, userId }))) { + return "not enough SendouQ participation"; + } + + return null; +} + +// names are left out on purpose: the message travels in a redirect's query string +function skippedSummary(reasons: Array) { + if (reasons.length === 0) return ""; + + const counts = R.countBy(reasons, (reason) => reason); + + return `Skipped ${reasons.length} player(s): ${Object.entries(counts) + .map(([reason, count]) => `${reason} (${count})`) + .join(", ")}`; +} + +async function addPlayerToOwnTeam({ + tournament, + tournamentId, + ownTeam, + adder, + userId, +}: { + tournament: Tournament; + tournamentId: number; + ownTeam: { id: number; name: string }; + adder: { username: string }; + userId: number; +}) { + ChatSystemMessage.notifyRoomsChanged([ + ...(await TournamentLFGRepository.leaveLfg({ + userId, + tournamentId, + })), + ...(await TournamentTeamRepository.join({ + userId, + newTeamId: ownTeam.id, + })), + ]); + + await SavedCalendarEventRepository.unsaveByUserId({ + userId, + tournamentId, + }); + + ShowcaseTournaments.addToCached({ + tournamentId, + type: "participant", + userId, + }); + + if (!tournament.isTest && !tournament.isDraft) { + notify({ + userIds: [userId], + notification: { + type: "TO_ADDED_TO_TEAM", + meta: { + adderUsername: adder.username, + tournamentId, + teamName: ownTeam.name, + tournamentName: tournament.ctx.name, + tournamentTeamId: ownTeam.id, + }, + pictureUrl: tournament.ctx.logoUrl, + }, + }); + } +} diff --git a/app/features/tournament/components/TournamentHeader.module.css b/app/features/tournament/components/TournamentHeader.module.css index 7b9372ca6..eba77b65f 100644 --- a/app/features/tournament/components/TournamentHeader.module.css +++ b/app/features/tournament/components/TournamentHeader.module.css @@ -93,6 +93,19 @@ color: var(--color-text-high); } +.date { + display: flex; + align-items: center; + gap: var(--s-1); +} + +.estimatedEnd { + display: flex; + align-items: center; + gap: var(--s-1); + font-weight: var(--weight-normal); +} + .actions { display: flex; gap: var(--s-2); diff --git a/app/features/tournament/components/TournamentHeader.tsx b/app/features/tournament/components/TournamentHeader.tsx index 19f3dbb2c..5c727ac25 100644 --- a/app/features/tournament/components/TournamentHeader.tsx +++ b/app/features/tournament/components/TournamentHeader.tsx @@ -6,6 +6,7 @@ import { ActionButton } from "~/components/ActionButton"; import { Avatar } from "~/components/Avatar"; import { LinkButton } from "~/components/elements/Button"; import { DiscordIcon } from "~/components/icons/Discord"; +import { LocaleTime } from "~/components/LocaleTime"; import { ShareUrlButton } from "~/components/ShareUrlButton"; import TimePopover from "~/components/TimePopover"; import { UserLink } from "~/components/UserLink"; @@ -18,7 +19,14 @@ import { saveTournamentSchema } from "../tournament-schemas"; import { tournamentNameParts } from "../tournament-utils"; import styles from "./TournamentHeader.module.css"; -export function TournamentHeader({ tournament }: { tournament: Tournament }) { +export function TournamentHeader({ + tournament, + estimatedEndsAt, +}: { + tournament: Tournament; + /** `null` when the tournament has no estimate. */ + estimatedEndsAt: number | null; +}) { const { name, subtext } = tournamentNameParts(tournament); const startTimes = R.uniqueBy( @@ -53,18 +61,31 @@ export function TournamentHeader({ tournament }: { tournament: Tournament }) {
    {startTimes.map((date) => ( - +
    + + {estimatedEndsAt ? ( + + ~ + + + ) : null} +
    ))}
    diff --git a/app/features/tournament/loaders/to.$id.info.server.ts b/app/features/tournament/loaders/to.$id.info.server.ts index b224d820d..fc69a5f92 100644 --- a/app/features/tournament/loaders/to.$id.info.server.ts +++ b/app/features/tournament/loaders/to.$id.info.server.ts @@ -1,18 +1,30 @@ import type { LoaderFunctionArgs } from "react-router"; +import { estimatedEndsAt } from "~/features/availability/core/TournamentDuration.server"; import * as SavedCalendarEventRepository from "~/features/tournament/SavedCalendarEventRepository.server"; import * as TournamentRepository from "~/features/tournament/TournamentRepository.server"; +import type { Tournament } from "~/features/tournament-bracket/core/Tournament"; import { tournamentFromParams } from "~/features/tournament-bracket/core/Tournament.server"; +import { dateToDatabaseTimestamp } from "~/utils/dates"; +import { logger } from "~/utils/logger"; export const loader = async ({ params }: LoaderFunctionArgs) => { - const { tournamentId, user } = await tournamentFromParams(params, { - for: "view", - }); + const { tournament, tournamentId, user } = await tournamentFromParams( + params, + { + for: "view", + }, + ); - const description = - await TournamentRepository.findDescriptionById(tournamentId); + const [description, endsAt] = await Promise.all([ + TournamentRepository.findDescriptionById(tournamentId), + estimatedEnd(tournament)?.catch((error) => { + logger.error("Failed to estimate the tournament's end", error); + return null; + }) ?? null, + ]); if (!user) { - return { isSaved: false, description }; + return { isSaved: false, description, endsAt }; } return { @@ -21,5 +33,26 @@ export const loader = async ({ params }: LoaderFunctionArgs) => { tournamentId, }), description, + endsAt, }; }; + +function estimatedEnd(tournament: Tournament) { + if (tournament.isLeague) return null; + if (tournament.ctx.startsAt <= new Date()) return null; + const isMultiSession = tournament.ctx.settings.bracketProgression.some( + (bracket) => bracket.startTime, + ); + if (isMultiSession) return null; + + return estimatedEndsAt({ + name: tournament.ctx.name, + organizationId: tournament.ctx.organization?.id ?? null, + startsAt: dateToDatabaseTimestamp(tournament.ctx.startsAt), + minMembersPerTeam: tournament.minMembersPerTeam, + bracketTypes: tournament.ctx.settings.bracketProgression.map( + (bracket) => bracket.type, + ), + teamCount: tournament.ctx.teams.length, + }); +} diff --git a/app/features/tournament/loaders/to.$id.register.server.ts b/app/features/tournament/loaders/to.$id.register.server.ts index 805f3b865..c843b0201 100644 --- a/app/features/tournament/loaders/to.$id.register.server.ts +++ b/app/features/tournament/loaders/to.$id.register.server.ts @@ -1,11 +1,17 @@ import type { LoaderFunctionArgs } from "react-router"; +import * as R from "remeda"; +import * as RegistrationAvailability from "~/features/availability/core/RegistrationAvailability.server"; import * as SQGroupRepository from "~/features/sendouq/SQGroupRepository.server"; import * as TeamRepository from "~/features/team/TeamRepository.server"; +import { getViewerTimezone } from "~/features/timezone/timezone-context.server"; import * as SavedCalendarEventRepository from "~/features/tournament/SavedCalendarEventRepository.server"; +import type { Tournament } from "~/features/tournament-bracket/core/Tournament"; import { tournamentFromParams, tournamentTeamsFullCached, } from "~/features/tournament-bracket/core/Tournament.server"; +import { dateToDatabaseTimestamp } from "~/utils/dates"; +import { logger } from "~/utils/logger"; export const loader = async ({ params }: LoaderFunctionArgs) => { const { tournament, tournamentId, user } = await tournamentFromParams( @@ -15,13 +21,28 @@ export const loader = async ({ params }: LoaderFunctionArgs) => { if (!user) return null; const teamMemberOf = tournament.teamMemberOfByUser(user); + const friendPlayers = await SQGroupRepository.findFriendsAndTeammates( + user.id, + ); + const [availability, teams] = await Promise.all([ + rosterAvailability({ + tournament, + userId: user.id, + friendIds: friendPlayers.friends.map((friend) => friend.id), + })?.catch((error) => { + logger.error("Failed to resolve registration availability", error); + return null; + }) ?? null, + TeamRepository.findAllMemberOfByUserId(user.id), + ]); if (!teamMemberOf) { return { ownTeam: null, mapPool: null, - friendPlayers: null, - teams: await TeamRepository.findAllMemberOfByUserId(user.id), + friendPlayers, + availability, + teams, isSaved: await SavedCalendarEventRepository.isSaved({ userId: user.id, tournamentId, @@ -37,10 +58,42 @@ export const loader = async ({ params }: LoaderFunctionArgs) => { return { ownTeam, mapPool: ownTeam?.mapPool ?? null, - friendPlayers: await SQGroupRepository.findFriendsAndTeammates(user.id), - teams: await TeamRepository.findAllMemberOfByUserId(user.id), + friendPlayers, + availability, + teams, isSaved: false, }; }; +function rosterAvailability({ + tournament, + userId, + friendIds, +}: { + tournament: Tournament; + userId: number; + friendIds: Array; +}) { + if (tournament.isLeague) return null; + + const startsAt = dateToDatabaseTimestamp(tournament.ctx.startsAt); + if (tournament.ctx.startsAt <= new Date()) return null; + + return RegistrationAvailability.registrationAvailability({ + tournament: { + id: tournament.ctx.id, + name: tournament.ctx.name, + organizationId: tournament.ctx.organization?.id ?? null, + startsAt, + minMembersPerTeam: tournament.minMembersPerTeam, + bracketTypes: tournament.ctx.settings.bracketProgression.map( + (bracket) => bracket.type, + ), + teamCount: tournament.ctx.teams.length, + }, + userIds: R.unique([userId, ...friendIds]), + timezone: getViewerTimezone() ?? "UTC", + }); +} + export type TournamentRegisterPageLoader = typeof loader; diff --git a/app/features/tournament/routes/to.$id.info.tsx b/app/features/tournament/routes/to.$id.info.tsx index f76220154..0e604744e 100644 --- a/app/features/tournament/routes/to.$id.info.tsx +++ b/app/features/tournament/routes/to.$id.info.tsx @@ -58,7 +58,7 @@ export default function TournamentInfoPage() { return (
    - +
    li { + padding-block: var(--s-2); + + & + li { + border-top: 1px solid var(--color-border); + } + } +} + +.emptySlotRow { + display: flex; + align-items: center; + gap: var(--s-1-5); + font-size: var(--font-xs); + font-weight: var(--weight-semi); color: var(--color-text-accent); +} + +.emptySlotRowOptional { + color: var(--color-text-high); +} + +.emptySlotCircle { + width: 24px; + height: 24px; + flex-shrink: 0; display: grid; place-items: center; - margin: 0 auto; + border-radius: var(--radius-full); + border: var(--border-style-accent); + color: var(--color-text-accent); } -.missingPlayerOptional { - border: 2px dashed var(--color-text-accent); - color: var(--color-text-accent); +.emptySlotCircleOptional { + border: var(--border-width) dashed var(--color-text-accent); +} + +.addMembers { + border-top: 1px solid var(--color-border); + padding-top: var(--s-3); +} + +.addMembersHeading { + font-size: var(--font-xs); + color: var(--color-text-high); + text-transform: uppercase; + letter-spacing: 0.5px; +} + +.quickAddRow { + display: flex; + align-items: flex-end; + gap: var(--s-2); +} + +.quickAddSelect { + flex: 1; + min-width: 0; +} + +.quickAddAllRow { + display: flex; + flex-wrap: wrap; + gap: var(--s-1-5); +} + +.quickAddItem { + display: flex; + flex-direction: column; + gap: var(--s-0-5); + min-width: 0; +} + +.quickAddItemAvailability { + display: inline-flex; + align-items: center; + gap: var(--s-1-5); + font-size: var(--font-xs); + font-weight: var(--weight-body); } @container (width >= 640px) { .section { margin: 0; border-radius: var(--radius-box); + padding: var(--s-6) var(--s-5); } } diff --git a/app/features/tournament/routes/to.$id.register.tsx b/app/features/tournament/routes/to.$id.register.tsx index 90a50156e..4051391f5 100644 --- a/app/features/tournament/routes/to.$id.register.tsx +++ b/app/features/tournament/routes/to.$id.register.tsx @@ -1,21 +1,43 @@ import clsx from "clsx"; -import { AlertCircle, Check, Clipboard, X } from "lucide-react"; +import { AlertCircle, Check, UserRound, UsersRound, X } from "lucide-react"; import * as React from "react"; +import { Text } from "react-aria-components"; import { useTranslation } from "react-i18next"; import { useFetcher, useLoaderData } from "react-router"; +import * as R from "remeda"; import { ActionButton } from "~/components/ActionButton"; import { Alert } from "~/components/Alert"; -import { Avatar } from "~/components/Avatar"; -import { Divider } from "~/components/Divider"; import { LinkButton, SendouButton } from "~/components/elements/Button"; import { SendouPopover } from "~/components/elements/Popover"; +import { + SendouSelect, + SendouSelectItem, + SendouSelectItemSection, +} from "~/components/elements/Select"; import { FormWithConfirm } from "~/components/FormWithConfirm"; import { FriendCodePopover } from "~/components/FriendCodePopover"; -import { Label } from "~/components/Label"; +import { InviteLinkInput } from "~/components/InviteLinkInput"; import { containerClassName } from "~/components/Main"; import { SubmitButton } from "~/components/SubmitButton"; import { Config } from "~/config"; import { useUser } from "~/features/auth/core/user"; +import { + AvailabilityMemberRow, + type AvailabilityPanelEntry, + AvailabilityRowDetail, + type AvailabilityRowStatus, + AvailabilityStatusDots, + AvailabilitySummary, + AvailabilityWindowText, + availabilityRowStatus, + RegistrationAvailabilityPanel, +} from "~/features/availability/components/RegistrationAvailabilityPanel"; +import type { + MemberRole, + MemberRoleType, +} from "~/features/team/team-constants"; +import { getMemberRoleType } from "~/features/team/team-utils"; +import { timezoneMiddleware } from "~/features/timezone/timezone-middleware.server"; import { type CounterPickMapPool, CounterPickMapPoolPicker, @@ -30,8 +52,8 @@ import { FormField } from "~/form/FormField"; import { SendouForm, useFormFieldContext } from "~/form/SendouForm"; import { useDateTimeFormat } from "~/hooks/intl/useDateTimeFormat"; import { useAutoRerender } from "~/hooks/useAutoRerender"; -import { useCopyToClipboard } from "~/hooks/useCopyToClipboard"; import { useHydrated } from "~/hooks/useHydrated"; +import type { SendouRouteHandle } from "~/utils/remix.server"; import { LOG_IN_URL, SENDOU_INK_BASE_URL, @@ -46,14 +68,38 @@ import { } from "../tournament-register-schemas"; import { addPlayerSchema, + addTeamPlayersSchema, checkInSchema, - deleteTeamMemberSchema, updateMapPoolSchema, } from "../tournament-schemas"; +import type { Route } from "./+types/to.$id.register"; import styles from "./to.$id.register.module.css"; export { action, loader }; +const QUICK_ADD_STATUS_ORDER: Record = { + available: 0, + partial: 1, + unknown: 2, + hidden: 3, + busy: 4, + unavailable: 5, +}; + +interface QuickAddPlayer { + id: number; + username: string; + teamId: number | null; + role: MemberRole | null; + roleType: MemberRoleType | null; +} + +export const middleware: Route.MiddlewareFunction[] = [timezoneMiddleware]; + +export const handle: SendouRouteHandle = { + i18n: ["schedule"], +}; + export default function TournamentRegisterPage() { const user = useUser(); const tournament = useTournament(); @@ -531,7 +577,7 @@ function TeamInfo({ -
    - -
    -
    - -
    + + ); @@ -564,30 +606,42 @@ function RegisterTeamFields({ readOnly = false }: { readOnly?: boolean }) { const isLinked = Boolean(values.teamId); - const teamOptions = (data?.teams ?? []).map((team) => ({ - value: String(team.id), - label: team.name, - })); + const entryByUserId = availabilityEntryByUserId(data); + + const teamOptions = (data?.teams ?? []).map((team) => { + const statuses = ( + entryByUserId + ? teamMemberStatuses({ data, teamId: team.id, entryByUserId }) + : [] + ).filter((status) => status === "available" || status === "partial"); + + return { + value: String(team.id), + label: team.name, + description: + statuses.length > 0 ? ( + + + + + ) : undefined, + }; + }); const showTeamSelect = teamOptions.length > 0 && tournament.registrationOpen; return ( <> {showTeamSelect ? ( -
    - -
    + ) : null} + {!data?.ownTeam ? : null} {!isLinked ? ( <> -
    - -
    -
    - -
    + + ) : null} @@ -653,8 +707,11 @@ function FillRoster({ }) { const data = useLoaderData(); const tournament = useTournament(); - const { copyToClipboard, copySuccess } = useCopyToClipboard(); - const { t } = useTranslation(["common", "tournament"]); + const { t } = useTranslation(["common", "tournament", "schedule"]); + const { formatter: dateFormatter } = useDateTimeFormat({ + month: "long", + day: "numeric", + }); const inviteLink = `${SENDOU_INK_BASE_URL}${tournamentJoinPage({ tournamentId: tournament.ctx.id, @@ -673,14 +730,14 @@ function FillRoster({ 0, ); - const showDeleteMemberSection = + const canRemoveMembers = !readOnly && !tournament.isInvitational && ((!ownTeamCheckedIn && ownTeamMembers.length > 1) || (ownTeamCheckedIn && ownTeamMembers.length > tournament.minMembersPerTeam)); - const playersAvailableToDirectlyAdd = (() => { + const quickAddPlayers = (() => { if (readOnly) return []; return (data?.friendPlayers?.friends ?? []).filter((user) => { const isNotInTeam = tournament.ctx.teams.every( @@ -697,89 +754,107 @@ function FillRoster({ const teamIsFull = ownTeamMembers.length >= tournament.maxMembersPerTeam; const canAddMembers = !teamIsFull && tournament.registrationOpen && !readOnly; + const availability = data?.availability; + const entryByUserId = availabilityEntryByUserId(data); + const requireInGameNames = tournament.ctx.settings.requireInGameNames; + return (
    -

    - 2. {t("tournament:pre.roster.header")} -

    -
    - {playersAvailableToDirectlyAdd.length > 0 && canAddMembers ? ( - <> - - {t("common:or")} - +
    +

    + 2. {t("tournament:pre.roster.header")} +

    + {availability?.window ? ( + ) : null} - {canAddMembers ? ( -
    -
    - {t("tournament:actions.shareLink", { inviteLink })} -
    -
    - : } - onPress={() => copyToClipboard(inviteLink)} - variant="outlined" - > - {t("common:actions.copyToClipboard")} - -
    +
    +
    + {availability?.beyondHorizon ? ( +
    + {t("schedule:registration.beyondHorizon", { + date: dateFormatter.format(availability.beyondHorizon.opensAt), + })}
    ) : null} -
    - {ownTeamMembers.map((member, i) => { - return ( -
    - - {tournament.ctx.settings.requireInGameNames ? ( -
    -
    - {member.inGameName ?? member.username} -
    - {member.inGameName ? ( -
    - {member.username} -
    - ) : null} -
    - ) : ( -
    - {member.username} -
    - )} -
    - ); - })} - {new Array(missingMembers).fill(null).map((_, i) => { - return ( -
    - ? -
    - ); - })} - {new Array(optionalMembers).fill(null).map((_, i) => { - return ( -
    + {ownTeamMembers.map((member, i) => ( + + ) : null + } + /> + ))} + {Array.from({ length: missingMembers }).map((_, i) => ( +
  • + + + + {t("tournament:pre.roster.emptySlot")} +
  • + ))} + {Array.from({ length: optionalMembers }).map((_, i) => ( +
  • + - ? -
  • - ); - })} -
    - {showDeleteMemberSection ? ( - + + + {t("tournament:pre.roster.emptySlot.optional")} + + ))} + + {entryByUserId ? ( + + availabilityRowStatus(entryByUserId.get(member.userId)), + )} + /> + ) : null} + {canAddMembers ? ( +
    +

    + {t("tournament:pre.roster.addMembers")} +

    + {quickAddPlayers.length > 0 ? ( + player.id).join(",")} + players={quickAddPlayers} + teams={data?.friendPlayers?.teams ?? []} + spotsLeft={tournament.maxMembersPerTeam - ownTeamMembers.length} + entryByUserId={entryByUserId} + /> + ) : null} + +
    ) : null}
    {tournament.ctx.settings.requireInGameNames ? ( @@ -802,111 +877,207 @@ function FillRoster({ ); } -function DirectlyAddPlayerSelect({ +function QuickAddPlayers({ players, teams, + spotsLeft, + entryByUserId, }: { - players: { id: number; username: string; teamId?: number }[]; - teams: { id: number; name: string }[]; + players: Array; + teams: Array<{ id: number; name: string }>; + spotsLeft: number; + entryByUserId: Map | null; }) { const { t } = useTranslation(["tournament", "common"]); const fetcher = useFetcher(); - const id = React.useId(); - const othersOptions = players - .filter((player) => !player.teamId) - .map((player) => { - return ( - - ); - }); + const sortByAvailability = (toSort: Array) => + entryByUserId + ? R.sortBy( + toSort, + (player) => + QUICK_ADD_STATUS_ORDER[ + availabilityRowStatus(entryByUserId.get(player.id)) + ], + ) + : toSort; + + const uniquePlayers = R.uniqueBy(players, (player) => player.id); + + const teamGroups = teams + .map((team) => ({ + team, + players: sortByAvailability( + uniquePlayers.filter((player) => player.teamId === team.id), + ), + })) + .filter((group) => group.players.length > 0); + + const pickupPlayers = sortByAvailability( + uniquePlayers.filter((player) => !player.teamId), + ); + + const sections = [ + ...teamGroups.map((group) => ({ + key: `team-${group.team.id}`, + heading: group.team.name, + players: group.players, + })), + ...(pickupPlayers.length > 0 + ? [ + { + key: "pickup", + heading: t("tournament:pre.roster.quickAdd.pickup"), + players: pickupPlayers, + }, + ] + : []), + ]; + + const [selectedUserId, setSelectedUserId] = React.useState( + sections[0]?.players[0]?.id ?? null, + ); + + const addAllByTeam = teams + .map((team) => ({ + team, + // in the loader's order so the list matches what the action adds when clamped + playersToAdd: players + .filter( + (player) => + player.teamId === team.id && getMemberRoleType(player) !== "OTHER", + ) + .slice(0, spotsLeft), + })) + .filter((entry) => entry.playersToAdd.length > 0); + + const renderPlayerItem = (player: QuickAddPlayer) => ( + + {entryByUserId ? ( + + {player.username} + + + + + + + + ) : ( + player.username + )} + + ); return ( - -
    - - -
    - - {t("common:actions.add")} - -
    +
    + +
    + setSelectedUserId(key as number | null)} + estimatedRowHeight={entryByUserId ? 52 : undefined} + className={styles.quickAddSelect} + data-testid="quick-add-select" + > + {(section) => ( + + {section.players.map(renderPlayerItem)} + + )} + + {selectedUserId ? ( + + ) : null} + + {t("common:actions.add")} + +
    +
    + {addAllByTeam.length > 0 ? ( +
    + {addAllByTeam.map(({ team, playersToAdd }) => ( + } + testId={`add-team-players-button-${team.id}`} + confirm={{ + dialogHeading: t( + "tournament:pre.roster.quickAdd.addAll.confirm", + { team: team.name }, + ), + description: playersToAdd + .map((player) => player.username) + .join(", "), + submitButtonText: t("common:actions.add"), + submitButtonVariant: "primary", + }} + > + {t("tournament:pre.roster.quickAdd.addAll", { + team: team.name, + })} + + ))} +
    + ) : null} +
    ); } -function DeleteMember({ members }: { members: TournamentTeamFull["members"] }) { +function RemoveMemberButton({ + member, +}: { + member: TournamentTeamFull["members"][number]; +}) { const { t } = useTranslation(["tournament", "common"]); - const id = React.useId(); - const fetcher = useFetcher(); - const [expanded, setExpanded] = React.useState(false); - if (!expanded) { - return ( + return ( + setExpanded(true)} - > - {t("tournament:pre.roster.delete.button")} - - ); - } - - return ( - - -
    - - - {t("common:actions.delete")} - -
    -
    + icon={} + aria-label={t("common:actions.remove")} + testId={`remove-member-${member.userId}`} + /> +
    ); } @@ -961,3 +1132,96 @@ function TeamCounterPickMapPoolPicker({
    ); } + +function SelectedTeamAvailability() { + const data = useLoaderData(); + const tournament = useTournament(); + const { values } = useFormFieldContext(); + + const availability = data?.availability; + if (!availability) return null; + + const teamId = values.teamId ? Number(values.teamId) : null; + + const inTournament = (userId: number) => + tournament.ctx.teams.some((team) => team.memberUserIds.includes(userId)); + + const entryByUserId = availabilityEntryByUserId(data); + const isFree = (userId: number) => { + const status = availabilityRowStatus(entryByUserId?.get(userId)); + return status === "available" || status === "partial"; + }; + + // with a team selected the panel shows its full roster, every status + // included; signing up as a pickup it instead lists everyone the viewer + // could recruit (all their teams' members and friends) in one list, kept + // to those actually free during the event + const roster = teamId + ? (data?.friendPlayers?.friends ?? []).filter( + (friend) => friend.teamId === teamId, + ) + : R.uniqueBy( + data?.friendPlayers?.friends ?? [], + (friend) => friend.id, + ).filter((friend) => !inTournament(friend.id) && isFree(friend.id)); + if (roster.length === 0 && !availability.beyondHorizon) return null; + + return ( + rosterUser.id), + }) + : [] + } + /> + ); +} + +function availabilityEntryByUserId( + data: ReturnType>, +) { + const availability = data?.availability; + if (!availability || availability.beyondHorizon) return null; + + return new Map(availability.entries.map((entry) => [entry.userId, entry])); +} + +function teamMemberStatuses({ + data, + teamId, + entryByUserId, +}: { + data: ReturnType>; + teamId: number; + entryByUserId: Map; +}): Array { + return (data?.friendPlayers?.friends ?? []) + .filter((friend) => friend.teamId === teamId) + .map((friend) => availabilityRowStatus(entryByUserId.get(friend.id))); +} + +function subCandidates({ + data, + tournament, + rosterUserIds, +}: { + data: ReturnType>; + tournament: ReturnType; + rosterUserIds: number[]; +}) { + const inTournament = (userId: number) => + tournament.ctx.teams.some((team) => team.memberUserIds.includes(userId)); + + return R.uniqueBy( + data?.friendPlayers?.friends ?? [], + (friend) => friend.id, + ).filter( + (friend) => !rosterUserIds.includes(friend.id) && !inTournament(friend.id), + ); +} diff --git a/app/features/tournament/tournament-schemas.server.ts b/app/features/tournament/tournament-schemas.server.ts index 37fe4060c..05cc7f81e 100644 --- a/app/features/tournament/tournament-schemas.server.ts +++ b/app/features/tournament/tournament-schemas.server.ts @@ -4,6 +4,7 @@ import { _action } from "~/utils/schema"; import { registerTeamFormSchemaServer } from "./tournament-register-schemas.server"; import { addPlayerSchema, + addTeamPlayersSchema, checkInSchema, deleteTeamMemberSchema, updateMapPoolSchema, @@ -25,6 +26,7 @@ export function registerSchema({ }), checkInSchema, addPlayerSchema, + addTeamPlayersSchema, v.object({ _action: _action("UNREGISTER"), }), diff --git a/app/features/tournament/tournament-schemas.ts b/app/features/tournament/tournament-schemas.ts index 11ea658bc..5f608842c 100644 --- a/app/features/tournament/tournament-schemas.ts +++ b/app/features/tournament/tournament-schemas.ts @@ -25,6 +25,11 @@ export const addPlayerSchema = v.object({ userId: id, }); +export const addTeamPlayersSchema = v.object({ + _action: _action("ADD_TEAM_PLAYERS"), + teamId: id, +}); + export const deleteTeamMemberSchema = v.object({ _action: _action("DELETE_TEAM_MEMBER"), userId: id, diff --git a/app/features/tournament/tournament-utils.server.ts b/app/features/tournament/tournament-utils.server.ts index 6c6d5a2c6..df745fad2 100644 --- a/app/features/tournament/tournament-utils.server.ts +++ b/app/features/tournament/tournament-utils.server.ts @@ -15,19 +15,27 @@ export async function requireNotBannedByOrganization({ user: { id: number }; message?: string; }) { - if (!tournament.ctx.organization) return; - - const isBanned = - await TournamentOrganizationRepository.isUserBannedByOrganization({ - organizationId: tournament.ctx.organization.id, - userId: user.id, - }); - - if (isBanned) { + if (await isBannedByOrganization({ tournament, userId: user.id })) { errorToast(message); } } +/** Whether the user is banned by the organization hosting the tournament (`false` if the tournament has no organization). */ +export async function isBannedByOrganization({ + tournament, + userId, +}: { + tournament: Tournament; + userId: number; +}) { + if (!tournament.ctx.organization) return false; + + return TournamentOrganizationRepository.isUserBannedByOrganization({ + organizationId: tournament.ctx.organization.id, + userId, + }); +} + /** * Whether the given team name is already used by another team in the tournament. * Single source of truth for the uniqueness rule shared by the player registration @@ -57,17 +65,25 @@ export async function requireSendouQParticipationIfNeeded({ tournament: Tournament; userId: number; }) { - if (!tournament.ctx.settings.requireSendouQParticipation) return; - - const hasEnough = - await LeaderboardRepository.hasEnoughSqMatchesByUserId(userId); - errorToastIfFalsy( - hasEnough, + await fulfillsSendouQParticipation({ tournament, userId }), `Must have played ${MATCHES_COUNT_NEEDED_FOR_LEADERBOARD} SendouQ matches this season to join`, ); } +/** Whether the user fulfills the tournament's SendouQ participation requirement (`true` if the tournament has none). */ +export async function fulfillsSendouQParticipation({ + tournament, + userId, +}: { + tournament: Tournament; + userId: number; +}) { + if (!tournament.ctx.settings.requireSendouQParticipation) return true; + + return LeaderboardRepository.hasEnoughSqMatchesByUserId(userId); +} + /** * Ends all unfinished matches involving dropped teams by awarding wins to their opponents. * If both teams in a match have dropped, a random winner is selected. Pure over the given diff --git a/app/form/FormField.tsx b/app/form/FormField.tsx index f72b7c1eb..437b96148 100644 --- a/app/form/FormField.tsx +++ b/app/form/FormField.tsx @@ -289,6 +289,7 @@ export function FormField({ items={selectOptions.map((opt) => ({ value: opt.value, label: opt.label, + description: opt.description, }))} value={value as string | null} onChange={handleChange as (v: string | null) => void} diff --git a/app/form/UnsavedChangesGuard.tsx b/app/form/UnsavedChangesGuard.tsx index b6583b8ad..d923a95b3 100644 --- a/app/form/UnsavedChangesGuard.tsx +++ b/app/form/UnsavedChangesGuard.tsx @@ -1,10 +1,21 @@ import * as React from "react"; import { useTranslation } from "react-i18next"; -import { useBlocker } from "react-router"; +import { type Location, useBlocker } from "react-router"; import { SendouButton } from "~/components/elements/Button"; import { SendouDialog } from "~/components/elements/Dialog"; -const dirtyCheckers = new Set<() => boolean>(); +/** + * Reports whether the registering component has unsaved changes. For an in-app + * navigation the blocked locations are passed, so a checker whose state + * survives same-route navigations can ignore those; a full page unload passes + * nothing and every dirty checker should warn. + */ +type UnsavedChangesChecker = (navigation?: { + currentLocation: Location; + nextLocation: Location; +}) => boolean; + +const dirtyCheckers = new Set(); /** * Confirms navigating away when any mounted form has unsaved changes. @@ -19,7 +30,7 @@ export function UnsavedChangesGuard() { ({ currentLocation, nextLocation }) => (currentLocation.pathname !== nextLocation.pathname || currentLocation.search !== nextLocation.search) && - hasUnsavedChanges(), + hasUnsavedChanges({ currentLocation, nextLocation }), ); React.useEffect(() => { @@ -65,10 +76,11 @@ export function UnsavedChangesGuard() { * form state without re-registering on every render. */ export function useUnsavedChangesChecker( - checkerRef: React.RefObject<() => boolean>, + checkerRef: React.RefObject, ) { React.useEffect(() => { - const checker = () => checkerRef.current(); + const checker: UnsavedChangesChecker = (navigation) => + checkerRef.current(navigation); dirtyCheckers.add(checker); return () => { dirtyCheckers.delete(checker); @@ -76,9 +88,9 @@ export function useUnsavedChangesChecker( }, [checkerRef]); } -function hasUnsavedChanges() { +function hasUnsavedChanges(navigation?: Parameters[0]) { for (const checker of dirtyCheckers) { - if (checker()) return true; + if (checker(navigation)) return true; } return false; } diff --git a/app/form/fields/SelectFormField.module.css b/app/form/fields/SelectFormField.module.css index 1cde6010b..12e39f777 100644 --- a/app/form/fields/SelectFormField.module.css +++ b/app/form/fields/SelectFormField.module.css @@ -1,3 +1,18 @@ .searchable { --select-width: 100%; } + +.twoLineItem { + display: flex; + flex-direction: column; + gap: var(--s-0-5); + min-width: 0; +} + +.itemDescription { + font-size: var(--font-xs); + font-weight: var(--weight-body); + color: var(--color-text-high); + overflow: hidden; + text-overflow: ellipsis; +} diff --git a/app/form/fields/SelectFormField.tsx b/app/form/fields/SelectFormField.tsx index 035c2815a..1ce504c65 100644 --- a/app/form/fields/SelectFormField.tsx +++ b/app/form/fields/SelectFormField.tsx @@ -1,4 +1,5 @@ import * as React from "react"; +import { Text } from "react-aria-components"; import { useTranslation } from "react-i18next"; import { SendouSelect, SendouSelectItem } from "~/components/elements/Select"; import type { FormFieldItems, FormFieldProps } from "../types"; @@ -10,6 +11,8 @@ import { } from "./FormFieldWrapper"; import styles from "./SelectFormField.module.css"; +const TWO_LINE_ROW_HEIGHT = 52; + type SelectFormFieldProps = Omit< FormFieldProps<"select">, "items" | "clearable" | "onBlur" | "name" | "searchable" @@ -56,12 +59,17 @@ export function SelectFormField({ return { value: item.value, resolvedLabel, + description: item.description, }; }); - if (searchable) { + const hasDescriptions = itemsWithResolvedLabels.some( + (item) => item.description, + ); + + if (searchable || hasDescriptions) { return ( - ({ onBlur={onBlur} clearable={clearable} disabled={disabled} - searchPlaceholder={t("common:actions.search")} + searchPlaceholder={searchable ? t("common:actions.search") : undefined} /> ); } @@ -113,7 +121,7 @@ export function SelectFormField({ ); } -function SearchableSelect({ +function CustomSelect({ name, label, bottomText, @@ -130,39 +138,68 @@ function SearchableSelect({ label?: string; bottomText?: string; error?: string; - items: Array<{ value: V; resolvedLabel: string }>; + items: Array<{ + value: V; + resolvedLabel: string; + description?: React.ReactNode; + }>; value: V | null; onChange: (value: V | null) => void; onBlur?: () => void; clearable?: boolean; disabled?: boolean; - searchPlaceholder: string; + searchPlaceholder?: string; }) { const { translatedLabel } = useTranslatedTexts({ label }); - const selectItems = items.map((item) => ({ - id: item.value, - textValue: item.resolvedLabel, - })); + const hasDescriptions = items.some((item) => item.description); + + // the Autocomplete wrapper of searchable selects drops falsy keys, so only + // plain selects render the clear choice as a list item like the native + // select's "—" option; searchable ones keep the clear button + const hasEmptyItem = Boolean(clearable && !searchPlaceholder); + + const selectItems = [ + ...(hasEmptyItem + ? [{ id: "", textValue: "—", description: undefined }] + : []), + ...items.map((item) => ({ + id: item.value as string, + textValue: item.resolvedLabel, + description: item.description, + })), + ]; return (
    { const newValue = key === "" ? null : (key as V); onChange(newValue); onBlur?.(); }} items={selectItems} - search={{ placeholder: searchPlaceholder }} - clearable={clearable} + search={ + searchPlaceholder ? { placeholder: searchPlaceholder } : undefined + } + clearable={clearable && !hasEmptyItem} isDisabled={disabled} + estimatedRowHeight={hasDescriptions ? TWO_LINE_ROW_HEIGHT : undefined} > {(item) => ( - {item.textValue} + {item.description ? ( + + {item.textValue} + + {item.description} + + + ) : ( + item.textValue + )} )} diff --git a/app/form/types.ts b/app/form/types.ts index de732c9fa..e4e01eef8 100644 --- a/app/form/types.ts +++ b/app/form/types.ts @@ -1,3 +1,4 @@ +import type * as React from "react"; import type * as v from "valibot"; import type { TeamSearchResult } from "~/components/elements/TeamSearch"; import type { TournamentSearchItem } from "~/components/elements/TournamentSearch"; @@ -59,6 +60,8 @@ interface FormFieldInGameName extends FormFieldBase { interface FormFieldItem { label: string | number | ((lang: string) => string); value: V; + /** Second line rendered under the label in the dropdown. Any item having one switches the field to the custom select. */ + description?: React.ReactNode; } interface FormFieldItemWithImage extends FormFieldItem { @@ -251,6 +254,8 @@ export type TrophyOption = { export type SelectOption = { value: string; label: string; + /** Second line rendered under the label in the dropdown. Any option having one switches the field to the custom select. */ + description?: React.ReactNode; }; /** Brand type to encode required options directly in schema types */ diff --git a/app/modules/i18n/resources.browser.ts b/app/modules/i18n/resources.browser.ts index a62c5f313..67165ace4 100644 --- a/app/modules/i18n/resources.browser.ts +++ b/app/modules/i18n/resources.browser.ts @@ -15,6 +15,7 @@ import lfg from "../../../locales/en/lfg.json"; import org from "../../../locales/en/org.json"; import params from "../../../locales/en/params.json"; import q from "../../../locales/en/q.json"; +import schedule from "../../../locales/en/schedule.json"; import scrims from "../../../locales/en/scrims.json"; import settings from "../../../locales/en/settings.json"; import team from "../../../locales/en/team.json"; @@ -44,6 +45,7 @@ export const resources = { org, params, q, + schedule, scrims, settings, team, diff --git a/app/modules/i18n/resources.server.ts b/app/modules/i18n/resources.server.ts index faf9546f5..ee6a938c1 100644 --- a/app/modules/i18n/resources.server.ts +++ b/app/modules/i18n/resources.server.ts @@ -16,6 +16,7 @@ import lfgDa from "../../../locales/da/lfg.json"; import orgDa from "../../../locales/da/org.json"; import paramsDa from "../../../locales/da/params.json"; import qDa from "../../../locales/da/q.json"; +import scheduleDa from "../../../locales/da/schedule.json"; import scrimsDa from "../../../locales/da/scrims.json"; import settingsDa from "../../../locales/da/settings.json"; import teamDa from "../../../locales/da/team.json"; @@ -44,6 +45,7 @@ import lfgDe from "../../../locales/de/lfg.json"; import orgDe from "../../../locales/de/org.json"; import paramsDe from "../../../locales/de/params.json"; import qDe from "../../../locales/de/q.json"; +import scheduleDe from "../../../locales/de/schedule.json"; import scrimsDe from "../../../locales/de/scrims.json"; import settingsDe from "../../../locales/de/settings.json"; import teamDe from "../../../locales/de/team.json"; @@ -72,6 +74,7 @@ import lfg from "../../../locales/en/lfg.json"; import org from "../../../locales/en/org.json"; import params from "../../../locales/en/params.json"; import q from "../../../locales/en/q.json"; +import scheduleEn from "../../../locales/en/schedule.json"; import scrimsEn from "../../../locales/en/scrims.json"; import settings from "../../../locales/en/settings.json"; import team from "../../../locales/en/team.json"; @@ -100,6 +103,7 @@ import lfgEsEs from "../../../locales/es-ES/lfg.json"; import orgEsEs from "../../../locales/es-ES/org.json"; import paramsEsEs from "../../../locales/es-ES/params.json"; import qEsEs from "../../../locales/es-ES/q.json"; +import scheduleEsEs from "../../../locales/es-ES/schedule.json"; import scrimsEsEs from "../../../locales/es-ES/scrims.json"; import settingsEsEs from "../../../locales/es-ES/settings.json"; import teamEsEs from "../../../locales/es-ES/team.json"; @@ -128,6 +132,7 @@ import lfgEsUs from "../../../locales/es-US/lfg.json"; import orgEsUs from "../../../locales/es-US/org.json"; import paramsEsUs from "../../../locales/es-US/params.json"; import qEsUs from "../../../locales/es-US/q.json"; +import scheduleEsUs from "../../../locales/es-US/schedule.json"; import scrimsEsUs from "../../../locales/es-US/scrims.json"; import settingsEsUs from "../../../locales/es-US/settings.json"; import teamEsUs from "../../../locales/es-US/team.json"; @@ -156,6 +161,7 @@ import lfgFrCa from "../../../locales/fr-CA/lfg.json"; import orgFrCa from "../../../locales/fr-CA/org.json"; import paramsFrCa from "../../../locales/fr-CA/params.json"; import qFrCa from "../../../locales/fr-CA/q.json"; +import scheduleFrCa from "../../../locales/fr-CA/schedule.json"; import scrimsFrCa from "../../../locales/fr-CA/scrims.json"; import settingsFrCa from "../../../locales/fr-CA/settings.json"; import teamFrCa from "../../../locales/fr-CA/team.json"; @@ -184,6 +190,7 @@ import lfgFrEu from "../../../locales/fr-EU/lfg.json"; import orgFrEu from "../../../locales/fr-EU/org.json"; import paramsFrEu from "../../../locales/fr-EU/params.json"; import qFrEu from "../../../locales/fr-EU/q.json"; +import scheduleFrEu from "../../../locales/fr-EU/schedule.json"; import scrimsFrEu from "../../../locales/fr-EU/scrims.json"; import settingsFrEu from "../../../locales/fr-EU/settings.json"; import teamFrEu from "../../../locales/fr-EU/team.json"; @@ -212,6 +219,7 @@ import lfgHe from "../../../locales/he/lfg.json"; import orgHe from "../../../locales/he/org.json"; import paramsHe from "../../../locales/he/params.json"; import qHe from "../../../locales/he/q.json"; +import scheduleHe from "../../../locales/he/schedule.json"; import scrimsHe from "../../../locales/he/scrims.json"; import settingsHe from "../../../locales/he/settings.json"; import teamHe from "../../../locales/he/team.json"; @@ -240,6 +248,7 @@ import lfgIt from "../../../locales/it/lfg.json"; import orgIt from "../../../locales/it/org.json"; import paramsIt from "../../../locales/it/params.json"; import qIt from "../../../locales/it/q.json"; +import scheduleIt from "../../../locales/it/schedule.json"; import scrimsIt from "../../../locales/it/scrims.json"; import settingsIt from "../../../locales/it/settings.json"; import teamIt from "../../../locales/it/team.json"; @@ -268,6 +277,7 @@ import lfgJa from "../../../locales/ja/lfg.json"; import orgJa from "../../../locales/ja/org.json"; import paramsJa from "../../../locales/ja/params.json"; import qJa from "../../../locales/ja/q.json"; +import scheduleJa from "../../../locales/ja/schedule.json"; import scrimsJa from "../../../locales/ja/scrims.json"; import settingsJa from "../../../locales/ja/settings.json"; import teamJa from "../../../locales/ja/team.json"; @@ -296,6 +306,7 @@ import lfgKo from "../../../locales/ko/lfg.json"; import orgKo from "../../../locales/ko/org.json"; import paramsKo from "../../../locales/ko/params.json"; import qKo from "../../../locales/ko/q.json"; +import scheduleKo from "../../../locales/ko/schedule.json"; import scrimsKo from "../../../locales/ko/scrims.json"; import settingsKo from "../../../locales/ko/settings.json"; import teamKo from "../../../locales/ko/team.json"; @@ -324,6 +335,7 @@ import lfgNl from "../../../locales/nl/lfg.json"; import orgNl from "../../../locales/nl/org.json"; import paramsNl from "../../../locales/nl/params.json"; import qNl from "../../../locales/nl/q.json"; +import scheduleNl from "../../../locales/nl/schedule.json"; import scrimsNl from "../../../locales/nl/scrims.json"; import settingsNl from "../../../locales/nl/settings.json"; import teamNl from "../../../locales/nl/team.json"; @@ -352,6 +364,7 @@ import lfgPl from "../../../locales/pl/lfg.json"; import orgPl from "../../../locales/pl/org.json"; import paramsPl from "../../../locales/pl/params.json"; import qPl from "../../../locales/pl/q.json"; +import schedulePl from "../../../locales/pl/schedule.json"; import scrimsPl from "../../../locales/pl/scrims.json"; import settingsPl from "../../../locales/pl/settings.json"; import teamPl from "../../../locales/pl/team.json"; @@ -380,6 +393,7 @@ import lfgPtBr from "../../../locales/pt-BR/lfg.json"; import orgPtBr from "../../../locales/pt-BR/org.json"; import paramsPtBr from "../../../locales/pt-BR/params.json"; import qPtBr from "../../../locales/pt-BR/q.json"; +import schedulePtBr from "../../../locales/pt-BR/schedule.json"; import scrimsPtBr from "../../../locales/pt-BR/scrims.json"; import settingsPtBr from "../../../locales/pt-BR/settings.json"; import teamPtBr from "../../../locales/pt-BR/team.json"; @@ -408,6 +422,7 @@ import lfgRu from "../../../locales/ru/lfg.json"; import orgRu from "../../../locales/ru/org.json"; import paramsRu from "../../../locales/ru/params.json"; import qRu from "../../../locales/ru/q.json"; +import scheduleRu from "../../../locales/ru/schedule.json"; import scrimsRu from "../../../locales/ru/scrims.json"; import settingsRu from "../../../locales/ru/settings.json"; import teamRu from "../../../locales/ru/team.json"; @@ -436,6 +451,7 @@ import lfgZh from "../../../locales/zh/lfg.json"; import orgZh from "../../../locales/zh/org.json"; import paramsZh from "../../../locales/zh/params.json"; import qZh from "../../../locales/zh/q.json"; +import scheduleZh from "../../../locales/zh/schedule.json"; import scrimsZh from "../../../locales/zh/scrims.json"; import settingsZh from "../../../locales/zh/settings.json"; import teamZh from "../../../locales/zh/team.json"; @@ -454,6 +470,7 @@ export const resources = { forms: formsEsUs, friends: friendsEsUs, weapons: weaponsEsUs, + schedule: scheduleEsUs, scrims: scrimsEsUs, settings: settingsEsUs, common: commonEsUs, @@ -484,6 +501,7 @@ export const resources = { forms: forms, friends: friends, weapons: weapons, + schedule: scheduleEn, scrims: scrimsEn, settings: settings, common: common, @@ -514,6 +532,7 @@ export const resources = { forms: formsKo, friends: friendsKo, weapons: weaponsKo, + schedule: scheduleKo, scrims: scrimsKo, settings: settingsKo, common: commonKo, @@ -544,6 +563,7 @@ export const resources = { forms: formsDe, friends: friendsDe, weapons: weaponsDe, + schedule: scheduleDe, scrims: scrimsDe, settings: settingsDe, common: commonDe, @@ -574,6 +594,7 @@ export const resources = { forms: formsNl, friends: friendsNl, weapons: weaponsNl, + schedule: scheduleNl, scrims: scrimsNl, settings: settingsNl, common: commonNl, @@ -604,6 +625,7 @@ export const resources = { forms: formsPtBr, friends: friendsPtBr, weapons: weaponsPtBr, + schedule: schedulePtBr, scrims: scrimsPtBr, settings: settingsPtBr, common: commonPtBr, @@ -634,6 +656,7 @@ export const resources = { forms: formsZh, friends: friendsZh, weapons: weaponsZh, + schedule: scheduleZh, scrims: scrimsZh, settings: settingsZh, common: commonZh, @@ -664,6 +687,7 @@ export const resources = { forms: formsFrCa, friends: friendsFrCa, weapons: weaponsFrCa, + schedule: scheduleFrCa, scrims: scrimsFrCa, settings: settingsFrCa, common: commonFrCa, @@ -694,6 +718,7 @@ export const resources = { forms: formsRu, friends: friendsRu, weapons: weaponsRu, + schedule: scheduleRu, scrims: scrimsRu, settings: settingsRu, common: commonRu, @@ -724,6 +749,7 @@ export const resources = { forms: formsIt, friends: friendsIt, weapons: weaponsIt, + schedule: scheduleIt, scrims: scrimsIt, settings: settingsIt, common: commonIt, @@ -754,6 +780,7 @@ export const resources = { forms: formsJa, friends: friendsJa, weapons: weaponsJa, + schedule: scheduleJa, scrims: scrimsJa, settings: settingsJa, common: commonJa, @@ -784,6 +811,7 @@ export const resources = { forms: formsDa, friends: friendsDa, weapons: weaponsDa, + schedule: scheduleDa, scrims: scrimsDa, settings: settingsDa, common: commonDa, @@ -814,6 +842,7 @@ export const resources = { forms: formsEsEs, friends: friendsEsEs, weapons: weaponsEsEs, + schedule: scheduleEsEs, scrims: scrimsEsEs, settings: settingsEsEs, common: commonEsEs, @@ -844,6 +873,7 @@ export const resources = { forms: formsHe, friends: friendsHe, weapons: weaponsHe, + schedule: scheduleHe, scrims: scrimsHe, settings: settingsHe, common: commonHe, @@ -874,6 +904,7 @@ export const resources = { forms: formsFrEu, friends: friendsFrEu, weapons: weaponsFrEu, + schedule: scheduleFrEu, scrims: scrimsFrEu, settings: settingsFrEu, common: commonFrEu, @@ -904,6 +935,7 @@ export const resources = { forms: formsPl, friends: friendsPl, weapons: weaponsPl, + schedule: schedulePl, scrims: scrimsPl, settings: settingsPl, common: commonPl, diff --git a/app/root.tsx b/app/root.tsx index db5e0c229..4a6344691 100644 --- a/app/root.tsx +++ b/app/root.tsx @@ -56,6 +56,7 @@ import { useTheme, } from "./features/theme/core/provider"; import { getThemeSession } from "./features/theme/core/theme-session.server"; +import { timezoneMiddleware } from "./features/timezone/timezone-middleware.server"; import { UnsavedChangesGuard } from "./form/UnsavedChangesGuard"; import { useUserIntlPreference } from "./hooks/intl/useUserIntlPreference"; import { useHydrated } from "./hooks/useHydrated"; @@ -87,6 +88,7 @@ export const middleware: Route.MiddlewareFunction[] = [ sessionIdMiddleware, userMiddleware, i18nMiddleware, + timezoneMiddleware, ]; import "~/styles/fonts.css"; @@ -102,6 +104,16 @@ import "nprogress/nprogress.css"; // already targets the header instead of briefly rendering over the sidebar. NProgress.configure({ parent: `#${NPROGRESS_ANCHOR_ID}` }); +type DevFaviconColors = { fill: string; stroke: string }; + +// tints the favicon per local dev instance so the browser tabs of parallel +// worktrees are told apart, matching each one's VS Code (Peacock) colors +const DEV_FAVICON_COLORS: Record = { + yellow: { fill: "#eae4c8", stroke: "#dcd2a3" }, + pink: { fill: "#eac8dd", stroke: "#dca3c6" }, + cyan: { fill: "#c8e3ea", stroke: "#a3d0dc" }, +}; + export const shouldRevalidate: ShouldRevalidateFunction = (args) => { if (isMatchResultsScopedRevalidation(args)) return false; if (isRevalidation(args)) return true; @@ -162,6 +174,7 @@ export const loader = async ({ request }: LoaderFunctionArgs) => { } : undefined, customTheme: isSupporter(user) ? user?.customTheme : undefined, + devFaviconColors: devFaviconColors(request), ...layoutData, }, { @@ -247,6 +260,9 @@ function Document({ /> ))} + {data?.devFaviconColors ? ( + + ) : null} @@ -491,6 +507,26 @@ function HydrationTestIndicator() { ); } +function devFaviconColors(request: Request) { + if (process.env.NODE_ENV !== "development") return; + + const [subdomain] = new URL(request.url).hostname.split("."); + + return DEV_FAVICON_COLORS[subdomain]; +} + +function DevFavicon({ colors }: { colors: DevFaviconColors }) { + const svg = ``; + + return ( + + ); +} + function Fonts() { return ( + AvailabilityWeekFactory.create({ + userId: users.id(1), + weekStartsAt: Availability.weekStartsAt(date, "UTC"), + timezone: "UTC", + }); + +const remainingWeekStarts = async () => + ( + await AvailabilityRepository.findAllWeeksByUserIds({ + userIds: [users.id(1)], + startsAt: 0, + endsAt: Availability.weekStartsAt(NOW, "UTC") + 1, + }) + ).map((week) => week.weekStartsAt); + +describe("DeleteOldAvailabilityRoutine", () => { + beforeEach(async () => { + vi.useFakeTimers(); + vi.setSystemTime(NOW); + await users.create(1); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + test("deletes weeks that ended over the retention period ago, keeping the rest", async () => { + const retentionAgo = subMonths(NOW, AVAILABILITY.RETENTION_MONTHS); + const longGone = subWeeks(retentionAgo, 4); + // the week the retention period reaches into still ends inside it + const justInside = retentionAgo; + + await seedWeekOf(longGone); + await seedWeekOf(justInside); + await seedWeekOf(NOW); + + await DeleteOldAvailabilityRoutine.run(); + + expect(await remainingWeekStarts()).toEqual([ + Availability.weekStartsAt(justInside, "UTC"), + Availability.weekStartsAt(NOW, "UTC"), + ]); + }); + + test("deletes team events that ended over the retention period ago, keeping the rest", async () => { + const team = await TeamFactory.create({ memberUserIds: [users.id(1)] }); + const retentionAgo = subMonths(NOW, AVAILABILITY.RETENTION_MONTHS); + await TeamEventFactory.create({ + teamId: team.id, + authorId: users.id(1), + startsAt: dateToDatabaseTimestamp(subWeeks(retentionAgo, 4)), + endsAt: dateToDatabaseTimestamp(subWeeks(retentionAgo, 4)) + 3600, + }); + await TeamEventFactory.create({ + teamId: team.id, + authorId: users.id(1), + startsAt: dateToDatabaseTimestamp(NOW), + endsAt: dateToDatabaseTimestamp(NOW) + 3600, + }); + + await DeleteOldAvailabilityRoutine.run(); + + const remaining = await AvailabilityRepository.findTeamEventsByTeamId({ + teamId: team.id, + startsAt: 0, + endsAt: dateToDatabaseTimestamp(NOW) + 7200, + }); + + expect(remaining).toHaveLength(1); + expect(remaining[0].startsAt).toBe(dateToDatabaseTimestamp(NOW)); + }); +}); diff --git a/app/routines/deleteOldAvailability.ts b/app/routines/deleteOldAvailability.ts new file mode 100644 index 000000000..f38602189 --- /dev/null +++ b/app/routines/deleteOldAvailability.ts @@ -0,0 +1,36 @@ +import { subDays, subMonths } from "date-fns"; +import * as AvailabilityRepository from "../features/availability/AvailabilityRepository.server"; +import { AVAILABILITY } from "../features/availability/availability-constants"; +import { dateToDatabaseTimestamp } from "../utils/dates"; +import { logger } from "../utils/logger"; +import { Routine } from "./routine.server"; + +const WEEK_DAYS = 7; + +export const DeleteOldAvailabilityRoutine = new Routine({ + name: "DeleteOldAvailability", + func: async () => { + // weeks are indexed by their start, so a week that ended long enough ago + // is one that started a week further back than that + const cutOff = subDays( + subMonths(new Date(), AVAILABILITY.RETENTION_MONTHS), + WEEK_DAYS, + ); + + const { numDeletedRows } = + await AvailabilityRepository.deleteWeeksStartedBefore( + dateToDatabaseTimestamp(cutOff), + ); + + const { numDeletedRows: deletedTeamEvents } = + await AvailabilityRepository.deleteTeamEventsEndedBefore( + dateToDatabaseTimestamp( + subMonths(new Date(), AVAILABILITY.RETENTION_MONTHS), + ), + ); + + logger.info( + `Deleted ${numDeletedRows} old availability weeks and ${deletedTeamEvents} old team events`, + ); + }, +}); diff --git a/app/routines/list.server.ts b/app/routines/list.server.ts index b96fea5ef..057b7c167 100644 --- a/app/routines/list.server.ts +++ b/app/routines/list.server.ts @@ -3,6 +3,7 @@ import { CloseExpiredCommissionsRoutine } from "./closeExpiredCommissions"; import { CloseExpiredContinueVotesRoutine } from "./closeExpiredContinueVotes"; import { ComputeLutiDivsRoutine } from "./computeLutiDivs"; import { DeleteObsoleteMatchVodsRoutine } from "./deleteObsoleteMatchVods"; +import { DeleteOldAvailabilityRoutine } from "./deleteOldAvailability"; import { DeleteOldExternalStreamsRoutine } from "./deleteOldExternalStreams"; import { DeleteOldNotificationsRoutine } from "./deleteOldNotifications"; import { DeleteOldPendingFriendRequestsRoutine } from "./deleteOldPendingFriendRequests"; @@ -13,6 +14,7 @@ import { EvictStaleRunningTournamentsRoutine } from "./evictStaleRunningTourname import { ExpireReadyChecksRoutine } from "./expireReadyChecks"; import { NotifyCheckInStartRoutine } from "./notifyCheckInStart"; import { NotifyPlusServerVotingRoutine } from "./notifyPlusServerVoting"; +import { NotifyScheduleTeamReminderRoutine } from "./notifyScheduleTeamReminder"; import { NotifyScrimStartingSoonRoutine } from "./notifyScrimStartingSoon"; import { NotifySeasonEndRoutine } from "./notifySeasonEnd"; import { NotifySeasonStartRoutine } from "./notifySeasonStart"; @@ -53,6 +55,8 @@ export const daily = [ DeleteOldPendingFriendRequestsRoutine, DeleteOldTournamentAuditLogsRoutine, DeleteOldScrimPickupRostersRoutine, + DeleteOldAvailabilityRoutine, + NotifyScheduleTeamReminderRoutine, CloseExpiredCommissionsRoutine, CloseExpiredChatRoomsRoutine, DeleteOrphanArtTagsRoutine, diff --git a/app/routines/notifyScheduleTeamReminder.test.ts b/app/routines/notifyScheduleTeamReminder.test.ts new file mode 100644 index 000000000..ade1ef77e --- /dev/null +++ b/app/routines/notifyScheduleTeamReminder.test.ts @@ -0,0 +1,69 @@ +import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; +import * as AvailabilityWeekFactory from "~/db/seed/factories/AvailabilityWeekFactory"; +import * as TeamFactory from "~/db/seed/factories/TeamFactory"; +import * as UserFactory from "~/db/seed/factories/UserFactory"; +import * as Availability from "~/features/availability/core/Availability"; +import { NotifyScheduleTeamReminderRoutine } from "./notifyScheduleTeamReminder"; + +const users = UserFactory.pool(); + +const { mockNotify } = vi.hoisted(() => ({ + mockNotify: vi.fn(), +})); + +vi.mock("~/features/notifications/core/notify.server", () => ({ + notify: mockNotify, +})); + +const MONDAY = new Date("2026-08-24T09:00:00Z"); +const WEDNESDAY = new Date("2026-08-26T09:00:00Z"); + +const reportCurrentWeek = (userId: number) => + AvailabilityWeekFactory.create({ + userId, + weekStartsAt: Availability.weekStartsAt(MONDAY, "UTC"), + timezone: "UTC", + }); + +describe("NotifyScheduleTeamReminderRoutine", () => { + beforeEach(async () => { + vi.useFakeTimers(); + vi.setSystemTime(MONDAY); + await users.create(2); + mockNotify.mockClear(); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + test("notifies the teammate who has not reported the week", async () => { + await TeamFactory.create({ memberUserIds: [users.id(1), users.id(2)] }); + await reportCurrentWeek(users.id(1)); + + await NotifyScheduleTeamReminderRoutine.run(); + + expect(mockNotify).toHaveBeenCalledWith({ + notification: { type: "SCHEDULE_TEAM_REMINDER" }, + userIds: [users.id(2)], + }); + }); + + test("notifies nobody when no teammate reported the week", async () => { + await TeamFactory.create({ memberUserIds: [users.id(1), users.id(2)] }); + + await NotifyScheduleTeamReminderRoutine.run(); + + expect(mockNotify).not.toHaveBeenCalled(); + }); + + test("does nothing on a day that is not the first of the week", async () => { + vi.setSystemTime(WEDNESDAY); + await TeamFactory.create({ memberUserIds: [users.id(1), users.id(2)] }); + await reportCurrentWeek(users.id(1)); + + await NotifyScheduleTeamReminderRoutine.run(); + + expect(mockNotify).not.toHaveBeenCalled(); + }); +}); diff --git a/app/routines/notifyScheduleTeamReminder.ts b/app/routines/notifyScheduleTeamReminder.ts new file mode 100644 index 000000000..5caf3d332 --- /dev/null +++ b/app/routines/notifyScheduleTeamReminder.ts @@ -0,0 +1,39 @@ +import { DANGEROUS_CAN_ACCESS_DEV_CONTROLS } from "../features/admin/core/dev-controls"; +import * as AvailabilityRepository from "../features/availability/AvailabilityRepository.server"; +import * as Availability from "../features/availability/core/Availability"; +import { notify } from "../features/notifications/core/notify.server"; +import { logger } from "../utils/logger"; +import { Routine } from "./routine.server"; + +/** + * Reminds users whose teammates have reported the week that just started while + * they have not. Runs on Mondays only, which is also what keeps it to at most + * one reminder per user per week. + */ +export const NotifyScheduleTeamReminderRoutine = new Routine({ + name: "NotifyScheduleTeamReminder", + func: async () => { + const now = new Date(); + + // runs whatever the day is when triggered by hand in development + if ( + !Availability.isFirstDayOfWeek(now, "UTC") && + !DANGEROUS_CAN_ACCESS_DEV_CONTROLS + ) { + return; + } + + const userIds = await AvailabilityRepository.findWeekReminderUserIds( + Availability.weekStartsAt(now, "UTC"), + ); + + if (userIds.length === 0) return; + + logger.info(`Reminding ${userIds.length} users about their schedule`); + + await notify({ + notification: { type: "SCHEDULE_TEAM_REMINDER" }, + userIds, + }); + }, +}); diff --git a/app/utils/cache.server.ts b/app/utils/cache.server.ts index 2d33a126a..d2951b36d 100644 --- a/app/utils/cache.server.ts +++ b/app/utils/cache.server.ts @@ -10,7 +10,7 @@ declare global { // biome-ignore lint/suspicious/noAssignInExpressions: trick to only create one export const cache = (global.__lruCache = global.__lruCache ? global.__lruCache - : new LRUCache>({ max: 5000 })); + : new LRUCache>({ max: 6000 })); export const ttl = (ms: number) => (ServerConfig.disableCache ? 0 : ms); diff --git a/app/utils/i18n.ts b/app/utils/i18n.ts index 9908fc525..a83f44e9f 100644 --- a/app/utils/i18n.ts +++ b/app/utils/i18n.ts @@ -19,6 +19,7 @@ const ALL_NAMESPACES = [ "user", "weapons", "scrims", + "schedule", "tournament", "team", "tier-list-maker", diff --git a/app/utils/urls.ts b/app/utils/urls.ts index bc67638a8..a8bbebc4f 100644 --- a/app/utils/urls.ts +++ b/app/utils/urls.ts @@ -259,6 +259,8 @@ export const editTeamPage = (customUrl: string) => `${teamPage(customUrl)}/edit`; export const manageTeamRosterPage = (customUrl: string) => `${teamPage(customUrl)}/roster`; +export const teamSchedulePage = (customUrl: string) => + `${teamPage(customUrl)}/schedule`; export const authErrorUrl = (errorCode: AuthErrorCode) => `/?authError=${errorCode}`; diff --git a/changelog/2026-08-30-schedules.md b/changelog/2026-08-30-schedules.md new file mode 100644 index 000000000..f22179293 --- /dev/null +++ b/changelog/2026-08-30-schedules.md @@ -0,0 +1,17 @@ +--- +navItem: [calendar, scrims] +type: feature +--- +Schedules: share when your availability and plan your team activities + +- Fill in your availability for this week and the next under "My schedule" on the events page +- Intuitive tool to enter your availability supporting drag gestures to quickly copy your availability from day to day +- Tournaments you have signed up for, scrims you have accepted and your team's events automatically count as busy +- Team pages have a new schedule tab: the whole roster's week side by side, when the team can play with a full roster or one player short +- Add activities to your team members calendar about events that are not tournament and scrims, e.g. "VoD review" +- Signing up for a tournament shows your roster's availability for the event +- Tournaments now show their estimated runtime +- Tournament "add all team members" shotcut action +- When posting a scrim you can pick the start time straight from your team's free time +- Your friends list shows the week of friends and teammates who have filled theirs in (ask your friend to sub for example!) +- Your schedule is only visible to your teammates and friends diff --git a/e2e/events.spec.ts b/e2e/events.spec.ts index 607bde65c..6eabfa126 100644 --- a/e2e/events.spec.ts +++ b/e2e/events.spec.ts @@ -1,12 +1,22 @@ -import { addHours } from "date-fns"; +import { addHours, subWeeks } from "date-fns"; import { NZAP_TEST_ID } from "~/db/seed/constants"; import { ADMIN_ID } from "~/features/admin/admin-constants"; +import * as Availability from "~/features/availability/core/Availability"; import { dateToDatabaseTimestamp } from "~/utils/dates"; -import { expect, impersonate, test } from "./helpers/playwright"; +import { + expect, + impersonate, + isNotVisible, + MACHINE_TIMEZONE, + setTimezoneCookie, + test, +} from "./helpers/playwright"; import { EventsPage } from "./pages/calendar/events-page"; const JOINED_TOURNAMENT_NAME = "Joined Tournament"; const ORGANIZED_TOURNAMENT_NAME = "Organized Tournament"; +const WEDNESDAY = 2; +const DAY_SECONDS = 24 * 60 * 60; test.describe("Events", () => { test("filters between tabs and navigates to an event", async ({ @@ -58,3 +68,177 @@ test.describe("Events", () => { await expect(page).not.toHaveURL(/\/events/); }); }); + +test.describe("My schedule", () => { + test("saves a week, edits it and submits an empty week", async ({ page }) => { + await impersonate(page, ADMIN_ID); + await setTimezoneCookie(page); + + const events = new EventsPage(page); + await events.goto(); + + await expect(events.weekNotFilledMarker("current")).toBeVisible(); + + await events.dayEditButton(WEDNESDAY).click(); + const popover = events.locators.dayEditorPopover; + await popover.getByLabel("Start").fill("18:00"); + await popover.getByLabel("End").fill("22:00"); + await popover.getByLabel("Note").fill("Leaving early"); + await page.keyboard.press("Escape"); + + await expect(events.locators.availabilityBars).toHaveCount(1); + + // leaving the page with the unsaved week warns first + await page + .getByRole("link", { name: "Find an event to join on the calendar!" }) + .click(); + await page.getByText("Unsaved changes").waitFor(); + await page.getByRole("button", { name: "Cancel" }).click(); + await expect(page).toHaveURL(/\/events/); + + await events.locators.saveWeekButton.click(); + await expect(page.getByText("Availability saved")).toBeAttached(); + + await events.goto(); + await expect(events.locators.availabilityBars).toHaveCount(1); + await isNotVisible(events.weekNotFilledMarker("current")); + await expect(events.weekNotFilledMarker("next")).toBeVisible(); + + await events.dayEditButton(WEDNESDAY).click(); + await expect(popover.getByLabel("Note")).toHaveValue("Leaving early"); + // deleting the only range commits instantly: the popover closes and the + // bar disappears without waiting for a popover close + save + await popover.getByRole("button", { name: "Delete" }).click(); + await isNotVisible(events.locators.dayEditorPopover); + await isNotVisible(events.locators.availabilityBars); + await events.locators.saveWeekButton.click(); + await expect(page.getByText("Availability saved")).toBeAttached(); + + // an empty submitted week is "unavailable all week", not missing + await events.goto(); + await isNotVisible(events.locators.availabilityBars); + 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("paints a range reaching past the hours the tracks show", async ({ + page, + }) => { + await impersonate(page, ADMIN_ID); + await setTimezoneCookie(page); + + const events = new EventsPage(page); + await events.goto(); + + // the tracks end at 2 AM until they are expanded; the paint runs past + // their right edge and the window widens to fit what it produced + await events.paintAvailability(WEDNESDAY, 0.5, 1.25); + + await expect(events.locators.availabilityBars).toHaveAttribute( + "title", + "8:00 PM – 5:00 AM", + ); + }); + + test("opens the day editor on the click following a drag", async ({ + page, + }) => { + await impersonate(page, ADMIN_ID); + await setTimezoneCookie(page); + + const events = new EventsPage(page); + await events.goto(); + + await events.paintAvailability(WEDNESDAY, 0.3, 0.5); + // the drag ends with a click of its own, which must not open the popover + // without swallowing the click that comes after it either + await events.dragAvailabilityBar(events.locators.availabilityBars, 60); + await isNotVisible(events.locators.dayEditorPopover); + + await events.locators.availabilityBars.click(); + await expect(events.locators.dayEditorPopover).toBeVisible(); + }); + + test("copies last week's ranges into the current week", async ({ + page, + factories, + }) => { + const lastWeekRange = Availability.weekRange( + subWeeks(new Date(), 1), + MACHINE_TIMEZONE, + ); + const lastWednesday = Availability.dateInTimezone( + lastWeekRange.startsAt + WEDNESDAY * DAY_SECONDS + DAY_SECONDS / 2, + MACHINE_TIMEZONE, + ); + await factories.AvailabilityWeekFactory.create({ + userId: ADMIN_ID, + weekStartsAt: lastWeekRange.startsAt, + timezone: MACHINE_TIMEZONE, + slots: [ + { + startsAt: Availability.localToTimestamp({ + date: lastWednesday, + time: "19:00", + timezone: MACHINE_TIMEZONE, + }), + endsAt: Availability.localToTimestamp({ + date: lastWednesday, + time: "21:00", + timezone: MACHINE_TIMEZONE, + }), + }, + ], + }); + + await impersonate(page, ADMIN_ID); + await setTimezoneCookie(page); + + const events = new EventsPage(page); + await events.goto(); + + await isNotVisible(events.locators.availabilityBars); + await events.locators.copyLastWeekButton.click(); + await expect(events.locators.availabilityBars).toHaveCount(1); + + await events.locators.saveWeekButton.click(); + await expect(page.getByText("Availability saved")).toBeAttached(); + }); +}); diff --git a/e2e/friends.spec.ts b/e2e/friends.spec.ts index 64f06bd1c..13ab4baf7 100644 --- a/e2e/friends.spec.ts +++ b/e2e/friends.spec.ts @@ -1,8 +1,25 @@ import { NZAP_TEST_ID } from "~/db/seed/constants"; -import { expect, impersonate, test } from "./helpers/playwright"; +import { ADMIN_ID } from "~/features/admin/admin-constants"; +import * as Availability from "~/features/availability/core/Availability"; +import { weekDates, weekRange } from "./helpers/availability"; +import { + expect, + impersonate, + isNotVisible, + MACHINE_TIMEZONE, + setTimezoneCookie, + test, +} from "./helpers/playwright"; +import { + befriend, + createNamedUsers, + expectTopToBottom, +} from "./helpers/sidebar"; import { FriendsPage } from "./pages/friends/friends-page"; import { NotificationPopover } from "./pages/layout/notification-popover"; +const WEDNESDAY = 2; + test.describe("Friends", () => { test("send friend request, accept it, then delete friend", async ({ page, @@ -40,4 +57,78 @@ test.describe("Friends", () => { await expect(friends.locators.noFriendsText).toBeVisible(); }); + + test("sorts friends who shared a schedule up and shows their week", async ({ + page, + factories, + }) => { + const [scheduled, unscheduled, queueing] = await createNamedUsers( + factories, + ["ScheduleFriend", "NoScheduleFriend", "QueueFriend"], + ); + await befriend( + factories, + [unscheduled.id, scheduled.id, queueing.id], + ADMIN_ID, + ); + await factories.SQGroupFactory.create({ memberUserIds: [queueing.id] }); + + await factories.AvailabilityWeekFactory.create({ + userId: scheduled.id, + weekStartsAt: weekRange().startsAt, + timezone: MACHINE_TIMEZONE, + slots: [daySlot(WEDNESDAY, "18:00", "22:00")], + }); + // a commitment of their own team, which the modal shows only as the free + // time it takes away + const { id: teamId } = await factories.TeamFactory.create({ + name: "Schedule Team", + memberUserIds: [scheduled.id], + }); + await factories.TeamEventFactory.create({ + teamId, + authorId: scheduled.id, + name: "VoD review", + ...daySlot(WEDNESDAY, "20:00", "22:00"), + }); + + await impersonate(page, ADMIN_ID); + await setTimezoneCookie(page); + + const friends = new FriendsPage(page); + await friends.goto(); + + await expectTopToBottom([ + friends.row(queueing.id), + friends.row(scheduled.id), + friends.row(unscheduled.id), + ]); + await isNotVisible(friends.scheduleButton(unscheduled.id)); + + await friends.scheduleButton(scheduled.id).click(); + await expect(friends.locators.scheduleRanges).toHaveCount(1); + await expect(friends.day(WEDNESDAY)).toContainText("6:00"); + await expect(friends.day(WEDNESDAY)).not.toContainText("VoD review"); + + // they only filled in the current week + await friends.locators.nextWeekToggle.click(); + await expect(friends.locators.noScheduleText).toBeVisible(); + }); }); + +function daySlot(dayIndex: number, start: string, end: string) { + const date = weekDates()[dayIndex]; + + return { + startsAt: Availability.localToTimestamp({ + date, + time: start, + timezone: MACHINE_TIMEZONE, + }), + endsAt: Availability.localToTimestamp({ + date, + time: end, + timezone: MACHINE_TIMEZONE, + }), + }; +} diff --git a/e2e/helpers/availability.ts b/e2e/helpers/availability.ts new file mode 100644 index 000000000..5eaa9a0e4 --- /dev/null +++ b/e2e/helpers/availability.ts @@ -0,0 +1,22 @@ +import * as R from "remeda"; +import * as Availability from "~/features/availability/core/Availability"; +import { MACHINE_TIMEZONE } from "./playwright"; + +const DAY_SECONDS = 24 * 60 * 60; + +/** The week `date` (default: now) falls in, as the test machine's timezone sees it. */ +export function weekRange(date = new Date()) { + return Availability.weekRange(date, MACHINE_TIMEZONE); +} + +/** The seven `YYYY-MM-DD` dates of the week `date` (default: now) falls in. */ +export function weekDates(date = new Date()) { + const { startsAt } = weekRange(date); + + return R.range(0, 7).map((dayIndex) => + Availability.dateInTimezone( + startsAt + dayIndex * DAY_SECONDS + DAY_SECONDS / 2, + MACHINE_TIMEZONE, + ), + ); +} diff --git a/e2e/helpers/factories.ts b/e2e/helpers/factories.ts index 1e1ac0dac..22a2f4285 100644 --- a/e2e/helpers/factories.ts +++ b/e2e/helpers/factories.ts @@ -35,6 +35,9 @@ export async function loadFactories(parallelIndex: number) { ApiTokenFactory: await import("~/db/seed/factories/ApiTokenFactory"), ArtFactory: await import("~/db/seed/factories/ArtFactory"), AssociationFactory: await import("~/db/seed/factories/AssociationFactory"), + AvailabilityWeekFactory: await import( + "~/db/seed/factories/AvailabilityWeekFactory" + ), BadgeFactory: await import("~/db/seed/factories/BadgeFactory"), BuildFactory: await import("~/db/seed/factories/BuildFactory"), CalendarEventFactory: await import( @@ -80,6 +83,7 @@ export async function loadFactories(parallelIndex: number) { SQReportedWeaponFactory: await import( "~/db/seed/factories/SQReportedWeaponFactory" ), + TeamEventFactory: await import("~/db/seed/factories/TeamEventFactory"), TeamFactory: await import("~/db/seed/factories/TeamFactory"), TournamentFactory: await import("~/db/seed/factories/TournamentFactory"), TournamentLFGTeamFactory: await import( diff --git a/e2e/helpers/playwright.ts b/e2e/helpers/playwright.ts index 961060005..a349a1581 100644 --- a/e2e/helpers/playwright.ts +++ b/e2e/helpers/playwright.ts @@ -492,3 +492,23 @@ export async function clickNavTab(page: Page, testId: string) { } await visibleTab.click(); } + +/** The IANA timezone of the machine running the tests, the one fixture times should be computed in. */ +export const MACHINE_TIMEZONE = + Intl.DateTimeFormat().resolvedOptions().timeZone; + +/** + * Writes the timezone cookie the browser would after hydration, so that the + * very first document request already renders in the machine's timezone the + * test computed its fixture times in. + */ +export function setTimezoneCookie(page: Page) { + return page.context().addCookies([ + { + name: "timezone", + value: MACHINE_TIMEZONE, + domain: "localhost", + path: "/", + }, + ]); +} diff --git a/e2e/pages/calendar/events-page.ts b/e2e/pages/calendar/events-page.ts index 873605ec5..15d831b7d 100644 --- a/e2e/pages/calendar/events-page.ts +++ b/e2e/pages/calendar/events-page.ts @@ -20,12 +20,62 @@ export class EventsPage { this.page = page; this.main = page.locator("main"); this.locators = { - title: page.getByRole("heading", { name: "My Events" }), + title: page.getByRole("heading", { name: "My events" }), viewTabs: this.main.getByRole("navigation"), 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"), }; } + /** The "• not filled" marker on a week toggle chip. */ + weekNotFilledMarker(week: "current" | "next") { + return this.page.getByTestId(`week-not-filled-${week}`); + } + + /** The pencil button opening the day editor popover of a day track. */ + dayEditButton(dayIndex: number) { + return this.page.getByTestId(`availability-day-edit-${dayIndex}`); + } + + /** + * Paints a range on a day track by dragging across it, `from` and `to` being + * fractions of the track's width. Past 1 the drag runs beyond the hours the + * track shows. + */ + async paintAvailability(dayIndex: number, from: number, to: number) { + const track = this.page.getByTestId(`availability-track-${dayIndex}`); + const box = await track.boundingBox(); + if (!box) { + throw new Error("Missing bounding box for the day track"); + } + + const y = box.y + box.height / 2; + await this.page.mouse.move(box.x + box.width * from, y); + await this.page.mouse.down(); + await this.page.mouse.move(box.x + box.width * to, y, { steps: 10 }); + await this.page.mouse.up(); + } + + /** Drags an availability bar sideways by `deltaX` pixels, moving the whole range. */ + async dragAvailabilityBar(bar: Locator, deltaX: number) { + const box = await bar.boundingBox(); + if (!box) { + throw new Error("Missing bounding box for the availability bar"); + } + + const x = box.x + box.width / 2; + const y = box.y + box.height / 2; + await this.page.mouse.move(x, y); + await this.page.mouse.down(); + await this.page.mouse.move(x + deltaX, y, { steps: 10 }); + await this.page.mouse.up(); + } + async goto() { await navigate({ page: this.page, url: EVENTS_PAGE }); } diff --git a/e2e/pages/friends/friends-page.ts b/e2e/pages/friends/friends-page.ts index 08fcdaa7b..101729eda 100644 --- a/e2e/pages/friends/friends-page.ts +++ b/e2e/pages/friends/friends-page.ts @@ -22,6 +22,13 @@ export class FriendsPage { acceptButton: this.page.getByRole("button", { name: "Accept" }), cancelRequestButton: this.page.getByRole("button", { name: "Cancel" }), noFriendsText: this.page.getByText("No friends yet"), + scheduleDays: this.page.getByTestId("schedule-week-days"), + scheduleRanges: this.page.getByTestId("schedule-range"), + noScheduleText: this.page.getByTestId("schedule-no-week"), + // the chip radio input is visually hidden, so the label is what clicks + nextWeekToggle: this.page.locator( + 'label[for="chip-radio-friend-schedule-week-next"]', + ), }; } @@ -52,6 +59,19 @@ export class FriendsPage { friend(name: string) { return new FriendMenu(this.page, name); } + + row(userId: number) { + return this.page.getByTestId(`friend-row-${userId}`); + } + + scheduleButton(userId: number) { + return this.page.getByTestId(`friend-schedule-button-${userId}`); + } + + /** One day row of the open week modal, Monday being 0. */ + day(dayIndex: number) { + return this.locators.scheduleDays.getByRole("listitem").nth(dayIndex); + } } class FriendMenu { diff --git a/e2e/pages/layout/mobile-nav.ts b/e2e/pages/layout/mobile-nav.ts index 52cc5cb2e..1c62dbaed 100644 --- a/e2e/pages/layout/mobile-nav.ts +++ b/e2e/pages/layout/mobile-nav.ts @@ -64,7 +64,9 @@ export class MobileNav { } async closePanel() { - await this.page.locator("button:has(svg.lucide-x)").first().click(); + await this.openPanelDialog + .locator("button[class*='panelCloseButton']") + .click(); } menuLink(name: string) { diff --git a/e2e/pages/scrims/new-scrim-post-page.ts b/e2e/pages/scrims/new-scrim-post-page.ts index 9b94d407c..ba42a19e2 100644 --- a/e2e/pages/scrims/new-scrim-post-page.ts +++ b/e2e/pages/scrims/new-scrim-post-page.ts @@ -12,10 +12,21 @@ import { createFormHelpers } from "../../helpers/playwright-form"; export class NewScrimPostPage { private readonly page: Page; readonly form; + readonly locators; constructor(page: Page) { this.page = page; this.form = createFormHelpers(page, scrimsNewFormSchema); + this.locators = { + schedulePicker: page.getByTestId("scrim-schedule-picker"), + scheduleSlots: page.getByTestId("scrim-schedule-slot"), + scheduleUnknown: page.getByTestId("scrim-schedule-unknown"), + flexibility: page.getByLabel("Start time flexibility"), + // the chip radio input is visually hidden, so the label is what clicks + nextWeekToggle: page.locator( + 'label[for="chip-radio-scrim-schedule-week-next"]', + ), + }; } async goto() { @@ -52,6 +63,13 @@ export class NewScrimPostPage { return this.page.getByLabel(`User ${nth}`); } + /** One segment of the Start date picker, e.g. `"hour"` or `"day"`. */ + startSegment(segmentName: string) { + return this.page.getByRole("spinbutton", { + name: new RegExp(`^${segmentName}, Start`), + }); + } + /** Limits who sees the post to one of the author's associations. */ async selectVisibility(associationName: string) { await this.page diff --git a/e2e/pages/scrims/scrims-page.ts b/e2e/pages/scrims/scrims-page.ts index 99f142f60..8cd5f7b2e 100644 --- a/e2e/pages/scrims/scrims-page.ts +++ b/e2e/pages/scrims/scrims-page.ts @@ -42,6 +42,7 @@ export class ScrimsPage { limitedVisibilityPopover: page.getByTestId("limited-visibility-popover"), tournamentPopover: page.getByTestId("tournament-popover-trigger"), canceledLabel: page.getByText("Canceled"), + fitIndicator: page.getByTestId("scrim-fit-indicator"), divsFilterPill: page.getByTestId("divs-filter"), addFilterButton: page.getByTestId("add-filter-button"), saveFiltersAsDefaultButton: page.getByTestId( @@ -96,6 +97,11 @@ export class ScrimsPage { await this.page.getByTestId("menu-item-divs-filter").click(); } + /** One roster member's row of the fit indicator's popover, its status in `data-status`. */ + availabilityRow(userId: number) { + return this.page.getByTestId(`availability-row-${userId}`); + } + async openTab(tab: Tab) { await this.page.getByRole("tab", { name: TAB_NAMES[tab] }).click(); } diff --git a/e2e/pages/team/team-page.ts b/e2e/pages/team/team-page.ts index 06e422168..1998a2c71 100644 --- a/e2e/pages/team/team-page.ts +++ b/e2e/pages/team/team-page.ts @@ -8,6 +8,7 @@ import { import { TeamEditPage } from "./team-edit-page"; import { TeamResultsPage } from "./team-results-page"; import { TeamRosterPage } from "./team-roster-page"; +import { TeamSchedulePage } from "./team-schedule-page"; export class TeamPage { private readonly page: Page; @@ -25,6 +26,7 @@ export class TeamPage { makeMainTeamButton: page.getByTestId("make-main-team-button"), leaveTeamButton: page.getByTestId("leave-team-button"), deleteTeamButton: page.getByTestId("delete-team-button"), + scheduleButton: page.getByTestId("team-schedule-button"), otherRolesTab: page.getByRole("tab", { name: /Other/ }), confirmDialog: page.getByRole("dialog"), resultsBannerLink: page.getByRole("link", { name: /View \d+ results/ }), @@ -62,6 +64,11 @@ export class TeamPage { return new TeamResultsPage(this.page); } + async openSchedule() { + await this.locators.scheduleButton.click(); + return new TeamSchedulePage(this.page); + } + async openActionsMenu() { await this.locators.actionsMenuButton.click(); } diff --git a/e2e/pages/team/team-schedule-page.ts b/e2e/pages/team/team-schedule-page.ts new file mode 100644 index 000000000..e2089bcc3 --- /dev/null +++ b/e2e/pages/team/team-schedule-page.ts @@ -0,0 +1,48 @@ +import type { Page } from "@playwright/test"; +import { teamPage } from "~/utils/urls"; +import { navigate } from "../../helpers/playwright"; + +export class TeamSchedulePage { + private readonly page: Page; + readonly locators; + + constructor(page: Page) { + this.page = page; + this.locators = { + grid: page.getByTestId("schedule-grid"), + summary: page.getByTestId("schedule-summary"), + hiddenMessage: page.getByTestId("schedule-hidden"), + windows: page.getByTestId("schedule-window"), + notes: page.getByTestId("schedule-note"), + teamEvents: page.getByTestId("schedule-team-event"), + addEventButton: page.getByTestId("add-team-event-button"), + // the chip radio input is visually hidden, so the label is what clicks + nextWeekToggle: page.locator( + 'label[for="chip-radio-schedule-week-next"]', + ), + }; + } + + async goto(customUrl: string) { + await navigate({ + page: this.page, + url: `${teamPage(customUrl)}/schedule`, + }); + } + + cell(userId: number, dayIndex: number) { + return this.page.getByTestId(`schedule-cell-${userId}-${dayIndex}`); + } + + cellRange(userId: number, dayIndex: number) { + 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/pages/tournament/tournament-page.ts b/e2e/pages/tournament/tournament-page.ts index bc5272d66..45e8fd4a9 100644 --- a/e2e/pages/tournament/tournament-page.ts +++ b/e2e/pages/tournament/tournament-page.ts @@ -14,6 +14,7 @@ export class TournamentPage { this.nav = new TournamentNav(page); this.locators = { registerCta: page.getByTestId("register-cta"), + estimatedEnd: page.getByTestId("estimated-end"), }; } diff --git a/e2e/pages/tournament/tournament-register-page.ts b/e2e/pages/tournament/tournament-register-page.ts index 0ab0a79dd..b6a072564 100644 --- a/e2e/pages/tournament/tournament-register-page.ts +++ b/e2e/pages/tournament/tournament-register-page.ts @@ -6,7 +6,11 @@ import { counterpickMap, pickCounterpickMaps, } from "../../helpers/counterpick-map-pool"; -import { navigate, submit } from "../../helpers/playwright"; +import { + modalClickConfirmButton, + navigate, + submit, +} from "../../helpers/playwright"; import { createFormHelpers } from "../../helpers/playwright-form"; import { TournamentNav } from "./tournament-nav"; @@ -51,6 +55,15 @@ export class TournamentRegisterPage { return this.page.getByTestId(`member-num-${number}`); } + availabilityRow(userId: number) { + return this.page.getByTestId(`availability-row-${userId}`); + } + + /** Opens the quick add dropdown so its player rows render. */ + async openQuickAdd() { + await this.page.getByTestId("quick-add-select").getByRole("button").click(); + } + /** The roster footer of a format too small to have subs, e.g. "2v2". */ noSubsFooter(format: string) { return this.page.getByText(`Format is ${format}. No subs allowed.`); @@ -68,6 +81,12 @@ export class TournamentRegisterPage { return submit(this.page, "add-player-button"); } + /** Adds every player-role member of the sendou.ink team via the quick add all button, confirming the dialog. */ + async addAllTeamPlayers(teamId: number) { + await this.page.getByTestId(`add-team-players-button-${teamId}`).click(); + await modalClickConfirmButton(this.page); + } + /** Picks the required amount of counterpick maps for every mode, skipping banned ones. */ pickCounterpickMaps() { return pickCounterpickMaps(this.page); diff --git a/e2e/scrims.spec.ts b/e2e/scrims.spec.ts index 4ec7db8f4..8154ae67f 100644 --- a/e2e/scrims.spec.ts +++ b/e2e/scrims.spec.ts @@ -1,6 +1,14 @@ -import { addDays, addHours, setHours, setMinutes, startOfHour } from "date-fns"; +import { + addDays, + addHours, + addWeeks, + setHours, + setMinutes, + startOfHour, +} from "date-fns"; import { NZAP_TEST_ID } from "~/db/seed/constants"; import { ADMIN_ID } from "~/features/admin/admin-constants"; +import * as Availability from "~/features/availability/core/Availability"; import { serializeLutiDiv } from "~/features/scrims/scrims-utils"; import type { ModeShort, StageId } from "~/modules/in-game-lists/types"; import { dateToDatabaseTimestamp } from "~/utils/dates"; @@ -11,7 +19,9 @@ import { expect, impersonate, isNotVisible, + MACHINE_TIMEZONE, navigate, + setTimezoneCookie, test, } from "./helpers/playwright"; import { AnythingAdder } from "./pages/layout/anything-adder"; @@ -25,6 +35,8 @@ const TOURNAMENT_NAME = "Swim or Sink"; const ASSOCIATION_NAME = "Inkling Alliance"; const PICKUP_NAMES = ["Pickup One", "Pickup Two", "Pickup Three"]; const GROUP_SIZE = 4; +const DAY_SECONDS = 24 * 60 * 60; +const WEDNESDAY = 2; const TOURNAMENT_MAP_POOL: Array<{ mode: ModeShort; stageId: StageId }> = [ { mode: "SZ", stageId: 1 }, { mode: "TC", stageId: 2 }, @@ -442,6 +454,105 @@ function createNamedUsers(factories: Factories, names: string[]) { })); } +test.describe("Scrim schedule picker", () => { + test("picks a start and its flexibility from the roster's shared free time", async ({ + page, + factories, + }) => { + const { memberUserIds } = await createTeamFor(factories, NZAP_TEST_ID); + const evening = nextWeekSlot(WEDNESDAY, "18:00", "23:00"); + + for (const userId of memberUserIds.slice(0, memberUserIds.length - 1)) { + await factories.AvailabilityWeekFactory.create({ + userId, + weekStartsAt: nextWeek().startsAt, + timezone: MACHINE_TIMEZONE, + slots: [evening], + }); + } + + await impersonate(page, NZAP_TEST_ID); + await setTimezoneCookie(page); + + const newPost = new NewScrimPostPage(page); + await newPost.goto(); + await newPost.locators.nextWeekToggle.click(); + + // the roster's last member never filled the week in, so the shared + // evening is one player short of a full team + await expect(newPost.locators.scheduleUnknown).toBeVisible(); + const slot = newPost.locators.scheduleSlots; + await expect(slot).toHaveCount(1); + await expect(slot).toHaveAttribute("data-tier", "ONE_SHORT"); + + await slot.click(); + + // 18:00, with the flexibility that still leaves an hour of the window + // to play whichever start is settled on, capped at the longest option + await expect(newPost.startSegment("hour")).toHaveText("6"); + await expect(newPost.startSegment("minute")).toHaveText("00"); + await expect(newPost.startSegment("AM/PM")).toHaveText("PM"); + await expect(newPost.locators.flexibility).toHaveValue("+3hours"); + await expect(slot).toHaveAttribute("data-picked", "true"); + }); +}); + +test.describe("Scrim fit indicator", () => { + test("shows how much of the viewer's roster could play a post", async ({ + page, + factories, + }) => { + const { memberUserIds } = await createTeamFor(factories, NZAP_TEST_ID); + const evening = nextWeekSlot(WEDNESDAY, "18:00", "23:00"); + const withoutSchedule = memberUserIds[memberUserIds.length - 1]; + + for (const userId of memberUserIds.filter( + (userId) => userId !== withoutSchedule, + )) { + await factories.AvailabilityWeekFactory.create({ + userId, + weekStartsAt: nextWeek().startsAt, + timezone: MACHINE_TIMEZONE, + slots: [evening], + }); + } + + await factories.ScrimPostFactory.create({ + users: await createGroup(factories), + startsAt: evening.startsAt, + isScheduledForFuture: true, + }); + + await impersonate(page, NZAP_TEST_ID); + + const scrims = new ScrimsPage(page); + await scrims.goto(); + await scrims.openTab("available"); + + await expect(scrims.locators.fitIndicator).toContainText("3/4 available"); + + await scrims.locators.fitIndicator.click(); + + await expect(scrims.availabilityRow(NZAP_TEST_ID)).toHaveAttribute( + "data-status", + "available", + ); + await expect(scrims.availabilityRow(withoutSchedule)).toHaveAttribute( + "data-status", + "unknown", + ); + + await page.keyboard.press("Escape"); + await scrims.requestFirst(); + + // the same breakdown, for the slot the request would be made for + await expect(scrims.availabilityRow(NZAP_TEST_ID)).toHaveAttribute( + "data-status", + "available", + ); + }); +}); + async function createTeamFor(factories: Factories, userId: number) { const teammates = await factories.UserFactory.createMany(GROUP_SIZE - 1); @@ -450,6 +561,22 @@ async function createTeamFor(factories: Factories, userId: number) { }); } +function nextWeek() { + return Availability.weekRange(addWeeks(new Date(), 1), MACHINE_TIMEZONE); +} + +/** Wall-clock range on a day of next week, so it is always ahead of "now". */ +function nextWeekSlot(dayIndex: number, start: string, end: string) { + const date = Availability.dateInTimezone( + nextWeek().startsAt + dayIndex * DAY_SECONDS + DAY_SECONDS / 2, + MACHINE_TIMEZONE, + ); + const at = (time: string) => + Availability.localToTimestamp({ date, time, timezone: MACHINE_TIMEZONE }); + + return { startsAt: at(start), endsAt: at(end) }; +} + /** A pick-up sized group of users, `userId` its owner if one is given. */ async function createGroup(factories: Factories, userId?: number) { const others = await factories.UserFactory.createMany( diff --git a/e2e/team.spec.ts b/e2e/team.spec.ts index f0dca026d..ffe0c4400 100644 --- a/e2e/team.spec.ts +++ b/e2e/team.spec.ts @@ -1,25 +1,35 @@ +import { addWeeks } from "date-fns"; import { NZAP_TEST_ID } from "~/db/seed/constants"; import { ADMIN_DISCORD_ID, ADMIN_ID } from "~/features/admin/admin-constants"; +import { addTeamEventSchema } from "~/features/availability/availability-schemas"; +import * as Availability from "~/features/availability/core/Availability"; +import { weekDates, weekRange } from "./helpers/availability"; import type { Factories } from "./helpers/factories"; import { expect, impersonate, isNotVisible, + MACHINE_TIMEZONE, navigate, + setTimezoneCookie, test, } from "./helpers/playwright"; +import { createFormHelpers } from "./helpers/playwright-form"; import { AnythingAdder } from "./pages/layout/anything-adder"; import { SELECTED_MAP_CLASS } from "./pages/settings/map-mode-preferences-field"; import { JoinTeamPage } from "./pages/team/join-team-page"; import { NewTeamPage } from "./pages/team/new-team-page"; import { TeamEditPage } from "./pages/team/team-edit-page"; import { TeamPage } from "./pages/team/team-page"; +import { TeamSchedulePage } from "./pages/team/team-schedule-page"; import { UserPage } from "./pages/user/user-page"; const TEAM_NAME = "Alliance Rogue"; const SECONDARY_TEAM_NAME = "Team Olive"; const ROSTER_SIZE = 4; const TOURNAMENT_NAME = "In The Zone 30"; +const WEDNESDAY = 2; +const THURSDAY = 3; test.describe("New team creation", () => { test("creates new team", async ({ page }) => { @@ -407,3 +417,168 @@ async function createFullTeam(factories: Factories) { memberUserIds: [ADMIN_ID, ...members.map((member) => member.id)], }); } + +test.describe("Team schedule", () => { + test("member sees the grid states and playable windows", async ({ + page, + factories, + }) => { + const noScheduleMember = await factories.UserFactory.create(); + const { id: teamId, customUrl } = await factories.TeamFactory.create({ + name: TEAM_NAME, + memberUserIds: [ADMIN_ID, NZAP_TEST_ID, noScheduleMember.id], + }); + + const { startsAt } = weekRange(); + await factories.AvailabilityWeekFactory.create({ + userId: ADMIN_ID, + weekStartsAt: startsAt, + timezone: MACHINE_TIMEZONE, + // the small-hours slot guards day bucketing: on machines off UTC it + // falls on another UTC day, so it moves columns if the server ignores + // the viewer's timezone + slots: [ + daySlot(WEDNESDAY, "18:00", "22:00"), + daySlot(THURSDAY, "00:30", "02:00"), + ], + dayNotes: [{ date: weekDates()[WEDNESDAY], text: "Leaving early" }], + }); + await factories.AvailabilityWeekFactory.create({ + userId: NZAP_TEST_ID, + weekStartsAt: startsAt, + 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); + + const team = new TeamPage(page); + await team.goto(customUrl); + + const schedule = await team.openSchedule(); + await expect(schedule.locators.grid).toBeVisible(); + + await expect(schedule.cellRange(ADMIN_ID, WEDNESDAY)).toBeVisible(); + 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 + // only playable window is the one-short tier + await expect(schedule.locators.windows).toHaveText(/Wed/); + await expect(schedule.dayDot(WEDNESDAY)).toBeVisible(); + await isNotVisible(schedule.dayDot(0)); + + await expect(schedule.locators.teamEvents).toContainText("VoD review"); + }); + + test("hides the schedule from non-members, a friend of a member included", async ({ + page, + factories, + }) => { + const friend = await factories.UserFactory.create(); + const { customUrl } = await factories.TeamFactory.create({ + name: TEAM_NAME, + memberUserIds: [ADMIN_ID], + }); + await factories.FriendshipFactory.create({ + userOneId: ADMIN_ID, + userTwoId: friend.id, + }); + await factories.AvailabilityWeekFactory.create({ + userId: ADMIN_ID, + weekStartsAt: weekRange().startsAt, + timezone: MACHINE_TIMEZONE, + slots: [daySlot(WEDNESDAY, "18:00", "22:00")], + }); + + await impersonate(page, friend.id); + + const schedule = new TeamSchedulePage(page); + await schedule.goto(customUrl); + await expect(schedule.locators.hiddenMessage).toBeVisible(); + await isNotVisible(schedule.locators.grid); + }); + + test("owner adds and deletes a team event, a regular member only sees it", async ({ + page, + factories, + }) => { + const { customUrl } = await factories.TeamFactory.create({ + name: TEAM_NAME, + memberUserIds: [ADMIN_ID, NZAP_TEST_ID], + }); + + await impersonate(page, ADMIN_ID); + await setTimezoneCookie(page); + + const schedule = new TeamSchedulePage(page); + await schedule.goto(customUrl); + + await schedule.locators.addEventButton.click(); + const form = createFormHelpers(page, addTeamEventSchema); + await form.fill("name", "VoD review vs. FTWin"); + await form.setDateTime("startsAt", nextWeekTime(WEDNESDAY, "20:00")); + await form.select("duration", "90"); + await form.submit(); + + await schedule.locators.nextWeekToggle.click(); + await expect(schedule.locators.teamEvents).toContainText( + "VoD review vs. FTWin", + ); + + await impersonate(page, NZAP_TEST_ID); + await schedule.goto(customUrl); + await schedule.locators.nextWeekToggle.click(); + await expect(schedule.locators.teamEvents).toBeVisible(); + await isNotVisible(schedule.locators.addEventButton); + await isNotVisible(page.getByTestId(/delete-team-event/)); + + await impersonate(page, ADMIN_ID); + await schedule.goto(customUrl); + await schedule.locators.nextWeekToggle.click(); + await page.getByTestId(/delete-team-event/).click(); + await page.getByTestId("confirm-button").click(); + await isNotVisible(schedule.locators.teamEvents); + }); +}); + +/** Wall-clock time on a day of next week, always ahead of "now" so the add-event form accepts it. */ +function nextWeekTime(dayIndex: number, time: string) { + const date = weekDates(addWeeks(new Date(), 1))[dayIndex]; + + return new Date( + Availability.localToTimestamp({ date, time, timezone: MACHINE_TIMEZONE }) * + 1000, + ); +} + +function daySlot(dayIndex: number, start: string, end: string) { + const dates = weekDates(); + + return { + startsAt: Availability.localToTimestamp({ + date: dates[dayIndex], + time: start, + timezone: MACHINE_TIMEZONE, + }), + endsAt: Availability.localToTimestamp({ + date: dates[dayIndex], + time: end, + timezone: MACHINE_TIMEZONE, + }), + }; +} diff --git a/e2e/tournament.spec.ts b/e2e/tournament.spec.ts index a954345eb..55f804e76 100644 --- a/e2e/tournament.spec.ts +++ b/e2e/tournament.spec.ts @@ -1,11 +1,17 @@ import { addHours, addMinutes } from "date-fns"; import { ADMIN_ID } from "~/features/admin/admin-constants"; -import { dateToDatabaseTimestamp } from "~/utils/dates"; +import * as Availability from "~/features/availability/core/Availability"; +import { + databaseTimestampToDate, + dateToDatabaseTimestamp, +} from "~/utils/dates"; import { expect, impersonate, isNotVisible, + MACHINE_TIMEZONE, navigate, + setTimezoneCookie, test, } from "./helpers/playwright"; import { NotificationPopover } from "./pages/layout/notification-popover"; @@ -18,6 +24,7 @@ import { TournamentTeamsPage } from "./pages/tournament/tournament-teams-page"; const TEAM_NAME = "Chimera"; const ROSTER_SIZE = 4; const SEEDED_TEAM_COUNT = 8; +const HOUR_SECONDS = 60 * 60; /** Views of a tournament whose loaders each ship some of its teams' data. */ const TOURNAMENT_TEAM_VIEWS = ["teams", "results", "brackets", "admin/seeds"]; @@ -74,6 +81,151 @@ test.describe("Tournament", () => { ).toBeVisible(); }); + test("shows the estimated end time next to the start time", async ({ + page, + factories, + }) => { + const startsAt = dateToDatabaseTimestamp(addHours(new Date(), 2)); + const tournament = await factories.TournamentFactory.create({ + authorId: ADMIN_ID, + startTimes: [startsAt], + }); + + const tournamentPage = new TournamentPage(page); + await tournamentPage.goto(tournament.id); + + // a lone single elimination bracket is the estimator's two hour case + await expect(tournamentPage.locators.estimatedEnd).toHaveAttribute( + "datetime", + databaseTimestampToDate(startsAt + 2 * HOUR_SECONDS).toISOString(), + ); + }); + + test("quick adds all of the team's players at once", async ({ + page, + factories, + }) => { + const [captain, slayer, support, coach] = + await factories.UserFactory.createMany(4); + const team = await factories.TeamFactory.create( + { memberUserIds: [captain.id, slayer.id, support.id, coach.id] }, + { + roles: { + [slayer.id]: "SLAYER", + [support.id]: "SUPPORT", + [coach.id]: "COACH", + }, + }, + ); + + const tournament = await factories.TournamentFactory.create({ + authorId: ADMIN_ID, + startTimes: [dateToDatabaseTimestamp(addHours(new Date(), 2))], + }); + + await impersonate(page, captain.id); + const tournamentPage = new TournamentPage(page); + await tournamentPage.goto(tournament.id); + + const register = await tournamentPage.register(); + await register.form.fill("pickUpName", TEAM_NAME); + await register.form.submit(); + await expect(register.member(1)).toBeVisible(); + + // teammates are offered in the quick add, grouped under the team + await register.openQuickAdd(); + await expect(register.availabilityRow(slayer.id)).toBeVisible(); + await expect(register.availabilityRow(coach.id)).toBeVisible(); + await page.keyboard.press("Escape"); + + await register.addAllTeamPlayers(team.id); + + await expect(register.member(2)).toBeVisible(); + await expect(register.member(3)).toBeVisible(); + // the coach is not part of the competitive lineup + await isNotVisible(register.member(4)); + }); + + test("shows the roster's availability for the event window", async ({ + page, + factories, + }) => { + const [captain, partialMember, unknownMember, stranger, friend] = + await factories.UserFactory.createMany(5); + await factories.TeamFactory.create({ + memberUserIds: [captain.id, partialMember.id, unknownMember.id], + }); + await factories.FriendshipFactory.create({ + userOneId: captain.id, + userTwoId: friend.id, + }); + + const startsAt = addHours(new Date(), 2); + const tournament = await factories.TournamentFactory.create({ + authorId: ADMIN_ID, + startTimes: [dateToDatabaseTimestamp(startsAt)], + }); + await factories.TournamentTeamFactory.create({ + tournamentId: tournament.id, + memberUserIds: [ + captain.id, + partialMember.id, + unknownMember.id, + stranger.id, + ], + }); + + const { startsAt: weekStartsAt } = Availability.weekRange( + startsAt, + MACHINE_TIMEZONE, + ); + const coveringSlot = { + startsAt: dateToDatabaseTimestamp(startsAt), + endsAt: dateToDatabaseTimestamp(addHours(startsAt, 5)), + }; + for (const userId of [captain.id, friend.id]) { + await factories.AvailabilityWeekFactory.create({ + userId, + weekStartsAt, + timezone: MACHINE_TIMEZONE, + slots: [coveringSlot], + }); + } + await factories.AvailabilityWeekFactory.create({ + userId: partialMember.id, + weekStartsAt, + timezone: MACHINE_TIMEZONE, + slots: [ + { + startsAt: dateToDatabaseTimestamp(addHours(startsAt, 1)), + endsAt: coveringSlot.endsAt, + }, + ], + }); + + await impersonate(page, captain.id); + await setTimezoneCookie(page); + const register = new TournamentRegisterPage(page); + await register.goto(tournament.id); + + const row = (userId: number) => register.availabilityRow(userId); + await expect(row(captain.id)).toHaveAttribute("data-status", "available"); + await expect(row(partialMember.id)).toHaveAttribute( + "data-status", + "partial", + ); + await expect(row(unknownMember.id)).toHaveAttribute( + "data-status", + "unknown", + ); + // on the tournament roster without being a teammate or a friend, so + // their schedule is not the viewer's to see + await expect(row(stranger.id)).toHaveAttribute("data-status", "hidden"); + // the friend with an overlapping submitted range is offered in quick add + await register.openQuickAdd(); + await expect(row(friend.id)).toHaveAttribute("data-status", "available"); + }); + test("registers a two player roster for a 2v2 tournament that takes no third member", async ({ page, factories, diff --git a/locales/da/calendar.json b/locales/da/calendar.json index ff0b0f8a4..2a5639c8a 100644 --- a/locales/da/calendar.json +++ b/locales/da/calendar.json @@ -83,6 +83,7 @@ "events.view.registered": "", "events.view.hosting": "", "events.view.scrims": "", + "events.view.team": "", "events.view.saved": "", "events.view.organization": "", "events.empty": "", diff --git a/locales/da/common.json b/locales/da/common.json index 234bd28dd..170e4917e 100644 --- a/locales/da/common.json +++ b/locales/da/common.json @@ -108,6 +108,10 @@ "notifications.text.TO_LIKE_RECEIVED": "", "notifications.title.TO_LIKE_ACCEPTED": "", "notifications.text.TO_LIKE_ACCEPTED": "", + "notifications.title.TEAM_EVENT_ADDED": "", + "notifications.text.TEAM_EVENT_ADDED": "", + "notifications.title.SCHEDULE_TEAM_REMINDER": "", + "notifications.text.SCHEDULE_TEAM_REMINDER": "", "auth.errors.aborted": "logindforsøg afbrudt", "auth.errors.failed": "Loginforsøg fejlet", "auth.errors.discordPermissions": "Før at du kan oprette en profil på sendou.ink, skal sendou.ink have adgang til din Discordprofils navn, brugerbillede og sociale forbindelser (de sociale medier, som du har tilknyttet din discordprofil).", @@ -358,6 +362,7 @@ "xsearch.link.noScreenshots": "", "build.private": "Privat", "or": "Eller", + "inviteLink": "", "yes": "Ja", "no": "Nej", "leaderboard.tabs.players": "", diff --git a/locales/da/forms.json b/locales/da/forms.json index 88a19d9c6..43209d9dd 100644 --- a/locales/da/forms.json +++ b/locales/da/forms.json @@ -127,7 +127,18 @@ "labels.scrimRequestMessage": "", "labels.scrimRequestStartTime": "", "bottomTexts.scrimRequestStartTime": "", + "labels.duration": "", + "options.duration.30m": "", + "options.duration.1h": "", + "options.duration.1h30m": "", + "options.duration.2h": "", + "options.duration.2h30m": "", + "options.duration.3h": "", + "options.duration.4h": "", + "options.duration.5h": "", + "options.duration.6h": "", "errors.dateInPast": "", + "errors.dateTooFarAway": "", "errors.dateTooEarly": "", "errors.dateTooLate": "", "errors.dateTooFarInFuture": "", diff --git a/locales/da/front.json b/locales/da/front.json index df82d5263..50d180a95 100644 --- a/locales/da/front.json +++ b/locales/da/front.json @@ -28,6 +28,8 @@ "sideNav.scrimVs": "", "sideNav.lookingForScrim": "", "sideNav.scrimRequestPending": "", + "sideNav.scheduleNudge": "", + "sideNav.scheduleNudge.dismiss": "", "mobileNav.menu": "", "mobileNav.friends": "", "mobileNav.you": "", diff --git a/locales/da/schedule.json b/locales/da/schedule.json new file mode 100644 index 000000000..deed72853 --- /dev/null +++ b/locales/da/schedule.json @@ -0,0 +1,47 @@ +{ + "commitment.scrim": "", + "editor.addTime": "", + "editor.copyLastWeek": "", + "editor.earlier": "", + "editor.editDay": "", + "editor.later": "", + "editor.notFilled": "", + "editor.note": "", + "editor.saved": "", + "editor.saveWeek": "", + "editor.title": "", + "editor.timesInYourTimezone": "", + "editor.visibility": "", + "events.title": "", + "events.add": "", + "events.addDialogTitle": "", + "events.membersWillSee": "", + "events.none": "", + "events.delete": "", + "events.deleteConfirm": "", + "friends.availabilityOf": "", + "registration.title": "", + "registration.estimated": "", + "registration.friends": "", + "registration.beyondHorizon": "", + "registration.summary.available": "", + "registration.summary.partial": "", + "registration.summary.out": "", + "registration.summary.unknown": "", + "team.canPlay": "", + "team.currentWeek": "", + "team.hidden": "", + "team.nextWeek": "", + "team.noSchedule": "", + "team.notAvailable": "", + "team.noWindows": "", + "team.weekHeading": "", + "team.withSub": "", + "picker.title": "", + "picker.free": "", + "picker.noSchedule": "", + "picker.andOthers": "", + "picker.legend.full": "", + "picker.legend.oneShort": "", + "scrims.availableOfRoster": "" +} diff --git a/locales/da/team.json b/locales/da/team.json index 809871de6..b7b7de31e 100644 --- a/locales/da/team.json +++ b/locales/da/team.json @@ -14,6 +14,7 @@ "actionButtons.leaveTeam.confirm": "Forlad hold", "actionButtons.editTeam": "Rediger hold", "actionButtons.manageRoster": "", + "actionButtons.schedule": "", "actionButtons.deleteTeam": "Slet hold", "actionButtons.deleteTeam.profilePicture": "", "actionButtons.deleteTeam.banner": "", diff --git a/locales/da/tournament.json b/locales/da/tournament.json index 6065bf5fb..4506f24ac 100644 --- a/locales/da/tournament.json +++ b/locales/da/tournament.json @@ -57,11 +57,15 @@ "pre.roster.header": "Udfyld holdmedlemslisten", "pre.roster.footer": "Mindst {{atLeastCount}} holdmedlemmer kræves for at deltage. Der kan maks være {{maxCount}} på holdet", "pre.roster.footer.noSubs": "", - "pre.roster.addFriend.header": "", - "pre.roster.addFriend.others": "", - "pre.roster.delete.button": "Fjern medlem", - "pre.roster.delete.header": "Medlem der fjernes", "pre.roster.ignWarning": "", + "pre.roster.quickAdd": "", + "pre.roster.quickAdd.pickup": "", + "pre.roster.quickAdd.addAll": "", + "pre.roster.quickAdd.addAll.confirm": "", + "pre.roster.addMembers": "", + "pre.roster.emptySlot": "", + "pre.roster.emptySlot.optional": "", + "pre.roster.remove.confirm": "", "pre.pool.header": "Vælg banepulje", "pre.pool.banned": "Bandlyst", "pre.pool.tiebreaker.short": "Tiebreaker", @@ -153,7 +157,6 @@ "staff.divider.addedForEvent": "", "staff.editOrganization": "", "actions.addSub": "tilføj Suppleant", - "actions.shareLink": "Del invitationslinket for at tilføje medlemmer: {{inviteLink}}", "actions.sub.prompt_one": "Du kan stadigvæk tilføje {{count}} Suppleant til din holdliste", "actions.sub.prompt_other": "Du kan stadigvæk tilføje {{count}} Suppleanter til din holdliste", "actions.sub.prompt_zero": "Din holdliste er fuld, så du kan ikke tilføje flere Suppleanter", diff --git a/locales/de/calendar.json b/locales/de/calendar.json index 8f905c6d0..49b23ecb8 100644 --- a/locales/de/calendar.json +++ b/locales/de/calendar.json @@ -83,6 +83,7 @@ "events.view.registered": "", "events.view.hosting": "", "events.view.scrims": "", + "events.view.team": "", "events.view.saved": "", "events.view.organization": "", "events.empty": "", diff --git a/locales/de/common.json b/locales/de/common.json index 8a0b74e7b..75aeb09cb 100644 --- a/locales/de/common.json +++ b/locales/de/common.json @@ -108,6 +108,10 @@ "notifications.text.TO_LIKE_RECEIVED": "", "notifications.title.TO_LIKE_ACCEPTED": "", "notifications.text.TO_LIKE_ACCEPTED": "", + "notifications.title.TEAM_EVENT_ADDED": "", + "notifications.text.TEAM_EVENT_ADDED": "", + "notifications.title.SCHEDULE_TEAM_REMINDER": "", + "notifications.text.SCHEDULE_TEAM_REMINDER": "", "auth.errors.aborted": "Einloggen abgebrochen", "auth.errors.failed": "Einloggen fehlgeschlagen", "auth.errors.discordPermissions": "Für dein sendou.ink-Profil benötigt die Seite Zugriff auf den Namen, Avatar und verbundene Social-Media-Accounts in deinem Discord-Profil.", @@ -358,6 +362,7 @@ "xsearch.link.noScreenshots": "", "build.private": "", "or": "", + "inviteLink": "", "yes": "", "no": "", "leaderboard.tabs.players": "", diff --git a/locales/de/forms.json b/locales/de/forms.json index f6863111b..97ee5eed7 100644 --- a/locales/de/forms.json +++ b/locales/de/forms.json @@ -127,7 +127,18 @@ "labels.scrimRequestMessage": "", "labels.scrimRequestStartTime": "", "bottomTexts.scrimRequestStartTime": "", + "labels.duration": "", + "options.duration.30m": "", + "options.duration.1h": "", + "options.duration.1h30m": "", + "options.duration.2h": "", + "options.duration.2h30m": "", + "options.duration.3h": "", + "options.duration.4h": "", + "options.duration.5h": "", + "options.duration.6h": "", "errors.dateInPast": "", + "errors.dateTooFarAway": "", "errors.dateTooEarly": "", "errors.dateTooLate": "", "errors.dateTooFarInFuture": "", diff --git a/locales/de/front.json b/locales/de/front.json index df82d5263..50d180a95 100644 --- a/locales/de/front.json +++ b/locales/de/front.json @@ -28,6 +28,8 @@ "sideNav.scrimVs": "", "sideNav.lookingForScrim": "", "sideNav.scrimRequestPending": "", + "sideNav.scheduleNudge": "", + "sideNav.scheduleNudge.dismiss": "", "mobileNav.menu": "", "mobileNav.friends": "", "mobileNav.you": "", diff --git a/locales/de/schedule.json b/locales/de/schedule.json new file mode 100644 index 000000000..deed72853 --- /dev/null +++ b/locales/de/schedule.json @@ -0,0 +1,47 @@ +{ + "commitment.scrim": "", + "editor.addTime": "", + "editor.copyLastWeek": "", + "editor.earlier": "", + "editor.editDay": "", + "editor.later": "", + "editor.notFilled": "", + "editor.note": "", + "editor.saved": "", + "editor.saveWeek": "", + "editor.title": "", + "editor.timesInYourTimezone": "", + "editor.visibility": "", + "events.title": "", + "events.add": "", + "events.addDialogTitle": "", + "events.membersWillSee": "", + "events.none": "", + "events.delete": "", + "events.deleteConfirm": "", + "friends.availabilityOf": "", + "registration.title": "", + "registration.estimated": "", + "registration.friends": "", + "registration.beyondHorizon": "", + "registration.summary.available": "", + "registration.summary.partial": "", + "registration.summary.out": "", + "registration.summary.unknown": "", + "team.canPlay": "", + "team.currentWeek": "", + "team.hidden": "", + "team.nextWeek": "", + "team.noSchedule": "", + "team.notAvailable": "", + "team.noWindows": "", + "team.weekHeading": "", + "team.withSub": "", + "picker.title": "", + "picker.free": "", + "picker.noSchedule": "", + "picker.andOthers": "", + "picker.legend.full": "", + "picker.legend.oneShort": "", + "scrims.availableOfRoster": "" +} diff --git a/locales/de/team.json b/locales/de/team.json index eb9f304a8..888a848ab 100644 --- a/locales/de/team.json +++ b/locales/de/team.json @@ -14,6 +14,7 @@ "actionButtons.leaveTeam.confirm": "Verlassen", "actionButtons.editTeam": "Team bearbeiten", "actionButtons.manageRoster": "", + "actionButtons.schedule": "", "actionButtons.deleteTeam": "Team löschen", "actionButtons.deleteTeam.profilePicture": "", "actionButtons.deleteTeam.banner": "", diff --git a/locales/de/tournament.json b/locales/de/tournament.json index 72c36c752..f58a1caa2 100644 --- a/locales/de/tournament.json +++ b/locales/de/tournament.json @@ -57,11 +57,15 @@ "pre.roster.header": "Roster füllen", "pre.roster.footer": "Mindestens {{atLeastCount}} Teammitglieder sind zum Spielen erforderlich. Maximale Rostergröße ist {{maxCount}}", "pre.roster.footer.noSubs": "", - "pre.roster.addFriend.header": "", - "pre.roster.addFriend.others": "", - "pre.roster.delete.button": "Mitglied löschen", - "pre.roster.delete.header": "Zu entfernendes Mitglied", "pre.roster.ignWarning": "", + "pre.roster.quickAdd": "", + "pre.roster.quickAdd.pickup": "", + "pre.roster.quickAdd.addAll": "", + "pre.roster.quickAdd.addAll.confirm": "", + "pre.roster.addMembers": "", + "pre.roster.emptySlot": "", + "pre.roster.emptySlot.optional": "", + "pre.roster.remove.confirm": "", "pre.pool.header": "Arenenpool wählen", "pre.pool.banned": "Gebannt", "pre.pool.tiebreaker.short": "Tiebreaker", @@ -153,7 +157,6 @@ "staff.divider.addedForEvent": "", "staff.editOrganization": "", "actions.addSub": "Ersatzspieler hinzufügen", - "actions.shareLink": "Teile deinen Invite-Link, um Mitglieder hinzuzufügen: {{inviteLink}}", "actions.sub.prompt_one": "Du kannst noch {{count}} Ersatzspieler zu deinem Roster hinzufügen", "actions.sub.prompt_other": "Du kannst noch {{count}} Ersatzspieler zu deinem Roster hinzufügen", "actions.sub.prompt_zero": "Dein Roster ist voll und keine weiteren Ersatzspieler können hinzugefügt werden", diff --git a/locales/en/calendar.json b/locales/en/calendar.json index fa8c84773..7d4c99b54 100644 --- a/locales/en/calendar.json +++ b/locales/en/calendar.json @@ -79,10 +79,11 @@ "forms.draft": "Draft", "forms.draftInfo": "Draft tournaments are hidden and only visible to organizers. The tournament must be opened (by disabling this toggle) before any bracket can be started.", "forms.draftBracketStartBlocked": "Tournament is in draft mode. Edit the tournament and disable the draft toggle before starting the bracket.", - "events.title": "My Events", + "events.title": "My events", "events.view.registered": "Registered", "events.view.hosting": "Hosting", "events.view.scrims": "Scrims", + "events.view.team": "Team", "events.view.saved": "Saved", "events.view.organization": "Organization", "events.empty": "No events in this category", diff --git a/locales/en/common.json b/locales/en/common.json index 652adf023..27ec78f9f 100644 --- a/locales/en/common.json +++ b/locales/en/common.json @@ -108,6 +108,10 @@ "notifications.text.TO_LIKE_RECEIVED": "{{likerUsername}} invited your group in {{tournamentName}}", "notifications.title.TO_LIKE_ACCEPTED": "Group Invitation Accepted", "notifications.text.TO_LIKE_ACCEPTED": "{{accepterUsername}} accepted your group invitation in {{tournamentName}}", + "notifications.title.TEAM_EVENT_ADDED": "New Team Event", + "notifications.text.TEAM_EVENT_ADDED": "{{teamName}} has a new event: {{eventName}}", + "notifications.title.SCHEDULE_TEAM_REMINDER": "Availability Missing", + "notifications.text.SCHEDULE_TEAM_REMINDER": "Your teammates are waiting for you to fill in your availability for the week", "auth.errors.aborted": "Login Aborted", "auth.errors.failed": "Login Failed", "auth.errors.discordPermissions": "For your sendou.ink profile, the site needs access to your Discord profile's name, avatar and social connections.", @@ -358,6 +362,7 @@ "xsearch.link.noScreenshots": "Without a link to the player page the request can not be considered. Screenshots are not necessary unless asked for.", "build.private": "Private", "or": "Or", + "inviteLink": "Invite link", "yes": "Yes", "no": "No", "leaderboard.tabs.players": "Players", diff --git a/locales/en/forms.json b/locales/en/forms.json index 42d1b4a84..69047cabc 100644 --- a/locales/en/forms.json +++ b/locales/en/forms.json @@ -127,7 +127,18 @@ "labels.scrimRequestMessage": "Message", "labels.scrimRequestStartTime": "Start time", "bottomTexts.scrimRequestStartTime": "Select a time within the post's time range", + "labels.duration": "Duration", + "options.duration.30m": "30 minutes", + "options.duration.1h": "1 hour", + "options.duration.1h30m": "1.5 hours", + "options.duration.2h": "2 hours", + "options.duration.2h30m": "2.5 hours", + "options.duration.3h": "3 hours", + "options.duration.4h": "4 hours", + "options.duration.5h": "5 hours", + "options.duration.6h": "6 hours", "errors.dateInPast": "Date can not be in the past", + "errors.dateTooFarAway": "Date is too far in the future", "errors.dateTooEarly": "Date is too early", "errors.dateTooLate": "Date is too late", "errors.dateTooFarInFuture": "Date can not be more than 2 weeks in the future", diff --git a/locales/en/front.json b/locales/en/front.json index 468392bfc..5a460a9c9 100644 --- a/locales/en/front.json +++ b/locales/en/front.json @@ -28,6 +28,8 @@ "sideNav.scrimVs": "vs. {{opponent}}", "sideNav.lookingForScrim": "Looking for scrim", "sideNav.scrimRequestPending": "Request pending", + "sideNav.scheduleNudge": "Add next week's availability", + "sideNav.scheduleNudge.dismiss": "Dismiss", "mobileNav.menu": "Menu", "mobileNav.friends": "Friends", "mobileNav.you": "You", diff --git a/locales/en/schedule.json b/locales/en/schedule.json new file mode 100644 index 000000000..a6c0c704d --- /dev/null +++ b/locales/en/schedule.json @@ -0,0 +1,47 @@ +{ + "commitment.scrim": "Scrim", + "editor.addTime": "Add time", + "editor.copyLastWeek": "Copy last week", + "editor.earlier": "Earlier", + "editor.editDay": "Edit {{day}}", + "editor.later": "Later", + "editor.notFilled": "not filled", + "editor.note": "Note", + "editor.saved": "Availability saved", + "editor.saveWeek": "Save week", + "editor.title": "My availability", + "editor.timesInYourTimezone": "Times in your time zone", + "editor.visibility": "Visible to your teammates and friends", + "events.title": "Team events", + "events.add": "Add event", + "events.addDialogTitle": "Add team event", + "events.membersWillSee": "Members will see this on their calendar.", + "events.none": "No events this week", + "events.delete": "Delete event", + "events.deleteConfirm": "Delete the event {{name}}?", + "friends.availabilityOf": "{{name}}'s availability", + "registration.title": "Availability", + "registration.estimated": "estimated", + "registration.friends": "Friends", + "registration.beyondHorizon": "Schedules for that week open on {{date}}", + "registration.summary.available": "{{amount}} available", + "registration.summary.partial": "{{amount}} partial", + "registration.summary.out": "{{amount}} out", + "registration.summary.unknown": "{{amount}} unknown", + "team.canPlay": "Team can play ({{players}}+)", + "team.currentWeek": "This week", + "team.hidden": "Only team members can see the team schedule", + "team.nextWeek": "Next week", + "team.noSchedule": "No schedule", + "team.notAvailable": "Not available", + "team.noWindows": "No shared free time", + "team.weekHeading": "Week {{week}}", + "team.withSub": "With a sub ({{players}})", + "picker.title": "Pick a start time from your team's schedule", + "picker.free": "{{amount}} free", + "picker.noSchedule": "No schedule this week: {{users}}", + "picker.andOthers": "{{amount}} more", + "picker.legend.full": "{{players}}+ free", + "picker.legend.oneShort": "{{players}} free (sub?)", + "scrims.availableOfRoster": "{{amount}}/{{total}} available" +} diff --git a/locales/en/team.json b/locales/en/team.json index 05904fc34..f3e3673d3 100644 --- a/locales/en/team.json +++ b/locales/en/team.json @@ -14,6 +14,7 @@ "actionButtons.leaveTeam.confirm": "Leave", "actionButtons.editTeam": "Edit Team", "actionButtons.manageRoster": "Manage Members", + "actionButtons.schedule": "Schedule", "actionButtons.deleteTeam": "Delete Team", "actionButtons.deleteTeam.profilePicture": "Remove Profile Picture", "actionButtons.deleteTeam.banner": "Remove Banner", diff --git a/locales/en/tournament.json b/locales/en/tournament.json index 0b225d3a0..cb509888b 100644 --- a/locales/en/tournament.json +++ b/locales/en/tournament.json @@ -57,11 +57,15 @@ "pre.roster.header": "Fill roster", "pre.roster.footer": "At least {{atLeastCount}} members are required to participate. Max roster size is {{maxCount}}.", "pre.roster.footer.noSubs": "Format is {{format}}. No subs allowed.", - "pre.roster.addFriend.header": "Add friends", - "pre.roster.addFriend.others": "Others", - "pre.roster.delete.button": "Delete member", - "pre.roster.delete.header": "Member to delete", "pre.roster.ignWarning": "Note that you are expected to use the in-game names as listed above. Playing in the event with a different name or using the alias feature might result in disqualification.", + "pre.roster.quickAdd": "Quick add", + "pre.roster.quickAdd.pickup": "Pickup", + "pre.roster.quickAdd.addAll": "Add all from {{team}}", + "pre.roster.quickAdd.addAll.confirm": "Add these players from {{team}} to the roster?", + "pre.roster.addMembers": "Add members", + "pre.roster.emptySlot": "Empty slot", + "pre.roster.emptySlot.optional": "Optional slot", + "pre.roster.remove.confirm": "Remove {{name}} from the roster?", "pre.pool.header": "Pick map pool", "pre.pool.banned": "Banned", "pre.pool.tiebreaker.short": "Tiebreaker", @@ -153,7 +157,6 @@ "staff.divider.addedForEvent": "For this event", "staff.editOrganization": "Edit organization", "actions.addSub": "Add sub", - "actions.shareLink": "Share your invite link to add members: {{inviteLink}}", "actions.sub.prompt_other": "You can still add {{count}} subs to your roster", "actions.sub.prompt_one": "You can still add {{count}} sub to your roster", "actions.sub.prompt_zero": "Your roster is full and more subs can't be added", diff --git a/locales/es-ES/calendar.json b/locales/es-ES/calendar.json index 28817cf22..fbe650018 100644 --- a/locales/es-ES/calendar.json +++ b/locales/es-ES/calendar.json @@ -85,6 +85,7 @@ "events.view.registered": "Registrado", "events.view.hosting": "Organizando", "events.view.scrims": "Scrims", + "events.view.team": "", "events.view.saved": "Guardados", "events.view.organization": "Organización", "events.empty": "No hay eventos en esta categoría", diff --git a/locales/es-ES/common.json b/locales/es-ES/common.json index 2fc34a1d1..e3be2b89e 100644 --- a/locales/es-ES/common.json +++ b/locales/es-ES/common.json @@ -108,6 +108,10 @@ "notifications.text.TO_LIKE_RECEIVED": "{{likerUsername}} ha invitado a tu grupo en {{tournamentName}}", "notifications.title.TO_LIKE_ACCEPTED": "Invitación de grupo aceptada", "notifications.text.TO_LIKE_ACCEPTED": "{{accepterUsername}} ha aceptado tu invitación de grupo en {{tournamentName}}", + "notifications.title.TEAM_EVENT_ADDED": "", + "notifications.text.TEAM_EVENT_ADDED": "", + "notifications.title.SCHEDULE_TEAM_REMINDER": "", + "notifications.text.SCHEDULE_TEAM_REMINDER": "", "auth.errors.aborted": "Inicio de Sesión Cancelado", "auth.errors.failed": "Error al Iniciar Sesión", "auth.errors.discordPermissions": "Para tu perfil de sendou.ink, el sitio necesita acceso al nombre, avatar y conexiones sociales de tu perfil de Discord.", @@ -358,6 +362,7 @@ "xsearch.link.noScreenshots": "No se puede tener en cuenta la solicitud sin un enlace a la página de jugador. No hace falta adjuntar capturas a menos que se pidan.", "build.private": "Privado", "or": "O", + "inviteLink": "", "yes": "Sí", "no": "No", "leaderboard.tabs.players": "", diff --git a/locales/es-ES/forms.json b/locales/es-ES/forms.json index 3a06c99f8..1233622a7 100644 --- a/locales/es-ES/forms.json +++ b/locales/es-ES/forms.json @@ -127,7 +127,18 @@ "labels.scrimRequestMessage": "Mensaje", "labels.scrimRequestStartTime": "Hora de inicio", "bottomTexts.scrimRequestStartTime": "Selecciona una hora dentro del rango de tiempo de la publicación", + "labels.duration": "", + "options.duration.30m": "", + "options.duration.1h": "", + "options.duration.1h30m": "", + "options.duration.2h": "", + "options.duration.2h30m": "", + "options.duration.3h": "", + "options.duration.4h": "", + "options.duration.5h": "", + "options.duration.6h": "", "errors.dateInPast": "La fecha no puede ser en el pasado", + "errors.dateTooFarAway": "", "errors.dateTooEarly": "La fecha es demasiado temprana", "errors.dateTooLate": "La fecha es demasiado tarde", "errors.dateTooFarInFuture": "La fecha no puede ser más de 2 semanas en el futuro", diff --git a/locales/es-ES/front.json b/locales/es-ES/front.json index 43a78b935..55d944244 100644 --- a/locales/es-ES/front.json +++ b/locales/es-ES/front.json @@ -28,6 +28,8 @@ "sideNav.scrimVs": "vs. {{opponent}}", "sideNav.lookingForScrim": "Buscando scrim", "sideNav.scrimRequestPending": "Solicitud pendiente", + "sideNav.scheduleNudge": "", + "sideNav.scheduleNudge.dismiss": "", "mobileNav.menu": "Menú", "mobileNav.friends": "Amigos", "mobileNav.you": "Tú", diff --git a/locales/es-ES/schedule.json b/locales/es-ES/schedule.json new file mode 100644 index 000000000..deed72853 --- /dev/null +++ b/locales/es-ES/schedule.json @@ -0,0 +1,47 @@ +{ + "commitment.scrim": "", + "editor.addTime": "", + "editor.copyLastWeek": "", + "editor.earlier": "", + "editor.editDay": "", + "editor.later": "", + "editor.notFilled": "", + "editor.note": "", + "editor.saved": "", + "editor.saveWeek": "", + "editor.title": "", + "editor.timesInYourTimezone": "", + "editor.visibility": "", + "events.title": "", + "events.add": "", + "events.addDialogTitle": "", + "events.membersWillSee": "", + "events.none": "", + "events.delete": "", + "events.deleteConfirm": "", + "friends.availabilityOf": "", + "registration.title": "", + "registration.estimated": "", + "registration.friends": "", + "registration.beyondHorizon": "", + "registration.summary.available": "", + "registration.summary.partial": "", + "registration.summary.out": "", + "registration.summary.unknown": "", + "team.canPlay": "", + "team.currentWeek": "", + "team.hidden": "", + "team.nextWeek": "", + "team.noSchedule": "", + "team.notAvailable": "", + "team.noWindows": "", + "team.weekHeading": "", + "team.withSub": "", + "picker.title": "", + "picker.free": "", + "picker.noSchedule": "", + "picker.andOthers": "", + "picker.legend.full": "", + "picker.legend.oneShort": "", + "scrims.availableOfRoster": "" +} diff --git a/locales/es-ES/team.json b/locales/es-ES/team.json index 4ccfbf1da..84c50023e 100644 --- a/locales/es-ES/team.json +++ b/locales/es-ES/team.json @@ -14,6 +14,7 @@ "actionButtons.leaveTeam.confirm": "Abandonar", "actionButtons.editTeam": "Editar equipo", "actionButtons.manageRoster": "Gestionar miembros", + "actionButtons.schedule": "", "actionButtons.deleteTeam": "Eliminar equipo", "actionButtons.deleteTeam.profilePicture": "Eliminar foto de perfil", "actionButtons.deleteTeam.banner": "Eliminar banner", diff --git a/locales/es-ES/tournament.json b/locales/es-ES/tournament.json index a658e54bc..00271f63a 100644 --- a/locales/es-ES/tournament.json +++ b/locales/es-ES/tournament.json @@ -57,11 +57,15 @@ "pre.roster.header": "Llenar equipo", "pre.roster.footer": "Se requieren al menos {{atLeastCount}} miembros para participar. La cantidad máxima es {{maxCount}}", "pre.roster.footer.noSubs": "El formato es {{format}}. No se permiten subs.", - "pre.roster.addFriend.header": "Añadir amigos", - "pre.roster.addFriend.others": "", - "pre.roster.delete.button": "Borrar miembro", - "pre.roster.delete.header": "Miembro que quieres borrar", "pre.roster.ignWarning": "", + "pre.roster.quickAdd": "", + "pre.roster.quickAdd.pickup": "", + "pre.roster.quickAdd.addAll": "", + "pre.roster.quickAdd.addAll.confirm": "", + "pre.roster.addMembers": "", + "pre.roster.emptySlot": "", + "pre.roster.emptySlot.optional": "", + "pre.roster.remove.confirm": "", "pre.pool.header": "Escoger grupo de mapas", "pre.pool.banned": "Prohibidos", "pre.pool.tiebreaker.short": "Desempate", @@ -154,7 +158,6 @@ "staff.divider.addedForEvent": "Para este evento", "staff.editOrganization": "Editar organización", "actions.addSub": "Añadir sub", - "actions.shareLink": "Comparte enlace de invitación para añadir miembros: {{inviteLink}}", "actions.sub.prompt_one": "Aún puedes añadir {{count}} sub a tu equipo", "actions.sub.prompt_many": "", "actions.sub.prompt_other": "Aún puedes añadir {{count}} subs a tu equipo", diff --git a/locales/es-US/calendar.json b/locales/es-US/calendar.json index 9f4655cdb..41967c393 100644 --- a/locales/es-US/calendar.json +++ b/locales/es-US/calendar.json @@ -85,6 +85,7 @@ "events.view.registered": "Registrado", "events.view.hosting": "Organizando", "events.view.scrims": "Scrims", + "events.view.team": "", "events.view.saved": "Guardados", "events.view.organization": "Organización", "events.empty": "No hay eventos en esta categoría", diff --git a/locales/es-US/common.json b/locales/es-US/common.json index 6ba4902e5..f529281e9 100644 --- a/locales/es-US/common.json +++ b/locales/es-US/common.json @@ -108,6 +108,10 @@ "notifications.text.TO_LIKE_RECEIVED": "{{likerUsername}} ha invitado a tu grupo en {{tournamentName}}", "notifications.title.TO_LIKE_ACCEPTED": "Invitación de grupo aceptada", "notifications.text.TO_LIKE_ACCEPTED": "{{accepterUsername}} ha aceptado tu invitación de grupo en {{tournamentName}}", + "notifications.title.TEAM_EVENT_ADDED": "", + "notifications.text.TEAM_EVENT_ADDED": "", + "notifications.title.SCHEDULE_TEAM_REMINDER": "", + "notifications.text.SCHEDULE_TEAM_REMINDER": "", "auth.errors.aborted": "Ingreso cancelado", "auth.errors.failed": "Ingreso fallido", "auth.errors.discordPermissions": "Para tu perfil en sendou.ink, el sitio requiere acceso a tu nombre en Discord, avatar, y redes sociales.", @@ -358,6 +362,7 @@ "xsearch.link.noScreenshots": "No se puede tener en cuenta la solicitud sin un enlace a la página de jugador. No hace falta adjuntar capturas a menos que se pidan.", "build.private": "Privado", "or": "O", + "inviteLink": "", "yes": "Sí", "no": "No", "leaderboard.tabs.players": "", diff --git a/locales/es-US/forms.json b/locales/es-US/forms.json index 459297dc4..1b8883402 100644 --- a/locales/es-US/forms.json +++ b/locales/es-US/forms.json @@ -127,7 +127,18 @@ "labels.scrimRequestMessage": "Mensaje", "labels.scrimRequestStartTime": "Hora de inicio", "bottomTexts.scrimRequestStartTime": "Selecciona una hora dentro del rango de tiempo de la publicación", + "labels.duration": "", + "options.duration.30m": "", + "options.duration.1h": "", + "options.duration.1h30m": "", + "options.duration.2h": "", + "options.duration.2h30m": "", + "options.duration.3h": "", + "options.duration.4h": "", + "options.duration.5h": "", + "options.duration.6h": "", "errors.dateInPast": "La fecha no puede ser en el pasado", + "errors.dateTooFarAway": "", "errors.dateTooEarly": "La fecha es demasiado temprana", "errors.dateTooLate": "La fecha es demasiado tarde", "errors.dateTooFarInFuture": "La fecha no puede ser más de 2 semanas en el futuro", diff --git a/locales/es-US/front.json b/locales/es-US/front.json index efb962de2..68993ce2f 100644 --- a/locales/es-US/front.json +++ b/locales/es-US/front.json @@ -28,6 +28,8 @@ "sideNav.scrimVs": "vs. {{opponent}}", "sideNav.lookingForScrim": "Buscando scrim", "sideNav.scrimRequestPending": "Solicitud pendiente", + "sideNav.scheduleNudge": "", + "sideNav.scheduleNudge.dismiss": "", "mobileNav.menu": "Menú", "mobileNav.friends": "Amigos", "mobileNav.you": "Tú", diff --git a/locales/es-US/schedule.json b/locales/es-US/schedule.json new file mode 100644 index 000000000..deed72853 --- /dev/null +++ b/locales/es-US/schedule.json @@ -0,0 +1,47 @@ +{ + "commitment.scrim": "", + "editor.addTime": "", + "editor.copyLastWeek": "", + "editor.earlier": "", + "editor.editDay": "", + "editor.later": "", + "editor.notFilled": "", + "editor.note": "", + "editor.saved": "", + "editor.saveWeek": "", + "editor.title": "", + "editor.timesInYourTimezone": "", + "editor.visibility": "", + "events.title": "", + "events.add": "", + "events.addDialogTitle": "", + "events.membersWillSee": "", + "events.none": "", + "events.delete": "", + "events.deleteConfirm": "", + "friends.availabilityOf": "", + "registration.title": "", + "registration.estimated": "", + "registration.friends": "", + "registration.beyondHorizon": "", + "registration.summary.available": "", + "registration.summary.partial": "", + "registration.summary.out": "", + "registration.summary.unknown": "", + "team.canPlay": "", + "team.currentWeek": "", + "team.hidden": "", + "team.nextWeek": "", + "team.noSchedule": "", + "team.notAvailable": "", + "team.noWindows": "", + "team.weekHeading": "", + "team.withSub": "", + "picker.title": "", + "picker.free": "", + "picker.noSchedule": "", + "picker.andOthers": "", + "picker.legend.full": "", + "picker.legend.oneShort": "", + "scrims.availableOfRoster": "" +} diff --git a/locales/es-US/team.json b/locales/es-US/team.json index f03a74656..4528de9d9 100644 --- a/locales/es-US/team.json +++ b/locales/es-US/team.json @@ -14,6 +14,7 @@ "actionButtons.leaveTeam.confirm": "Abandonar", "actionButtons.editTeam": "Editar Equipo", "actionButtons.manageRoster": "Gestionar miembros", + "actionButtons.schedule": "", "actionButtons.deleteTeam": "Eliminar Equipo", "actionButtons.deleteTeam.profilePicture": "Eliminar foto de perfil", "actionButtons.deleteTeam.banner": "Eliminar banner", diff --git a/locales/es-US/tournament.json b/locales/es-US/tournament.json index c21641bf5..bc8f2858e 100644 --- a/locales/es-US/tournament.json +++ b/locales/es-US/tournament.json @@ -57,11 +57,15 @@ "pre.roster.header": "Llenar equipo", "pre.roster.footer": "Se requieren al menos {{atLeastCount}} miembros para participar. La cantidad máxima es {{maxCount}}", "pre.roster.footer.noSubs": "El formato es {{format}}. No se permiten subs.", - "pre.roster.addFriend.header": "Añadir amigos", - "pre.roster.addFriend.others": "", - "pre.roster.delete.button": "Borrar miembro", - "pre.roster.delete.header": "Miembro que quieres borrar", "pre.roster.ignWarning": "", + "pre.roster.quickAdd": "", + "pre.roster.quickAdd.pickup": "", + "pre.roster.quickAdd.addAll": "", + "pre.roster.quickAdd.addAll.confirm": "", + "pre.roster.addMembers": "", + "pre.roster.emptySlot": "", + "pre.roster.emptySlot.optional": "", + "pre.roster.remove.confirm": "", "pre.pool.header": "Escoger grupo de escenarios", "pre.pool.banned": "Prohibidos", "pre.pool.tiebreaker.short": "Desempate", @@ -154,7 +158,6 @@ "staff.divider.addedForEvent": "Para este evento", "staff.editOrganization": "Editar organización", "actions.addSub": "Añadir sub", - "actions.shareLink": "Comparte enlace de invitación para añadir miembros: {{inviteLink}}", "actions.sub.prompt_one": "Aún puedes añadir {{count}} sub a tu equipo", "actions.sub.prompt_many": "", "actions.sub.prompt_other": "Aún puedes añadir {{count}} subs a tu equipo", diff --git a/locales/fr-CA/calendar.json b/locales/fr-CA/calendar.json index 8fbce9bd7..9d5fad56d 100644 --- a/locales/fr-CA/calendar.json +++ b/locales/fr-CA/calendar.json @@ -85,6 +85,7 @@ "events.view.registered": "", "events.view.hosting": "", "events.view.scrims": "", + "events.view.team": "", "events.view.saved": "", "events.view.organization": "", "events.empty": "", diff --git a/locales/fr-CA/common.json b/locales/fr-CA/common.json index 795687d72..f725c62cf 100644 --- a/locales/fr-CA/common.json +++ b/locales/fr-CA/common.json @@ -108,6 +108,10 @@ "notifications.text.TO_LIKE_RECEIVED": "", "notifications.title.TO_LIKE_ACCEPTED": "", "notifications.text.TO_LIKE_ACCEPTED": "", + "notifications.title.TEAM_EVENT_ADDED": "", + "notifications.text.TEAM_EVENT_ADDED": "", + "notifications.title.SCHEDULE_TEAM_REMINDER": "", + "notifications.text.SCHEDULE_TEAM_REMINDER": "", "auth.errors.aborted": "Connexion abandonnée", "auth.errors.failed": "Connexion échouée", "auth.errors.discordPermissions": "Pour mettre en place votre profil, sendou.ink a besoin de votre nom de profil Discord, de votre avatar et de vos réseaux connectés.", @@ -358,6 +362,7 @@ "xsearch.link.noScreenshots": "", "build.private": "Privé", "or": "Ou", + "inviteLink": "", "yes": "Oui", "no": "Non", "leaderboard.tabs.players": "", diff --git a/locales/fr-CA/forms.json b/locales/fr-CA/forms.json index d9c951b84..7cf260a4b 100644 --- a/locales/fr-CA/forms.json +++ b/locales/fr-CA/forms.json @@ -127,7 +127,18 @@ "labels.scrimRequestMessage": "", "labels.scrimRequestStartTime": "", "bottomTexts.scrimRequestStartTime": "", + "labels.duration": "", + "options.duration.30m": "", + "options.duration.1h": "", + "options.duration.1h30m": "", + "options.duration.2h": "", + "options.duration.2h30m": "", + "options.duration.3h": "", + "options.duration.4h": "", + "options.duration.5h": "", + "options.duration.6h": "", "errors.dateInPast": "", + "errors.dateTooFarAway": "", "errors.dateTooEarly": "", "errors.dateTooLate": "", "errors.dateTooFarInFuture": "", diff --git a/locales/fr-CA/front.json b/locales/fr-CA/front.json index df82d5263..50d180a95 100644 --- a/locales/fr-CA/front.json +++ b/locales/fr-CA/front.json @@ -28,6 +28,8 @@ "sideNav.scrimVs": "", "sideNav.lookingForScrim": "", "sideNav.scrimRequestPending": "", + "sideNav.scheduleNudge": "", + "sideNav.scheduleNudge.dismiss": "", "mobileNav.menu": "", "mobileNav.friends": "", "mobileNav.you": "", diff --git a/locales/fr-CA/schedule.json b/locales/fr-CA/schedule.json new file mode 100644 index 000000000..deed72853 --- /dev/null +++ b/locales/fr-CA/schedule.json @@ -0,0 +1,47 @@ +{ + "commitment.scrim": "", + "editor.addTime": "", + "editor.copyLastWeek": "", + "editor.earlier": "", + "editor.editDay": "", + "editor.later": "", + "editor.notFilled": "", + "editor.note": "", + "editor.saved": "", + "editor.saveWeek": "", + "editor.title": "", + "editor.timesInYourTimezone": "", + "editor.visibility": "", + "events.title": "", + "events.add": "", + "events.addDialogTitle": "", + "events.membersWillSee": "", + "events.none": "", + "events.delete": "", + "events.deleteConfirm": "", + "friends.availabilityOf": "", + "registration.title": "", + "registration.estimated": "", + "registration.friends": "", + "registration.beyondHorizon": "", + "registration.summary.available": "", + "registration.summary.partial": "", + "registration.summary.out": "", + "registration.summary.unknown": "", + "team.canPlay": "", + "team.currentWeek": "", + "team.hidden": "", + "team.nextWeek": "", + "team.noSchedule": "", + "team.notAvailable": "", + "team.noWindows": "", + "team.weekHeading": "", + "team.withSub": "", + "picker.title": "", + "picker.free": "", + "picker.noSchedule": "", + "picker.andOthers": "", + "picker.legend.full": "", + "picker.legend.oneShort": "", + "scrims.availableOfRoster": "" +} diff --git a/locales/fr-CA/team.json b/locales/fr-CA/team.json index 2e6bdab4d..41530bc02 100644 --- a/locales/fr-CA/team.json +++ b/locales/fr-CA/team.json @@ -14,6 +14,7 @@ "actionButtons.leaveTeam.confirm": "Quitter", "actionButtons.editTeam": "Modifier l'équipe", "actionButtons.manageRoster": "", + "actionButtons.schedule": "", "actionButtons.deleteTeam": "Supprimer l'équipe", "actionButtons.deleteTeam.profilePicture": "", "actionButtons.deleteTeam.banner": "", diff --git a/locales/fr-CA/tournament.json b/locales/fr-CA/tournament.json index 9de3b4e3c..584380ef8 100644 --- a/locales/fr-CA/tournament.json +++ b/locales/fr-CA/tournament.json @@ -57,11 +57,15 @@ "pre.roster.header": "Remplir la liste", "pre.roster.footer": "Au moins {{atLeastCount}} membres sont requis pour participer. La taille maximum est de {{maxCount}}", "pre.roster.footer.noSubs": "", - "pre.roster.addFriend.header": "", - "pre.roster.addFriend.others": "", - "pre.roster.delete.button": "Effacer membre", - "pre.roster.delete.header": "Membre à effacer", "pre.roster.ignWarning": "", + "pre.roster.quickAdd": "", + "pre.roster.quickAdd.pickup": "", + "pre.roster.quickAdd.addAll": "", + "pre.roster.quickAdd.addAll.confirm": "", + "pre.roster.addMembers": "", + "pre.roster.emptySlot": "", + "pre.roster.emptySlot.optional": "", + "pre.roster.remove.confirm": "", "pre.pool.header": "Sélection de stage", "pre.pool.banned": "", "pre.pool.tiebreaker.short": "", @@ -154,7 +158,6 @@ "staff.divider.addedForEvent": "", "staff.editOrganization": "", "actions.addSub": "Ajouter remplaçant", - "actions.shareLink": "Partagez votre lien d'invitation pour ajouter des membres: {{inviteLink}}", "actions.sub.prompt_one": "Vous pouvez encore ajouter {{count}} remplaçant à votre liste", "actions.sub.prompt_many": "", "actions.sub.prompt_other": "Vous pouvez encore ajouter {{count}} remplaçants à votre liste", diff --git a/locales/fr-EU/calendar.json b/locales/fr-EU/calendar.json index 8fbce9bd7..9d5fad56d 100644 --- a/locales/fr-EU/calendar.json +++ b/locales/fr-EU/calendar.json @@ -85,6 +85,7 @@ "events.view.registered": "", "events.view.hosting": "", "events.view.scrims": "", + "events.view.team": "", "events.view.saved": "", "events.view.organization": "", "events.empty": "", diff --git a/locales/fr-EU/common.json b/locales/fr-EU/common.json index b448e6979..a832c75d7 100644 --- a/locales/fr-EU/common.json +++ b/locales/fr-EU/common.json @@ -108,6 +108,10 @@ "notifications.text.TO_LIKE_RECEIVED": "", "notifications.title.TO_LIKE_ACCEPTED": "", "notifications.text.TO_LIKE_ACCEPTED": "", + "notifications.title.TEAM_EVENT_ADDED": "", + "notifications.text.TEAM_EVENT_ADDED": "", + "notifications.title.SCHEDULE_TEAM_REMINDER": "", + "notifications.text.SCHEDULE_TEAM_REMINDER": "", "auth.errors.aborted": "Connexion abandonnée", "auth.errors.failed": "Connexion échouée", "auth.errors.discordPermissions": "Pour mettre en place votre profil, sendou.ink a besoin de votre nom de profil Discord, de votre avatar et de vos réseaux connectés.", @@ -358,6 +362,7 @@ "xsearch.link.noScreenshots": "", "build.private": "Privé", "or": "Ou", + "inviteLink": "", "yes": "Oui", "no": "Non", "leaderboard.tabs.players": "", diff --git a/locales/fr-EU/forms.json b/locales/fr-EU/forms.json index 90f2f257b..e3e091e3b 100644 --- a/locales/fr-EU/forms.json +++ b/locales/fr-EU/forms.json @@ -127,7 +127,18 @@ "labels.scrimRequestMessage": "", "labels.scrimRequestStartTime": "", "bottomTexts.scrimRequestStartTime": "", + "labels.duration": "", + "options.duration.30m": "", + "options.duration.1h": "", + "options.duration.1h30m": "", + "options.duration.2h": "", + "options.duration.2h30m": "", + "options.duration.3h": "", + "options.duration.4h": "", + "options.duration.5h": "", + "options.duration.6h": "", "errors.dateInPast": "", + "errors.dateTooFarAway": "", "errors.dateTooEarly": "", "errors.dateTooLate": "", "errors.dateTooFarInFuture": "", diff --git a/locales/fr-EU/front.json b/locales/fr-EU/front.json index faaec0026..f7194efda 100644 --- a/locales/fr-EU/front.json +++ b/locales/fr-EU/front.json @@ -28,6 +28,8 @@ "sideNav.scrimVs": "", "sideNav.lookingForScrim": "", "sideNav.scrimRequestPending": "", + "sideNav.scheduleNudge": "", + "sideNav.scheduleNudge.dismiss": "", "mobileNav.menu": "", "mobileNav.friends": "", "mobileNav.you": "", diff --git a/locales/fr-EU/schedule.json b/locales/fr-EU/schedule.json new file mode 100644 index 000000000..deed72853 --- /dev/null +++ b/locales/fr-EU/schedule.json @@ -0,0 +1,47 @@ +{ + "commitment.scrim": "", + "editor.addTime": "", + "editor.copyLastWeek": "", + "editor.earlier": "", + "editor.editDay": "", + "editor.later": "", + "editor.notFilled": "", + "editor.note": "", + "editor.saved": "", + "editor.saveWeek": "", + "editor.title": "", + "editor.timesInYourTimezone": "", + "editor.visibility": "", + "events.title": "", + "events.add": "", + "events.addDialogTitle": "", + "events.membersWillSee": "", + "events.none": "", + "events.delete": "", + "events.deleteConfirm": "", + "friends.availabilityOf": "", + "registration.title": "", + "registration.estimated": "", + "registration.friends": "", + "registration.beyondHorizon": "", + "registration.summary.available": "", + "registration.summary.partial": "", + "registration.summary.out": "", + "registration.summary.unknown": "", + "team.canPlay": "", + "team.currentWeek": "", + "team.hidden": "", + "team.nextWeek": "", + "team.noSchedule": "", + "team.notAvailable": "", + "team.noWindows": "", + "team.weekHeading": "", + "team.withSub": "", + "picker.title": "", + "picker.free": "", + "picker.noSchedule": "", + "picker.andOthers": "", + "picker.legend.full": "", + "picker.legend.oneShort": "", + "scrims.availableOfRoster": "" +} diff --git a/locales/fr-EU/team.json b/locales/fr-EU/team.json index 0e64ac75a..a240a8335 100644 --- a/locales/fr-EU/team.json +++ b/locales/fr-EU/team.json @@ -14,6 +14,7 @@ "actionButtons.leaveTeam.confirm": "Quitter", "actionButtons.editTeam": "Modifier l'équipe", "actionButtons.manageRoster": "", + "actionButtons.schedule": "", "actionButtons.deleteTeam": "Supprimer l'équipe", "actionButtons.deleteTeam.profilePicture": "", "actionButtons.deleteTeam.banner": "", diff --git a/locales/fr-EU/tournament.json b/locales/fr-EU/tournament.json index 8ba5e4c73..2f83aeb47 100644 --- a/locales/fr-EU/tournament.json +++ b/locales/fr-EU/tournament.json @@ -57,11 +57,15 @@ "pre.roster.header": "Remplir la liste", "pre.roster.footer": "Au moins {{atLeastCount}} membres sont requis pour participer. La taille maximum est de {{maxCount}}", "pre.roster.footer.noSubs": "", - "pre.roster.addFriend.header": "", - "pre.roster.addFriend.others": "", - "pre.roster.delete.button": "Effacer membre", - "pre.roster.delete.header": "Membre à effacer", "pre.roster.ignWarning": "", + "pre.roster.quickAdd": "", + "pre.roster.quickAdd.pickup": "", + "pre.roster.quickAdd.addAll": "", + "pre.roster.quickAdd.addAll.confirm": "", + "pre.roster.addMembers": "", + "pre.roster.emptySlot": "", + "pre.roster.emptySlot.optional": "", + "pre.roster.remove.confirm": "", "pre.pool.header": "Sélection de stage", "pre.pool.banned": "Bannis", "pre.pool.tiebreaker.short": "Manche décisive", @@ -154,7 +158,6 @@ "staff.divider.addedForEvent": "", "staff.editOrganization": "", "actions.addSub": "Ajouter remplaçant", - "actions.shareLink": "Partagez votre lien d'invitation pour ajouter des membres: {{inviteLink}}", "actions.sub.prompt_one": "Vous pouvez encore ajouter {{count}} remplaçant à votre liste", "actions.sub.prompt_many": "", "actions.sub.prompt_other": "Vous pouvez encore ajouter {{count}} remplaçants à votre liste", diff --git a/locales/he/calendar.json b/locales/he/calendar.json index da78a0b78..50f7c8457 100644 --- a/locales/he/calendar.json +++ b/locales/he/calendar.json @@ -85,6 +85,7 @@ "events.view.registered": "", "events.view.hosting": "", "events.view.scrims": "", + "events.view.team": "", "events.view.saved": "", "events.view.organization": "", "events.empty": "", diff --git a/locales/he/common.json b/locales/he/common.json index e1079807a..a28d18b2f 100644 --- a/locales/he/common.json +++ b/locales/he/common.json @@ -108,6 +108,10 @@ "notifications.text.TO_LIKE_RECEIVED": "", "notifications.title.TO_LIKE_ACCEPTED": "", "notifications.text.TO_LIKE_ACCEPTED": "", + "notifications.title.TEAM_EVENT_ADDED": "", + "notifications.text.TEAM_EVENT_ADDED": "", + "notifications.title.SCHEDULE_TEAM_REMINDER": "", + "notifications.text.SCHEDULE_TEAM_REMINDER": "", "auth.errors.aborted": "הכניסה בוטלה", "auth.errors.failed": "הכניסה נכשלה", "auth.errors.discordPermissions": "עבור פרופיל sendou.ink שלך, האתר זקוק לגישה לשם, הפרופיל והקשרים החברתיים של פרופיל ה-Discord שלך.", @@ -358,6 +362,7 @@ "xsearch.link.noScreenshots": "", "build.private": "פרטי", "or": "או", + "inviteLink": "", "yes": "כן", "no": "לא", "leaderboard.tabs.players": "", diff --git a/locales/he/forms.json b/locales/he/forms.json index 1a5885194..e9548f971 100644 --- a/locales/he/forms.json +++ b/locales/he/forms.json @@ -127,7 +127,18 @@ "labels.scrimRequestMessage": "", "labels.scrimRequestStartTime": "", "bottomTexts.scrimRequestStartTime": "", + "labels.duration": "", + "options.duration.30m": "", + "options.duration.1h": "", + "options.duration.1h30m": "", + "options.duration.2h": "", + "options.duration.2h30m": "", + "options.duration.3h": "", + "options.duration.4h": "", + "options.duration.5h": "", + "options.duration.6h": "", "errors.dateInPast": "", + "errors.dateTooFarAway": "", "errors.dateTooEarly": "", "errors.dateTooLate": "", "errors.dateTooFarInFuture": "", diff --git a/locales/he/front.json b/locales/he/front.json index df82d5263..50d180a95 100644 --- a/locales/he/front.json +++ b/locales/he/front.json @@ -28,6 +28,8 @@ "sideNav.scrimVs": "", "sideNav.lookingForScrim": "", "sideNav.scrimRequestPending": "", + "sideNav.scheduleNudge": "", + "sideNav.scheduleNudge.dismiss": "", "mobileNav.menu": "", "mobileNav.friends": "", "mobileNav.you": "", diff --git a/locales/he/schedule.json b/locales/he/schedule.json new file mode 100644 index 000000000..deed72853 --- /dev/null +++ b/locales/he/schedule.json @@ -0,0 +1,47 @@ +{ + "commitment.scrim": "", + "editor.addTime": "", + "editor.copyLastWeek": "", + "editor.earlier": "", + "editor.editDay": "", + "editor.later": "", + "editor.notFilled": "", + "editor.note": "", + "editor.saved": "", + "editor.saveWeek": "", + "editor.title": "", + "editor.timesInYourTimezone": "", + "editor.visibility": "", + "events.title": "", + "events.add": "", + "events.addDialogTitle": "", + "events.membersWillSee": "", + "events.none": "", + "events.delete": "", + "events.deleteConfirm": "", + "friends.availabilityOf": "", + "registration.title": "", + "registration.estimated": "", + "registration.friends": "", + "registration.beyondHorizon": "", + "registration.summary.available": "", + "registration.summary.partial": "", + "registration.summary.out": "", + "registration.summary.unknown": "", + "team.canPlay": "", + "team.currentWeek": "", + "team.hidden": "", + "team.nextWeek": "", + "team.noSchedule": "", + "team.notAvailable": "", + "team.noWindows": "", + "team.weekHeading": "", + "team.withSub": "", + "picker.title": "", + "picker.free": "", + "picker.noSchedule": "", + "picker.andOthers": "", + "picker.legend.full": "", + "picker.legend.oneShort": "", + "scrims.availableOfRoster": "" +} diff --git a/locales/he/team.json b/locales/he/team.json index be7f271ae..327171e6e 100644 --- a/locales/he/team.json +++ b/locales/he/team.json @@ -14,6 +14,7 @@ "actionButtons.leaveTeam.confirm": "לעזוב", "actionButtons.editTeam": "עריכת צוות", "actionButtons.manageRoster": "", + "actionButtons.schedule": "", "actionButtons.deleteTeam": "מחיקת צוות", "actionButtons.deleteTeam.profilePicture": "", "actionButtons.deleteTeam.banner": "", diff --git a/locales/he/tournament.json b/locales/he/tournament.json index 3555e5576..1f44bc75c 100644 --- a/locales/he/tournament.json +++ b/locales/he/tournament.json @@ -57,11 +57,15 @@ "pre.roster.header": "מלא צוות", "pre.roster.footer": "לפחות {{atLeastCount}} חברי צוות נדרשים כדי להשתתף. גודל הצוות המרבי הוא {{maxCount}}", "pre.roster.footer.noSubs": "", - "pre.roster.addFriend.header": "", - "pre.roster.addFriend.others": "", - "pre.roster.delete.button": "מחקו חבר צוות", - "pre.roster.delete.header": "חבר צוות למחיקה", "pre.roster.ignWarning": "", + "pre.roster.quickAdd": "", + "pre.roster.quickAdd.pickup": "", + "pre.roster.quickAdd.addAll": "", + "pre.roster.quickAdd.addAll.confirm": "", + "pre.roster.addMembers": "", + "pre.roster.emptySlot": "", + "pre.roster.emptySlot.optional": "", + "pre.roster.remove.confirm": "", "pre.pool.header": "בחרו מאגר מפות", "pre.pool.banned": "", "pre.pool.tiebreaker.short": "", @@ -154,7 +158,6 @@ "staff.divider.addedForEvent": "", "staff.editOrganization": "", "actions.addSub": "הוסיפו ממלא מקום", - "actions.shareLink": "שתפו קישור הזמנה להוספת חברי צוות: {{inviteLink}}", "actions.sub.prompt_one": "אתם עדיין יכולים להוסיף {{count}} ממלא מקום לצוות שלכם", "actions.sub.prompt_two": "", "actions.sub.prompt_other": "אתם עדיין יכולים להוסיף {{count}} ממלאי מקום לצוות שלכם", diff --git a/locales/it/calendar.json b/locales/it/calendar.json index fb89f677b..5f2187a38 100644 --- a/locales/it/calendar.json +++ b/locales/it/calendar.json @@ -85,6 +85,7 @@ "events.view.registered": "", "events.view.hosting": "", "events.view.scrims": "", + "events.view.team": "", "events.view.saved": "", "events.view.organization": "", "events.empty": "", diff --git a/locales/it/common.json b/locales/it/common.json index 9604f1b35..6125a6114 100644 --- a/locales/it/common.json +++ b/locales/it/common.json @@ -108,6 +108,10 @@ "notifications.text.TO_LIKE_RECEIVED": "", "notifications.title.TO_LIKE_ACCEPTED": "", "notifications.text.TO_LIKE_ACCEPTED": "", + "notifications.title.TEAM_EVENT_ADDED": "", + "notifications.text.TEAM_EVENT_ADDED": "", + "notifications.title.SCHEDULE_TEAM_REMINDER": "", + "notifications.text.SCHEDULE_TEAM_REMINDER": "", "auth.errors.aborted": "Accesso cancellato", "auth.errors.failed": "Accesso fallito", "auth.errors.discordPermissions": "Per il tuo profilo di sendou.ink, il sito ha bisogno di accesso al nome utente, avatar e connessioni social del tuo profilo Discord.", @@ -358,6 +362,7 @@ "xsearch.link.noScreenshots": "", "build.private": "Privato", "or": "O", + "inviteLink": "", "yes": "Sì", "no": "No", "leaderboard.tabs.players": "", diff --git a/locales/it/forms.json b/locales/it/forms.json index afc04ae48..5ac452fd8 100644 --- a/locales/it/forms.json +++ b/locales/it/forms.json @@ -127,7 +127,18 @@ "labels.scrimRequestMessage": "", "labels.scrimRequestStartTime": "", "bottomTexts.scrimRequestStartTime": "", + "labels.duration": "", + "options.duration.30m": "", + "options.duration.1h": "", + "options.duration.1h30m": "", + "options.duration.2h": "", + "options.duration.2h30m": "", + "options.duration.3h": "", + "options.duration.4h": "", + "options.duration.5h": "", + "options.duration.6h": "", "errors.dateInPast": "", + "errors.dateTooFarAway": "", "errors.dateTooEarly": "", "errors.dateTooLate": "", "errors.dateTooFarInFuture": "", diff --git a/locales/it/front.json b/locales/it/front.json index 2f1b62a1d..159266a35 100644 --- a/locales/it/front.json +++ b/locales/it/front.json @@ -28,6 +28,8 @@ "sideNav.scrimVs": "", "sideNav.lookingForScrim": "", "sideNav.scrimRequestPending": "", + "sideNav.scheduleNudge": "", + "sideNav.scheduleNudge.dismiss": "", "mobileNav.menu": "", "mobileNav.friends": "", "mobileNav.you": "", diff --git a/locales/it/schedule.json b/locales/it/schedule.json new file mode 100644 index 000000000..deed72853 --- /dev/null +++ b/locales/it/schedule.json @@ -0,0 +1,47 @@ +{ + "commitment.scrim": "", + "editor.addTime": "", + "editor.copyLastWeek": "", + "editor.earlier": "", + "editor.editDay": "", + "editor.later": "", + "editor.notFilled": "", + "editor.note": "", + "editor.saved": "", + "editor.saveWeek": "", + "editor.title": "", + "editor.timesInYourTimezone": "", + "editor.visibility": "", + "events.title": "", + "events.add": "", + "events.addDialogTitle": "", + "events.membersWillSee": "", + "events.none": "", + "events.delete": "", + "events.deleteConfirm": "", + "friends.availabilityOf": "", + "registration.title": "", + "registration.estimated": "", + "registration.friends": "", + "registration.beyondHorizon": "", + "registration.summary.available": "", + "registration.summary.partial": "", + "registration.summary.out": "", + "registration.summary.unknown": "", + "team.canPlay": "", + "team.currentWeek": "", + "team.hidden": "", + "team.nextWeek": "", + "team.noSchedule": "", + "team.notAvailable": "", + "team.noWindows": "", + "team.weekHeading": "", + "team.withSub": "", + "picker.title": "", + "picker.free": "", + "picker.noSchedule": "", + "picker.andOthers": "", + "picker.legend.full": "", + "picker.legend.oneShort": "", + "scrims.availableOfRoster": "" +} diff --git a/locales/it/team.json b/locales/it/team.json index bdebb8191..a8d6605a7 100644 --- a/locales/it/team.json +++ b/locales/it/team.json @@ -14,6 +14,7 @@ "actionButtons.leaveTeam.confirm": "Lascia", "actionButtons.editTeam": "Modifica team", "actionButtons.manageRoster": "", + "actionButtons.schedule": "", "actionButtons.deleteTeam": "Delete team", "actionButtons.deleteTeam.profilePicture": "", "actionButtons.deleteTeam.banner": "", diff --git a/locales/it/tournament.json b/locales/it/tournament.json index 3c6e54276..cc87be634 100644 --- a/locales/it/tournament.json +++ b/locales/it/tournament.json @@ -57,11 +57,15 @@ "pre.roster.header": "Riempi roster", "pre.roster.footer": "Sono necessari almeno {{atLeastCount}} membri per partecipare. La dimensione massima del roster è {{maxCount}}", "pre.roster.footer.noSubs": "", - "pre.roster.addFriend.header": "", - "pre.roster.addFriend.others": "", - "pre.roster.delete.button": "Elimina membro", - "pre.roster.delete.header": "Membro da eliminare", "pre.roster.ignWarning": "", + "pre.roster.quickAdd": "", + "pre.roster.quickAdd.pickup": "", + "pre.roster.quickAdd.addAll": "", + "pre.roster.quickAdd.addAll.confirm": "", + "pre.roster.addMembers": "", + "pre.roster.emptySlot": "", + "pre.roster.emptySlot.optional": "", + "pre.roster.remove.confirm": "", "pre.pool.header": "Scegli pool mappe", "pre.pool.banned": "Banneta", "pre.pool.tiebreaker.short": "Spareggio", @@ -154,7 +158,6 @@ "staff.divider.addedForEvent": "", "staff.editOrganization": "", "actions.addSub": "Aggiungi sub", - "actions.shareLink": "Condividi il tuo link d'invito per aggiungere membri: {{inviteLink}}", "actions.sub.prompt_one": "Puoi ancora aggiungere {{count}} sub al tuo roster", "actions.sub.prompt_many": "", "actions.sub.prompt_other": "Puoi ancora aggiungere {{count}} sub al tuo roster", diff --git a/locales/ja/calendar.json b/locales/ja/calendar.json index 2f70e6b11..f52c6dfc6 100644 --- a/locales/ja/calendar.json +++ b/locales/ja/calendar.json @@ -81,6 +81,7 @@ "events.view.registered": "参加", "events.view.hosting": "運営", "events.view.scrims": "対抗戦", + "events.view.team": "", "events.view.saved": "保存済み", "events.view.organization": "", "events.empty": "イベントはありません", diff --git a/locales/ja/common.json b/locales/ja/common.json index e57a72e53..77c7c44c2 100644 --- a/locales/ja/common.json +++ b/locales/ja/common.json @@ -108,6 +108,10 @@ "notifications.text.TO_LIKE_RECEIVED": "{{likerUsername}}があなたのグループを{{tournamentName}}に招待しました", "notifications.title.TO_LIKE_ACCEPTED": "招待が承諾されました", "notifications.text.TO_LIKE_ACCEPTED": "{{accepterUsername}}が{{tournamentName}}への招待が承諾されました", + "notifications.title.TEAM_EVENT_ADDED": "", + "notifications.text.TEAM_EVENT_ADDED": "", + "notifications.title.SCHEDULE_TEAM_REMINDER": "", + "notifications.text.SCHEDULE_TEAM_REMINDER": "", "auth.errors.aborted": "ログインを中断しました", "auth.errors.failed": "ログインに失敗しました", "auth.errors.discordPermissions": "sendou.ink プロファイルを作成するには、Discord 名、アバター、そしてSNSの連携が必要です。", @@ -358,6 +362,7 @@ "xsearch.link.noScreenshots": "", "build.private": "非公開", "or": "または", + "inviteLink": "", "yes": "はい", "no": "いいえ", "leaderboard.tabs.players": "", diff --git a/locales/ja/forms.json b/locales/ja/forms.json index 138b164be..397f79646 100644 --- a/locales/ja/forms.json +++ b/locales/ja/forms.json @@ -127,7 +127,18 @@ "labels.scrimRequestMessage": "", "labels.scrimRequestStartTime": "", "bottomTexts.scrimRequestStartTime": "", + "labels.duration": "", + "options.duration.30m": "", + "options.duration.1h": "", + "options.duration.1h30m": "", + "options.duration.2h": "", + "options.duration.2h30m": "", + "options.duration.3h": "", + "options.duration.4h": "", + "options.duration.5h": "", + "options.duration.6h": "", "errors.dateInPast": "", + "errors.dateTooFarAway": "", "errors.dateTooEarly": "", "errors.dateTooLate": "", "errors.dateTooFarInFuture": "", diff --git a/locales/ja/front.json b/locales/ja/front.json index df82d5263..50d180a95 100644 --- a/locales/ja/front.json +++ b/locales/ja/front.json @@ -28,6 +28,8 @@ "sideNav.scrimVs": "", "sideNav.lookingForScrim": "", "sideNav.scrimRequestPending": "", + "sideNav.scheduleNudge": "", + "sideNav.scheduleNudge.dismiss": "", "mobileNav.menu": "", "mobileNav.friends": "", "mobileNav.you": "", diff --git a/locales/ja/schedule.json b/locales/ja/schedule.json new file mode 100644 index 000000000..deed72853 --- /dev/null +++ b/locales/ja/schedule.json @@ -0,0 +1,47 @@ +{ + "commitment.scrim": "", + "editor.addTime": "", + "editor.copyLastWeek": "", + "editor.earlier": "", + "editor.editDay": "", + "editor.later": "", + "editor.notFilled": "", + "editor.note": "", + "editor.saved": "", + "editor.saveWeek": "", + "editor.title": "", + "editor.timesInYourTimezone": "", + "editor.visibility": "", + "events.title": "", + "events.add": "", + "events.addDialogTitle": "", + "events.membersWillSee": "", + "events.none": "", + "events.delete": "", + "events.deleteConfirm": "", + "friends.availabilityOf": "", + "registration.title": "", + "registration.estimated": "", + "registration.friends": "", + "registration.beyondHorizon": "", + "registration.summary.available": "", + "registration.summary.partial": "", + "registration.summary.out": "", + "registration.summary.unknown": "", + "team.canPlay": "", + "team.currentWeek": "", + "team.hidden": "", + "team.nextWeek": "", + "team.noSchedule": "", + "team.notAvailable": "", + "team.noWindows": "", + "team.weekHeading": "", + "team.withSub": "", + "picker.title": "", + "picker.free": "", + "picker.noSchedule": "", + "picker.andOthers": "", + "picker.legend.full": "", + "picker.legend.oneShort": "", + "scrims.availableOfRoster": "" +} diff --git a/locales/ja/team.json b/locales/ja/team.json index 967acf2cb..15a08796d 100644 --- a/locales/ja/team.json +++ b/locales/ja/team.json @@ -14,6 +14,7 @@ "actionButtons.leaveTeam.confirm": "チームを抜ける", "actionButtons.editTeam": "チームを編集", "actionButtons.manageRoster": "", + "actionButtons.schedule": "", "actionButtons.deleteTeam": "チームを削除", "actionButtons.deleteTeam.profilePicture": "", "actionButtons.deleteTeam.banner": "", diff --git a/locales/ja/tournament.json b/locales/ja/tournament.json index 030b0d28b..7bee31700 100644 --- a/locales/ja/tournament.json +++ b/locales/ja/tournament.json @@ -57,11 +57,15 @@ "pre.roster.header": "参加プレイヤーを登録", "pre.roster.footer": "少なくとも {{atLeastCount}} 人の参加が必要です。最大メンバー数は {{maxCount}} です。", "pre.roster.footer.noSubs": "", - "pre.roster.addFriend.header": "", - "pre.roster.addFriend.others": "", - "pre.roster.delete.button": "メンバーを削除する", - "pre.roster.delete.header": "削除するメンバー", "pre.roster.ignWarning": "", + "pre.roster.quickAdd": "", + "pre.roster.quickAdd.pickup": "", + "pre.roster.quickAdd.addAll": "", + "pre.roster.quickAdd.addAll.confirm": "", + "pre.roster.addMembers": "", + "pre.roster.emptySlot": "", + "pre.roster.emptySlot.optional": "", + "pre.roster.remove.confirm": "", "pre.pool.header": "マッププールを選択する", "pre.pool.banned": "禁止", "pre.pool.tiebreaker.short": "タイブレイカー", @@ -151,7 +155,6 @@ "staff.divider.addedForEvent": "", "staff.editOrganization": "", "actions.addSub": "サブを追加", - "actions.shareLink": "メンバー招待リンクをシェアする: {{inviteLink}}", "actions.sub.prompt_zero": "メンバーが上限に達しているので、これ以上サブを追加することができません", "actions.finalize": "", "actions.finalize.button": "", diff --git a/locales/ko/calendar.json b/locales/ko/calendar.json index aad8656c7..43aebe643 100644 --- a/locales/ko/calendar.json +++ b/locales/ko/calendar.json @@ -79,6 +79,7 @@ "events.view.registered": "", "events.view.hosting": "", "events.view.scrims": "", + "events.view.team": "", "events.view.saved": "", "events.view.organization": "", "events.empty": "", diff --git a/locales/ko/common.json b/locales/ko/common.json index 951475ac9..e7bb1c6bb 100644 --- a/locales/ko/common.json +++ b/locales/ko/common.json @@ -108,6 +108,10 @@ "notifications.text.TO_LIKE_RECEIVED": "", "notifications.title.TO_LIKE_ACCEPTED": "", "notifications.text.TO_LIKE_ACCEPTED": "", + "notifications.title.TEAM_EVENT_ADDED": "", + "notifications.text.TEAM_EVENT_ADDED": "", + "notifications.title.SCHEDULE_TEAM_REMINDER": "", + "notifications.text.SCHEDULE_TEAM_REMINDER": "", "auth.errors.aborted": "로그인 중단됨", "auth.errors.failed": "로그인 실패", "auth.errors.discordPermissions": "sendou.ink 프로필을 위해 디스코드 프로필의 이름, 아바타와 연락처에 대한 접근이 필요합니다.", @@ -358,6 +362,7 @@ "xsearch.link.noScreenshots": "", "build.private": "Private", "or": "또는", + "inviteLink": "", "yes": "네", "no": "아니오", "leaderboard.tabs.players": "", diff --git a/locales/ko/forms.json b/locales/ko/forms.json index ce8bb30c3..abceebdc2 100644 --- a/locales/ko/forms.json +++ b/locales/ko/forms.json @@ -127,7 +127,18 @@ "labels.scrimRequestMessage": "", "labels.scrimRequestStartTime": "", "bottomTexts.scrimRequestStartTime": "", + "labels.duration": "", + "options.duration.30m": "", + "options.duration.1h": "", + "options.duration.1h30m": "", + "options.duration.2h": "", + "options.duration.2h30m": "", + "options.duration.3h": "", + "options.duration.4h": "", + "options.duration.5h": "", + "options.duration.6h": "", "errors.dateInPast": "", + "errors.dateTooFarAway": "", "errors.dateTooEarly": "", "errors.dateTooLate": "", "errors.dateTooFarInFuture": "", diff --git a/locales/ko/front.json b/locales/ko/front.json index df82d5263..50d180a95 100644 --- a/locales/ko/front.json +++ b/locales/ko/front.json @@ -28,6 +28,8 @@ "sideNav.scrimVs": "", "sideNav.lookingForScrim": "", "sideNav.scrimRequestPending": "", + "sideNav.scheduleNudge": "", + "sideNav.scheduleNudge.dismiss": "", "mobileNav.menu": "", "mobileNav.friends": "", "mobileNav.you": "", diff --git a/locales/ko/schedule.json b/locales/ko/schedule.json new file mode 100644 index 000000000..deed72853 --- /dev/null +++ b/locales/ko/schedule.json @@ -0,0 +1,47 @@ +{ + "commitment.scrim": "", + "editor.addTime": "", + "editor.copyLastWeek": "", + "editor.earlier": "", + "editor.editDay": "", + "editor.later": "", + "editor.notFilled": "", + "editor.note": "", + "editor.saved": "", + "editor.saveWeek": "", + "editor.title": "", + "editor.timesInYourTimezone": "", + "editor.visibility": "", + "events.title": "", + "events.add": "", + "events.addDialogTitle": "", + "events.membersWillSee": "", + "events.none": "", + "events.delete": "", + "events.deleteConfirm": "", + "friends.availabilityOf": "", + "registration.title": "", + "registration.estimated": "", + "registration.friends": "", + "registration.beyondHorizon": "", + "registration.summary.available": "", + "registration.summary.partial": "", + "registration.summary.out": "", + "registration.summary.unknown": "", + "team.canPlay": "", + "team.currentWeek": "", + "team.hidden": "", + "team.nextWeek": "", + "team.noSchedule": "", + "team.notAvailable": "", + "team.noWindows": "", + "team.weekHeading": "", + "team.withSub": "", + "picker.title": "", + "picker.free": "", + "picker.noSchedule": "", + "picker.andOthers": "", + "picker.legend.full": "", + "picker.legend.oneShort": "", + "scrims.availableOfRoster": "" +} diff --git a/locales/ko/team.json b/locales/ko/team.json index 7e13669ec..59642bfe4 100644 --- a/locales/ko/team.json +++ b/locales/ko/team.json @@ -14,6 +14,7 @@ "actionButtons.leaveTeam.confirm": "", "actionButtons.editTeam": "", "actionButtons.manageRoster": "", + "actionButtons.schedule": "", "actionButtons.deleteTeam": "", "actionButtons.deleteTeam.profilePicture": "", "actionButtons.deleteTeam.banner": "", diff --git a/locales/ko/tournament.json b/locales/ko/tournament.json index bef3bf695..4f1f4f42d 100644 --- a/locales/ko/tournament.json +++ b/locales/ko/tournament.json @@ -57,11 +57,15 @@ "pre.roster.header": "", "pre.roster.footer": "", "pre.roster.footer.noSubs": "", - "pre.roster.addFriend.header": "", - "pre.roster.addFriend.others": "", - "pre.roster.delete.button": "", - "pre.roster.delete.header": "", "pre.roster.ignWarning": "", + "pre.roster.quickAdd": "", + "pre.roster.quickAdd.pickup": "", + "pre.roster.quickAdd.addAll": "", + "pre.roster.quickAdd.addAll.confirm": "", + "pre.roster.addMembers": "", + "pre.roster.emptySlot": "", + "pre.roster.emptySlot.optional": "", + "pre.roster.remove.confirm": "", "pre.pool.header": "", "pre.pool.banned": "", "pre.pool.tiebreaker.short": "", @@ -151,7 +155,6 @@ "staff.divider.addedForEvent": "", "staff.editOrganization": "", "actions.addSub": "", - "actions.shareLink": "", "actions.sub.prompt_zero": "", "actions.finalize": "", "actions.finalize.button": "", diff --git a/locales/nl/calendar.json b/locales/nl/calendar.json index 15dca99f4..db7ea02e6 100644 --- a/locales/nl/calendar.json +++ b/locales/nl/calendar.json @@ -83,6 +83,7 @@ "events.view.registered": "", "events.view.hosting": "", "events.view.scrims": "", + "events.view.team": "", "events.view.saved": "", "events.view.organization": "", "events.empty": "", diff --git a/locales/nl/common.json b/locales/nl/common.json index a1c12c26e..814697afb 100644 --- a/locales/nl/common.json +++ b/locales/nl/common.json @@ -108,6 +108,10 @@ "notifications.text.TO_LIKE_RECEIVED": "", "notifications.title.TO_LIKE_ACCEPTED": "", "notifications.text.TO_LIKE_ACCEPTED": "", + "notifications.title.TEAM_EVENT_ADDED": "", + "notifications.text.TEAM_EVENT_ADDED": "", + "notifications.title.SCHEDULE_TEAM_REMINDER": "", + "notifications.text.SCHEDULE_TEAM_REMINDER": "", "auth.errors.aborted": "", "auth.errors.failed": "", "auth.errors.discordPermissions": "", @@ -358,6 +362,7 @@ "xsearch.link.noScreenshots": "", "build.private": "", "or": "", + "inviteLink": "", "yes": "", "no": "", "leaderboard.tabs.players": "", diff --git a/locales/nl/forms.json b/locales/nl/forms.json index 883a2cc2f..dfa189efb 100644 --- a/locales/nl/forms.json +++ b/locales/nl/forms.json @@ -127,7 +127,18 @@ "labels.scrimRequestMessage": "", "labels.scrimRequestStartTime": "", "bottomTexts.scrimRequestStartTime": "", + "labels.duration": "", + "options.duration.30m": "", + "options.duration.1h": "", + "options.duration.1h30m": "", + "options.duration.2h": "", + "options.duration.2h30m": "", + "options.duration.3h": "", + "options.duration.4h": "", + "options.duration.5h": "", + "options.duration.6h": "", "errors.dateInPast": "", + "errors.dateTooFarAway": "", "errors.dateTooEarly": "", "errors.dateTooLate": "", "errors.dateTooFarInFuture": "", diff --git a/locales/nl/front.json b/locales/nl/front.json index df82d5263..50d180a95 100644 --- a/locales/nl/front.json +++ b/locales/nl/front.json @@ -28,6 +28,8 @@ "sideNav.scrimVs": "", "sideNav.lookingForScrim": "", "sideNav.scrimRequestPending": "", + "sideNav.scheduleNudge": "", + "sideNav.scheduleNudge.dismiss": "", "mobileNav.menu": "", "mobileNav.friends": "", "mobileNav.you": "", diff --git a/locales/nl/schedule.json b/locales/nl/schedule.json new file mode 100644 index 000000000..deed72853 --- /dev/null +++ b/locales/nl/schedule.json @@ -0,0 +1,47 @@ +{ + "commitment.scrim": "", + "editor.addTime": "", + "editor.copyLastWeek": "", + "editor.earlier": "", + "editor.editDay": "", + "editor.later": "", + "editor.notFilled": "", + "editor.note": "", + "editor.saved": "", + "editor.saveWeek": "", + "editor.title": "", + "editor.timesInYourTimezone": "", + "editor.visibility": "", + "events.title": "", + "events.add": "", + "events.addDialogTitle": "", + "events.membersWillSee": "", + "events.none": "", + "events.delete": "", + "events.deleteConfirm": "", + "friends.availabilityOf": "", + "registration.title": "", + "registration.estimated": "", + "registration.friends": "", + "registration.beyondHorizon": "", + "registration.summary.available": "", + "registration.summary.partial": "", + "registration.summary.out": "", + "registration.summary.unknown": "", + "team.canPlay": "", + "team.currentWeek": "", + "team.hidden": "", + "team.nextWeek": "", + "team.noSchedule": "", + "team.notAvailable": "", + "team.noWindows": "", + "team.weekHeading": "", + "team.withSub": "", + "picker.title": "", + "picker.free": "", + "picker.noSchedule": "", + "picker.andOthers": "", + "picker.legend.full": "", + "picker.legend.oneShort": "", + "scrims.availableOfRoster": "" +} diff --git a/locales/nl/team.json b/locales/nl/team.json index 7e13669ec..59642bfe4 100644 --- a/locales/nl/team.json +++ b/locales/nl/team.json @@ -14,6 +14,7 @@ "actionButtons.leaveTeam.confirm": "", "actionButtons.editTeam": "", "actionButtons.manageRoster": "", + "actionButtons.schedule": "", "actionButtons.deleteTeam": "", "actionButtons.deleteTeam.profilePicture": "", "actionButtons.deleteTeam.banner": "", diff --git a/locales/nl/tournament.json b/locales/nl/tournament.json index 0e768460f..5f6e1e63c 100644 --- a/locales/nl/tournament.json +++ b/locales/nl/tournament.json @@ -57,11 +57,15 @@ "pre.roster.header": "", "pre.roster.footer": "", "pre.roster.footer.noSubs": "", - "pre.roster.addFriend.header": "", - "pre.roster.addFriend.others": "", - "pre.roster.delete.button": "", - "pre.roster.delete.header": "", "pre.roster.ignWarning": "", + "pre.roster.quickAdd": "", + "pre.roster.quickAdd.pickup": "", + "pre.roster.quickAdd.addAll": "", + "pre.roster.quickAdd.addAll.confirm": "", + "pre.roster.addMembers": "", + "pre.roster.emptySlot": "", + "pre.roster.emptySlot.optional": "", + "pre.roster.remove.confirm": "", "pre.pool.header": "", "pre.pool.banned": "", "pre.pool.tiebreaker.short": "", @@ -153,7 +157,6 @@ "staff.divider.addedForEvent": "", "staff.editOrganization": "", "actions.addSub": "", - "actions.shareLink": "", "actions.sub.prompt_one": "", "actions.sub.prompt_other": "", "actions.sub.prompt_zero": "", diff --git a/locales/pl/calendar.json b/locales/pl/calendar.json index 592196284..e2427f359 100644 --- a/locales/pl/calendar.json +++ b/locales/pl/calendar.json @@ -87,6 +87,7 @@ "events.view.registered": "", "events.view.hosting": "", "events.view.scrims": "", + "events.view.team": "", "events.view.saved": "", "events.view.organization": "", "events.empty": "", diff --git a/locales/pl/common.json b/locales/pl/common.json index 09eb92c4f..b83b8be41 100644 --- a/locales/pl/common.json +++ b/locales/pl/common.json @@ -108,6 +108,10 @@ "notifications.text.TO_LIKE_RECEIVED": "", "notifications.title.TO_LIKE_ACCEPTED": "", "notifications.text.TO_LIKE_ACCEPTED": "", + "notifications.title.TEAM_EVENT_ADDED": "", + "notifications.text.TEAM_EVENT_ADDED": "", + "notifications.title.SCHEDULE_TEAM_REMINDER": "", + "notifications.text.SCHEDULE_TEAM_REMINDER": "", "auth.errors.aborted": "Logowanie przerwane", "auth.errors.failed": "Logowanie nieudane", "auth.errors.discordPermissions": "Do twojego profilu sendou.ink, ta strona potrzebuje dostęp do twojej nazwy, avataru i połączeń konta Discord.", @@ -358,6 +362,7 @@ "xsearch.link.noScreenshots": "", "build.private": "", "or": "", + "inviteLink": "", "yes": "", "no": "", "leaderboard.tabs.players": "", diff --git a/locales/pl/forms.json b/locales/pl/forms.json index 39ffdb336..21afa2ec0 100644 --- a/locales/pl/forms.json +++ b/locales/pl/forms.json @@ -127,7 +127,18 @@ "labels.scrimRequestMessage": "", "labels.scrimRequestStartTime": "", "bottomTexts.scrimRequestStartTime": "", + "labels.duration": "", + "options.duration.30m": "", + "options.duration.1h": "", + "options.duration.1h30m": "", + "options.duration.2h": "", + "options.duration.2h30m": "", + "options.duration.3h": "", + "options.duration.4h": "", + "options.duration.5h": "", + "options.duration.6h": "", "errors.dateInPast": "", + "errors.dateTooFarAway": "", "errors.dateTooEarly": "", "errors.dateTooLate": "", "errors.dateTooFarInFuture": "", diff --git a/locales/pl/front.json b/locales/pl/front.json index df82d5263..50d180a95 100644 --- a/locales/pl/front.json +++ b/locales/pl/front.json @@ -28,6 +28,8 @@ "sideNav.scrimVs": "", "sideNav.lookingForScrim": "", "sideNav.scrimRequestPending": "", + "sideNav.scheduleNudge": "", + "sideNav.scheduleNudge.dismiss": "", "mobileNav.menu": "", "mobileNav.friends": "", "mobileNav.you": "", diff --git a/locales/pl/schedule.json b/locales/pl/schedule.json new file mode 100644 index 000000000..deed72853 --- /dev/null +++ b/locales/pl/schedule.json @@ -0,0 +1,47 @@ +{ + "commitment.scrim": "", + "editor.addTime": "", + "editor.copyLastWeek": "", + "editor.earlier": "", + "editor.editDay": "", + "editor.later": "", + "editor.notFilled": "", + "editor.note": "", + "editor.saved": "", + "editor.saveWeek": "", + "editor.title": "", + "editor.timesInYourTimezone": "", + "editor.visibility": "", + "events.title": "", + "events.add": "", + "events.addDialogTitle": "", + "events.membersWillSee": "", + "events.none": "", + "events.delete": "", + "events.deleteConfirm": "", + "friends.availabilityOf": "", + "registration.title": "", + "registration.estimated": "", + "registration.friends": "", + "registration.beyondHorizon": "", + "registration.summary.available": "", + "registration.summary.partial": "", + "registration.summary.out": "", + "registration.summary.unknown": "", + "team.canPlay": "", + "team.currentWeek": "", + "team.hidden": "", + "team.nextWeek": "", + "team.noSchedule": "", + "team.notAvailable": "", + "team.noWindows": "", + "team.weekHeading": "", + "team.withSub": "", + "picker.title": "", + "picker.free": "", + "picker.noSchedule": "", + "picker.andOthers": "", + "picker.legend.full": "", + "picker.legend.oneShort": "", + "scrims.availableOfRoster": "" +} diff --git a/locales/pl/team.json b/locales/pl/team.json index 4fb714e1d..b1a20f22c 100644 --- a/locales/pl/team.json +++ b/locales/pl/team.json @@ -14,6 +14,7 @@ "actionButtons.leaveTeam.confirm": "Opuść", "actionButtons.editTeam": "Edytuj Drużynę", "actionButtons.manageRoster": "", + "actionButtons.schedule": "", "actionButtons.deleteTeam": "Usuń drużynę", "actionButtons.deleteTeam.profilePicture": "", "actionButtons.deleteTeam.banner": "", diff --git a/locales/pl/tournament.json b/locales/pl/tournament.json index 45d245417..33fa7c80b 100644 --- a/locales/pl/tournament.json +++ b/locales/pl/tournament.json @@ -57,11 +57,15 @@ "pre.roster.header": "", "pre.roster.footer": "", "pre.roster.footer.noSubs": "", - "pre.roster.addFriend.header": "", - "pre.roster.addFriend.others": "", - "pre.roster.delete.button": "", - "pre.roster.delete.header": "", "pre.roster.ignWarning": "", + "pre.roster.quickAdd": "", + "pre.roster.quickAdd.pickup": "", + "pre.roster.quickAdd.addAll": "", + "pre.roster.quickAdd.addAll.confirm": "", + "pre.roster.addMembers": "", + "pre.roster.emptySlot": "", + "pre.roster.emptySlot.optional": "", + "pre.roster.remove.confirm": "", "pre.pool.header": "", "pre.pool.banned": "", "pre.pool.tiebreaker.short": "", @@ -155,7 +159,6 @@ "staff.divider.addedForEvent": "", "staff.editOrganization": "", "actions.addSub": "", - "actions.shareLink": "", "actions.sub.prompt_one": "", "actions.sub.prompt_few": "", "actions.sub.prompt_many": "", diff --git a/locales/pt-BR/calendar.json b/locales/pt-BR/calendar.json index 7002feace..29fe5df65 100644 --- a/locales/pt-BR/calendar.json +++ b/locales/pt-BR/calendar.json @@ -85,6 +85,7 @@ "events.view.registered": "", "events.view.hosting": "", "events.view.scrims": "", + "events.view.team": "", "events.view.saved": "", "events.view.organization": "", "events.empty": "", diff --git a/locales/pt-BR/common.json b/locales/pt-BR/common.json index 84beda5f6..961ed2a25 100644 --- a/locales/pt-BR/common.json +++ b/locales/pt-BR/common.json @@ -108,6 +108,10 @@ "notifications.text.TO_LIKE_RECEIVED": "", "notifications.title.TO_LIKE_ACCEPTED": "", "notifications.text.TO_LIKE_ACCEPTED": "", + "notifications.title.TEAM_EVENT_ADDED": "", + "notifications.text.TEAM_EVENT_ADDED": "", + "notifications.title.SCHEDULE_TEAM_REMINDER": "", + "notifications.text.SCHEDULE_TEAM_REMINDER": "", "auth.errors.aborted": "Login Abortado", "auth.errors.failed": "Login Falhou", "auth.errors.discordPermissions": "Para o seu perfil do sendou.ink, o site precisa de acesso ao nome do perfil do seu Discord, incluindo também o avatar e conexões sociais.", @@ -358,6 +362,7 @@ "xsearch.link.noScreenshots": "", "build.private": "Privada", "or": "Ou", + "inviteLink": "", "yes": "Sim", "no": "Não", "leaderboard.tabs.players": "", diff --git a/locales/pt-BR/forms.json b/locales/pt-BR/forms.json index dbf506ea9..53f897d78 100644 --- a/locales/pt-BR/forms.json +++ b/locales/pt-BR/forms.json @@ -127,7 +127,18 @@ "labels.scrimRequestMessage": "", "labels.scrimRequestStartTime": "", "bottomTexts.scrimRequestStartTime": "", + "labels.duration": "", + "options.duration.30m": "", + "options.duration.1h": "", + "options.duration.1h30m": "", + "options.duration.2h": "", + "options.duration.2h30m": "", + "options.duration.3h": "", + "options.duration.4h": "", + "options.duration.5h": "", + "options.duration.6h": "", "errors.dateInPast": "", + "errors.dateTooFarAway": "", "errors.dateTooEarly": "", "errors.dateTooLate": "", "errors.dateTooFarInFuture": "", diff --git a/locales/pt-BR/front.json b/locales/pt-BR/front.json index df82d5263..50d180a95 100644 --- a/locales/pt-BR/front.json +++ b/locales/pt-BR/front.json @@ -28,6 +28,8 @@ "sideNav.scrimVs": "", "sideNav.lookingForScrim": "", "sideNav.scrimRequestPending": "", + "sideNav.scheduleNudge": "", + "sideNav.scheduleNudge.dismiss": "", "mobileNav.menu": "", "mobileNav.friends": "", "mobileNav.you": "", diff --git a/locales/pt-BR/schedule.json b/locales/pt-BR/schedule.json new file mode 100644 index 000000000..deed72853 --- /dev/null +++ b/locales/pt-BR/schedule.json @@ -0,0 +1,47 @@ +{ + "commitment.scrim": "", + "editor.addTime": "", + "editor.copyLastWeek": "", + "editor.earlier": "", + "editor.editDay": "", + "editor.later": "", + "editor.notFilled": "", + "editor.note": "", + "editor.saved": "", + "editor.saveWeek": "", + "editor.title": "", + "editor.timesInYourTimezone": "", + "editor.visibility": "", + "events.title": "", + "events.add": "", + "events.addDialogTitle": "", + "events.membersWillSee": "", + "events.none": "", + "events.delete": "", + "events.deleteConfirm": "", + "friends.availabilityOf": "", + "registration.title": "", + "registration.estimated": "", + "registration.friends": "", + "registration.beyondHorizon": "", + "registration.summary.available": "", + "registration.summary.partial": "", + "registration.summary.out": "", + "registration.summary.unknown": "", + "team.canPlay": "", + "team.currentWeek": "", + "team.hidden": "", + "team.nextWeek": "", + "team.noSchedule": "", + "team.notAvailable": "", + "team.noWindows": "", + "team.weekHeading": "", + "team.withSub": "", + "picker.title": "", + "picker.free": "", + "picker.noSchedule": "", + "picker.andOthers": "", + "picker.legend.full": "", + "picker.legend.oneShort": "", + "scrims.availableOfRoster": "" +} diff --git a/locales/pt-BR/team.json b/locales/pt-BR/team.json index e33749aec..09c1f38be 100644 --- a/locales/pt-BR/team.json +++ b/locales/pt-BR/team.json @@ -14,6 +14,7 @@ "actionButtons.leaveTeam.confirm": "Sair", "actionButtons.editTeam": "Editar Time", "actionButtons.manageRoster": "", + "actionButtons.schedule": "", "actionButtons.deleteTeam": "Apagar Time", "actionButtons.deleteTeam.profilePicture": "", "actionButtons.deleteTeam.banner": "", diff --git a/locales/pt-BR/tournament.json b/locales/pt-BR/tournament.json index c44589c13..36ae832d7 100644 --- a/locales/pt-BR/tournament.json +++ b/locales/pt-BR/tournament.json @@ -57,11 +57,15 @@ "pre.roster.header": "Preencher lista", "pre.roster.footer": "Pelo menos {{atLeastCount}} membros são necessários para participar. O número máximo da lista de participantes é de {{maxCount}}", "pre.roster.footer.noSubs": "", - "pre.roster.addFriend.header": "", - "pre.roster.addFriend.others": "", - "pre.roster.delete.button": "Excluir membro", - "pre.roster.delete.header": "Membro a ser excluído", "pre.roster.ignWarning": "", + "pre.roster.quickAdd": "", + "pre.roster.quickAdd.pickup": "", + "pre.roster.quickAdd.addAll": "", + "pre.roster.quickAdd.addAll.confirm": "", + "pre.roster.addMembers": "", + "pre.roster.emptySlot": "", + "pre.roster.emptySlot.optional": "", + "pre.roster.remove.confirm": "", "pre.pool.header": "Escolher seleção de mapas", "pre.pool.banned": "Banido", "pre.pool.tiebreaker.short": "Desempate", @@ -154,7 +158,6 @@ "staff.divider.addedForEvent": "", "staff.editOrganization": "", "actions.addSub": "Adicionar substituto(a)", - "actions.shareLink": "Compartilhe seu link de convite para adicionar membros: {{inviteLink}}", "actions.sub.prompt_one": "Você ainda pode adicionar {{count}} substituto(a) à sua lista", "actions.sub.prompt_many": "", "actions.sub.prompt_other": "Você ainda pode adicionar {{count}} substitutos(as) à sua lista", diff --git a/locales/ru/calendar.json b/locales/ru/calendar.json index 0f5995c84..60a916e65 100644 --- a/locales/ru/calendar.json +++ b/locales/ru/calendar.json @@ -87,6 +87,7 @@ "events.view.registered": "", "events.view.hosting": "", "events.view.scrims": "", + "events.view.team": "", "events.view.saved": "", "events.view.organization": "", "events.empty": "", diff --git a/locales/ru/common.json b/locales/ru/common.json index a4955321b..11ae04e12 100644 --- a/locales/ru/common.json +++ b/locales/ru/common.json @@ -108,6 +108,10 @@ "notifications.text.TO_LIKE_RECEIVED": "", "notifications.title.TO_LIKE_ACCEPTED": "", "notifications.text.TO_LIKE_ACCEPTED": "", + "notifications.title.TEAM_EVENT_ADDED": "", + "notifications.text.TEAM_EVENT_ADDED": "", + "notifications.title.SCHEDULE_TEAM_REMINDER": "", + "notifications.text.SCHEDULE_TEAM_REMINDER": "", "auth.errors.aborted": "Вход отменён", "auth.errors.failed": "Ошибка входа", "auth.errors.discordPermissions": "Для вашего профиля на sendou.ink странице нужен доступ к вашему имени, аватару и привязанным аккаунтам соц. сетей в Discord.", @@ -358,6 +362,7 @@ "xsearch.link.noScreenshots": "", "build.private": "Приватный", "or": "Или", + "inviteLink": "", "yes": "Да", "no": "Нет", "leaderboard.tabs.players": "", diff --git a/locales/ru/forms.json b/locales/ru/forms.json index f25e5b880..c177c11c7 100644 --- a/locales/ru/forms.json +++ b/locales/ru/forms.json @@ -127,7 +127,18 @@ "labels.scrimRequestMessage": "", "labels.scrimRequestStartTime": "", "bottomTexts.scrimRequestStartTime": "", + "labels.duration": "", + "options.duration.30m": "", + "options.duration.1h": "", + "options.duration.1h30m": "", + "options.duration.2h": "", + "options.duration.2h30m": "", + "options.duration.3h": "", + "options.duration.4h": "", + "options.duration.5h": "", + "options.duration.6h": "", "errors.dateInPast": "", + "errors.dateTooFarAway": "", "errors.dateTooEarly": "", "errors.dateTooLate": "", "errors.dateTooFarInFuture": "", diff --git a/locales/ru/front.json b/locales/ru/front.json index 50622da2d..d81054318 100644 --- a/locales/ru/front.json +++ b/locales/ru/front.json @@ -28,6 +28,8 @@ "sideNav.scrimVs": "", "sideNav.lookingForScrim": "", "sideNav.scrimRequestPending": "", + "sideNav.scheduleNudge": "", + "sideNav.scheduleNudge.dismiss": "", "mobileNav.menu": "", "mobileNav.friends": "", "mobileNav.you": "", diff --git a/locales/ru/schedule.json b/locales/ru/schedule.json new file mode 100644 index 000000000..deed72853 --- /dev/null +++ b/locales/ru/schedule.json @@ -0,0 +1,47 @@ +{ + "commitment.scrim": "", + "editor.addTime": "", + "editor.copyLastWeek": "", + "editor.earlier": "", + "editor.editDay": "", + "editor.later": "", + "editor.notFilled": "", + "editor.note": "", + "editor.saved": "", + "editor.saveWeek": "", + "editor.title": "", + "editor.timesInYourTimezone": "", + "editor.visibility": "", + "events.title": "", + "events.add": "", + "events.addDialogTitle": "", + "events.membersWillSee": "", + "events.none": "", + "events.delete": "", + "events.deleteConfirm": "", + "friends.availabilityOf": "", + "registration.title": "", + "registration.estimated": "", + "registration.friends": "", + "registration.beyondHorizon": "", + "registration.summary.available": "", + "registration.summary.partial": "", + "registration.summary.out": "", + "registration.summary.unknown": "", + "team.canPlay": "", + "team.currentWeek": "", + "team.hidden": "", + "team.nextWeek": "", + "team.noSchedule": "", + "team.notAvailable": "", + "team.noWindows": "", + "team.weekHeading": "", + "team.withSub": "", + "picker.title": "", + "picker.free": "", + "picker.noSchedule": "", + "picker.andOthers": "", + "picker.legend.full": "", + "picker.legend.oneShort": "", + "scrims.availableOfRoster": "" +} diff --git a/locales/ru/team.json b/locales/ru/team.json index b3e3f031a..d8a6cddf4 100644 --- a/locales/ru/team.json +++ b/locales/ru/team.json @@ -14,6 +14,7 @@ "actionButtons.leaveTeam.confirm": "Покинуть", "actionButtons.editTeam": "Редактировать команду", "actionButtons.manageRoster": "", + "actionButtons.schedule": "", "actionButtons.deleteTeam": "Удалить команду", "actionButtons.deleteTeam.profilePicture": "", "actionButtons.deleteTeam.banner": "", diff --git a/locales/ru/tournament.json b/locales/ru/tournament.json index 4e7e5bff4..85823b75b 100644 --- a/locales/ru/tournament.json +++ b/locales/ru/tournament.json @@ -57,11 +57,15 @@ "pre.roster.header": "Заполните состав", "pre.roster.footer": "Необходимый минимум игроков для данного турнира: {{atLeastCount}}. Максимальное количество игроков в составе: {{maxCount}}", "pre.roster.footer.noSubs": "", - "pre.roster.addFriend.header": "", - "pre.roster.addFriend.others": "", - "pre.roster.delete.button": "Удалить участника", - "pre.roster.delete.header": "Участник для удаления", "pre.roster.ignWarning": "", + "pre.roster.quickAdd": "", + "pre.roster.quickAdd.pickup": "", + "pre.roster.quickAdd.addAll": "", + "pre.roster.quickAdd.addAll.confirm": "", + "pre.roster.addMembers": "", + "pre.roster.emptySlot": "", + "pre.roster.emptySlot.optional": "", + "pre.roster.remove.confirm": "", "pre.pool.header": "Выберите пул арен", "pre.pool.banned": "Запрещено", "pre.pool.tiebreaker.short": "Тайбрейк", @@ -155,7 +159,6 @@ "staff.divider.addedForEvent": "", "staff.editOrganization": "", "actions.addSub": "Добавить запасного", - "actions.shareLink": "Ссылка приглашения в команду: {{inviteLink}}", "actions.sub.prompt_one": "Вы ещё можете добавить {{count}} запасного", "actions.sub.prompt_few": "", "actions.sub.prompt_many": "", diff --git a/locales/zh/calendar.json b/locales/zh/calendar.json index 635c20b35..c78f710ba 100644 --- a/locales/zh/calendar.json +++ b/locales/zh/calendar.json @@ -81,6 +81,7 @@ "events.view.registered": "已报名", "events.view.hosting": "我主办的", "events.view.scrims": "对抗战", + "events.view.team": "", "events.view.saved": "已保存", "events.view.organization": "组织", "events.empty": "该分类下暂无赛事。", diff --git a/locales/zh/common.json b/locales/zh/common.json index c254e8204..293936c0d 100644 --- a/locales/zh/common.json +++ b/locales/zh/common.json @@ -108,6 +108,10 @@ "notifications.text.TO_LIKE_RECEIVED": "{{likerUsername}} 在赛事【{{tournamentName}}】中邀请了您的小组", "notifications.title.TO_LIKE_ACCEPTED": "小组邀请已接受", "notifications.text.TO_LIKE_ACCEPTED": "{{accepterUsername}} 在赛事【{{tournamentName}}】中接受了您的小组邀请", + "notifications.title.TEAM_EVENT_ADDED": "", + "notifications.text.TEAM_EVENT_ADDED": "", + "notifications.title.SCHEDULE_TEAM_REMINDER": "", + "notifications.text.SCHEDULE_TEAM_REMINDER": "", "auth.errors.aborted": "登录中止", "auth.errors.failed": "登录失败", "auth.errors.discordPermissions": "为了完善您的sendou.ink个人资料,网站需要获取您的 Discord 名字、头像和社交链接。", @@ -358,6 +362,7 @@ "xsearch.link.noScreenshots": "", "build.private": "私人", "or": "或", + "inviteLink": "", "yes": "是", "no": "否", "leaderboard.tabs.players": "", diff --git a/locales/zh/forms.json b/locales/zh/forms.json index 8d9b2901f..81cff2877 100644 --- a/locales/zh/forms.json +++ b/locales/zh/forms.json @@ -127,7 +127,18 @@ "labels.scrimRequestMessage": "消息", "labels.scrimRequestStartTime": "开始时间", "bottomTexts.scrimRequestStartTime": "请在招募帖的时间范围内选择一个时间", + "labels.duration": "", + "options.duration.30m": "", + "options.duration.1h": "", + "options.duration.1h30m": "", + "options.duration.2h": "", + "options.duration.2h30m": "", + "options.duration.3h": "", + "options.duration.4h": "", + "options.duration.5h": "", + "options.duration.6h": "", "errors.dateInPast": "日期不能早于当前时间", + "errors.dateTooFarAway": "", "errors.dateTooEarly": "日期过早", "errors.dateTooLate": "日期过晚", "errors.dateTooFarInFuture": "日期不能晚于当前时间超过 2 周", diff --git a/locales/zh/front.json b/locales/zh/front.json index 5bec39084..9f16b6f82 100644 --- a/locales/zh/front.json +++ b/locales/zh/front.json @@ -28,6 +28,8 @@ "sideNav.scrimVs": "对战 {{opponent}}", "sideNav.lookingForScrim": "寻找对抗战", "sideNav.scrimRequestPending": "请求待处理", + "sideNav.scheduleNudge": "", + "sideNav.scheduleNudge.dismiss": "", "mobileNav.menu": "菜单", "mobileNav.friends": "好友", "mobileNav.you": "你", diff --git a/locales/zh/schedule.json b/locales/zh/schedule.json new file mode 100644 index 000000000..deed72853 --- /dev/null +++ b/locales/zh/schedule.json @@ -0,0 +1,47 @@ +{ + "commitment.scrim": "", + "editor.addTime": "", + "editor.copyLastWeek": "", + "editor.earlier": "", + "editor.editDay": "", + "editor.later": "", + "editor.notFilled": "", + "editor.note": "", + "editor.saved": "", + "editor.saveWeek": "", + "editor.title": "", + "editor.timesInYourTimezone": "", + "editor.visibility": "", + "events.title": "", + "events.add": "", + "events.addDialogTitle": "", + "events.membersWillSee": "", + "events.none": "", + "events.delete": "", + "events.deleteConfirm": "", + "friends.availabilityOf": "", + "registration.title": "", + "registration.estimated": "", + "registration.friends": "", + "registration.beyondHorizon": "", + "registration.summary.available": "", + "registration.summary.partial": "", + "registration.summary.out": "", + "registration.summary.unknown": "", + "team.canPlay": "", + "team.currentWeek": "", + "team.hidden": "", + "team.nextWeek": "", + "team.noSchedule": "", + "team.notAvailable": "", + "team.noWindows": "", + "team.weekHeading": "", + "team.withSub": "", + "picker.title": "", + "picker.free": "", + "picker.noSchedule": "", + "picker.andOthers": "", + "picker.legend.full": "", + "picker.legend.oneShort": "", + "scrims.availableOfRoster": "" +} diff --git a/locales/zh/team.json b/locales/zh/team.json index 995cb0e57..29dc92f92 100644 --- a/locales/zh/team.json +++ b/locales/zh/team.json @@ -14,6 +14,7 @@ "actionButtons.leaveTeam.confirm": "退出", "actionButtons.editTeam": "编辑队伍", "actionButtons.manageRoster": "管理队员", + "actionButtons.schedule": "", "actionButtons.deleteTeam": "删除队伍", "actionButtons.deleteTeam.profilePicture": "移除队徽", "actionButtons.deleteTeam.banner": "移除横幅", diff --git a/locales/zh/tournament.json b/locales/zh/tournament.json index b9b53fff4..80fa2f64f 100644 --- a/locales/zh/tournament.json +++ b/locales/zh/tournament.json @@ -57,11 +57,15 @@ "pre.roster.header": "填写阵容", "pre.roster.footer": "至少需要 {{atLeastCount}} 名成员才能参赛。最大阵容人数为 {{maxCount}} 人。", "pre.roster.footer.noSubs": "赛制为 {{format}}。不允许替补。", - "pre.roster.addFriend.header": "添加好友", - "pre.roster.addFriend.others": "", - "pre.roster.delete.button": "删除成员", - "pre.roster.delete.header": "要删除的成员", "pre.roster.ignWarning": "", + "pre.roster.quickAdd": "", + "pre.roster.quickAdd.pickup": "", + "pre.roster.quickAdd.addAll": "", + "pre.roster.quickAdd.addAll.confirm": "", + "pre.roster.addMembers": "", + "pre.roster.emptySlot": "", + "pre.roster.emptySlot.optional": "", + "pre.roster.remove.confirm": "", "pre.pool.header": "选择场地池", "pre.pool.banned": "已禁用", "pre.pool.tiebreaker.short": "决胜局场地", @@ -152,7 +156,6 @@ "staff.divider.addedForEvent": "仅限此赛事", "staff.editOrganization": "编辑组织", "actions.addSub": "添加替补", - "actions.shareLink": "分享您的邀请链接以添加成员: {{inviteLink}}", "actions.sub.prompt": "您仍可以向阵容中添加 {{count}} 名替补", "actions.sub.prompt_zero": "您的阵容已满,无法添加更多替补", "actions.finalize": "正在结束赛事", diff --git a/migrations/20260822034109-availability.ts b/migrations/20260822034109-availability.ts new file mode 100644 index 000000000..facbc6381 --- /dev/null +++ b/migrations/20260822034109-availability.ts @@ -0,0 +1,85 @@ +import { type Kysely, sql } from "kysely"; + +/** Weekly availability users report for their teammates and friends, and the team events that block it */ +export async function up(db: Kysely): Promise { + await db.transaction().execute(async (trx) => { + await trx.schema + .createTable("AvailabilityWeek") + .addColumn("id", "integer", (col) => col.primaryKey()) + .addColumn("userId", "integer", (col) => + col.notNull().references("User.id").onDelete("cascade"), + ) + .addColumn("weekStartsAt", "integer", (col) => col.notNull()) + .addColumn("timezone", "text", (col) => col.notNull()) + .addColumn("createdAt", "integer", (col) => + col.notNull().defaultTo(sql`(strftime('%s', 'now'))`), + ) + .addColumn("updatedAt", "integer", (col) => + col.notNull().defaultTo(sql`(strftime('%s', 'now'))`), + ) + .addUniqueConstraint("availability_week_user_id_week_starts_at", [ + "userId", + "weekStartsAt", + ]) + // every table in this schema is strict + .modifyEnd(sql`strict`) + .execute(); + + await trx.schema + .createTable("AvailabilitySlot") + .addColumn("id", "integer", (col) => col.primaryKey()) + .addColumn("availabilityWeekId", "integer", (col) => + col.notNull().references("AvailabilityWeek.id").onDelete("cascade"), + ) + .addColumn("startsAt", "integer", (col) => col.notNull()) + .addColumn("endsAt", "integer", (col) => col.notNull()) + .modifyEnd(sql`strict`) + .execute(); + + await trx.schema + .createIndex("availability_slot_availability_week_id") + .on("AvailabilitySlot") + .column("availabilityWeekId") + .execute(); + + await trx.schema + .createTable("AvailabilityDayNote") + .addColumn("availabilityWeekId", "integer", (col) => + col.notNull().references("AvailabilityWeek.id").onDelete("cascade"), + ) + .addColumn("date", "text", (col) => col.notNull()) + .addColumn("text", "text", (col) => col.notNull()) + .addPrimaryKeyConstraint("availability_day_note_pk", [ + "availabilityWeekId", + "date", + ]) + .modifyEnd(sql`strict`) + .execute(); + + await trx.schema + .createTable("TeamEvent") + .addColumn("id", "integer", (col) => col.primaryKey()) + .addColumn("teamId", "integer", (col) => + col.notNull().references("AllTeam.id").onDelete("cascade"), + ) + // the event belongs to the team, so it outlives its author's account, + // the way every other authored row of the schema does + .addColumn("authorId", "integer", (col) => + col.references("User.id").onDelete("set null"), + ) + .addColumn("name", "text", (col) => col.notNull()) + .addColumn("startsAt", "integer", (col) => col.notNull()) + .addColumn("endsAt", "integer", (col) => col.notNull()) + .addColumn("createdAt", "integer", (col) => + col.notNull().defaultTo(sql`(strftime('%s', 'now'))`), + ) + .modifyEnd(sql`strict`) + .execute(); + + await trx.schema + .createIndex("team_event_team_id_starts_at") + .on("TeamEvent") + .columns(["teamId", "startsAt"]) + .execute(); + }); +} diff --git a/scripts/benchmark-db/cases.ts b/scripts/benchmark-db/cases.ts index 23462c694..54e184c75 100644 --- a/scripts/benchmark-db/cases.ts +++ b/scripts/benchmark-db/cases.ts @@ -1,9 +1,11 @@ +import { subDays } from "date-fns"; import * as AdminRepository from "~/features/admin/AdminRepository.server"; import * as ExternalStreamRepository from "~/features/admin/ExternalStreamRepository.server"; import * as ApiRepository from "~/features/api/ApiRepository.server"; import * as ArtRepository from "~/features/art/ArtRepository.server"; import * as AssociationRepository from "~/features/associations/AssociationRepository.server"; import * as LogInLinkRepository from "~/features/auth/LogInLinkRepository.server"; +import * as AvailabilityRepository from "~/features/availability/AvailabilityRepository.server"; import * as BadgeRepository from "~/features/badges/BadgeRepository.server"; import * as BuildRepository from "~/features/builds/BuildRepository.server"; import * as CalendarRepository from "~/features/calendar/CalendarRepository.server"; @@ -158,6 +160,66 @@ export function buildCases(fx: Fixtures): { LogInLinkRepository.findValidByCode(code), ); + // AvailabilityRepository + add( + "AvailabilityRepository.findAllWeeksByUserIds", + both(fx.manyUserIds, fx.availabilityWindow), + ([userIds, window]) => + AvailabilityRepository.findAllWeeksByUserIds({ + userIds, + startsAt: window.startsAt, + endsAt: window.endsAt, + }), + ); + add( + "AvailabilityRepository.hasReportedWeek", + both(fx.heavyUser, fx.availabilityWindow), + ([user, window]) => + AvailabilityRepository.hasReportedWeek({ + userId: user.id, + weekStartsAt: window.weekStartsAt, + }), + ); + add( + "AvailabilityRepository.findWeekReminderUserIds", + fx.availabilityWindow, + (window) => + AvailabilityRepository.findWeekReminderUserIds(window.weekStartsAt), + ); + add( + "AvailabilityRepository.findAllTeamEventsByUserIds", + both(fx.manyUserIds, fx.availabilityWindow), + ([userIds, window]) => + AvailabilityRepository.findAllTeamEventsByUserIds({ + userIds, + startsAt: window.startsAt, + endsAt: window.endsAt, + }), + ); + add( + "AvailabilityRepository.findTeamEventsByTeamId", + both(fx.heavyTeam, fx.availabilityWindow), + ([team, window]) => + AvailabilityRepository.findTeamEventsByTeamId({ + teamId: team.id, + startsAt: window.startsAt, + endsAt: window.endsAt, + }), + ); + add( + "AvailabilityRepository.findAllUpcomingTeamEventsByUserId", + both(fx.heavyTeam, fx.availabilityWindow), + ([team, window]) => + AvailabilityRepository.findAllUpcomingTeamEventsByUserId({ + userId: team.memberUserId, + startsAt: window.startsAt, + endsAt: window.endsAt, + }), + ); + add("AvailabilityRepository.findTeamEventById", fx.teamEventId, (id) => + AvailabilityRepository.findTeamEventById(id), + ); + // BadgeRepository addStatic("BadgeRepository.findAll", () => BadgeRepository.findAll()); add("BadgeRepository.findById", fx.heavyBadgeId, (badgeId) => @@ -625,6 +687,16 @@ export function buildCases(fx: Fixtures): { add("ScrimPostRepository.findUserScrims", fx.scrimUserIds, (userIds) => ScrimPostRepository.findUserScrims(userIds[0]), ); + add( + "ScrimPostRepository.findAllAcceptedByUserIds", + both(fx.scrimUserIds, fx.scrimWindow), + ([userIds, window]) => + ScrimPostRepository.findAllAcceptedByUserIds({ + userIds, + startsAt: dateToDatabaseTimestamp(window.startTime), + endsAt: dateToDatabaseTimestamp(window.endTime), + }), + ); // GroupMatchContinueVoteRepository add( @@ -1028,9 +1100,15 @@ export function buildCases(fx: Fixtures): { org.memberUserId, ), ); + addStatic("TournamentOrganizationRepository.findAllSeries", () => + TournamentOrganizationRepository.findAllSeries(), + ); addStatic( - "TournamentOrganizationRepository.findAllSeriesWithTierHistory", - () => TournamentOrganizationRepository.findAllSeriesWithTierHistory(), + "TournamentOrganizationRepository.findAllOrganizedTournamentTeamCounts", + () => + TournamentOrganizationRepository.findAllOrganizedTournamentTeamCounts({ + startedAfter: dateToDatabaseTimestamp(subDays(new Date(), 90)), + }), ); // SavedCalendarEventRepository @@ -1229,6 +1307,16 @@ export function buildCases(fx: Fixtures): { fx.tournamentTeamPair, (teamIds) => TournamentTeamRepository.findMapPoolsByTeamIds(teamIds), ); + add( + "TournamentTeamRepository.findAllRegistrationsByUserIds", + both(fx.manyUserIds, fx.availabilityWindow), + ([userIds, window]) => + TournamentTeamRepository.findAllRegistrationsByUserIds({ + userIds, + startsAt: window.startsAt, + endsAt: window.endsAt, + }), + ); add( "TournamentTeamRepository.isOrganizerAddedMember", both(fx.heavyTournamentTeamId, fx.heavyUser), diff --git a/scripts/benchmark-db/fixtures.ts b/scripts/benchmark-db/fixtures.ts index 1cc8d857f..beb65af01 100644 --- a/scripts/benchmark-db/fixtures.ts +++ b/scripts/benchmark-db/fixtures.ts @@ -1,7 +1,8 @@ -import { sub } from "date-fns"; +import { addWeeks, sub } from "date-fns"; import { sql } from "kysely"; import { db } from "~/db/sql"; import type { Tables } from "~/db/tables"; +import * as Availability from "~/features/availability/core/Availability"; import * as ChatRepository from "~/features/chat/ChatRepository.server"; import type { ChatRoomType } from "~/features/chat/chat-types"; import type { SkillTeamIdentifier } from "~/features/mmr/mmr-utils"; @@ -60,6 +61,13 @@ export interface Fixtures { calendarAuthorId: number | null; calendarWindow: { startTime: Date; endTime: Date } | null; scrimWindow: { startTime: Date; endTime: Date } | null; + /** The horizon availability reads cover: the current week's start, and the current-plus-next week as a range. */ + availabilityWindow: { + weekStartsAt: number; + startsAt: number; + endsAt: number; + } | null; + teamEventId: number | null; heavyScrimPostId: number | null; scrimUserIds: number[] | null; heavyOrg: { @@ -174,6 +182,8 @@ export async function resolveFixtures(): Promise { calendarAuthorId: await resolveCalendarAuthorId(), calendarWindow: await resolveCalendarWindow(), scrimWindow: await resolveScrimWindow(), + availabilityWindow: resolveAvailabilityWindow(), + teamEventId: await resolveTeamEventId(), heavyScrimPostId, scrimUserIds: await resolveScrimUserIds(heavyScrimPostId, heavyUser), heavyOrg: await resolveHeavyOrg(), @@ -1423,6 +1433,27 @@ async function resolveScannerIngestSendouq() { }; } +function resolveAvailabilityWindow() { + const current = Availability.weekRange(new Date(), "UTC"); + + return { + weekStartsAt: current.startsAt, + startsAt: current.startsAt, + endsAt: Availability.weekRange(addWeeks(new Date(), 1), "UTC").endsAt, + }; +} + +async function resolveTeamEventId() { + const row = await db + .selectFrom("TeamEvent") + .select("id") + .orderBy("startsAt", "desc") + .limit(1) + .executeTakeFirst(); + + return row?.id ?? null; +} + async function resolveCastedTournamentId() { const row = await db .selectFrom("Tournament") diff --git a/vite.config.ts b/vite.config.ts index 38a4ceeff..a1abedd41 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -15,6 +15,26 @@ export default defineConfig((config) => { }, }, plugins: [ + { + // Vite dev serves everything with no-cache, so the browser revalidates + // the woff2 on every font re-resolution — any mutation (e.g. an + // intent-prefetch link mounting) then flashes fallback fonts across the + // whole page while the 304 round-trips. Fonts effectively never change, + // so dev caches them hard, matching how the production build serves them. + name: "cache-fonts-in-dev", + apply: "serve", + configureServer(server) { + server.middlewares.use((req, res, next) => { + if (req.url?.includes("/fonts/") && req.url.includes(".woff2")) { + res.setHeader( + "Cache-Control", + "public, max-age=31536000, immutable", + ); + } + next(); + }); + }, + }, { // Wraps CSS modules in a @layer so utility classes always win and, more // generally, so that the more specific of two modules styling the same