mirror of
https://github.com/Sendouc/sendou.ink.git
synced 2026-09-08 20:26:08 -05:00
Availability tool (#3375)
This commit is contained in:
@@ -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
|
||||
|
||||
10
app/components/InviteLinkInput.module.css
Normal file
10
app/components/InviteLinkInput.module.css
Normal file
@@ -0,0 +1,10 @@
|
||||
.row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--s-2);
|
||||
|
||||
& input {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
}
|
||||
45
app/components/InviteLinkInput.tsx
Normal file
45
app/components/InviteLinkInput.tsx
Normal file
@@ -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 (
|
||||
<div>
|
||||
<Label htmlFor={id}>{label ?? t("common:inviteLink")}</Label>
|
||||
<div className={styles.row}>
|
||||
<input
|
||||
type="text"
|
||||
value={link}
|
||||
readOnly
|
||||
id={id}
|
||||
onFocus={(e) => e.currentTarget.select()}
|
||||
data-testid="invite-link-input"
|
||||
/>
|
||||
<SendouButton
|
||||
shape="square"
|
||||
variant={copySuccess ? "outlined-success" : "outlined"}
|
||||
onPress={() => copyToClipboard(link)}
|
||||
icon={copySuccess ? <Check /> : <Clipboard />}
|
||||
aria-label={t("common:actions.copyToClipboard")}
|
||||
data-testid="copy-invite-link-button"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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" ? (
|
||||
<TourneysPanel
|
||||
events={sidebarData?.events ?? []}
|
||||
showScheduleNudge={sidebarData?.scheduleNudge ?? false}
|
||||
onClose={closePanel}
|
||||
onTabPress={handleTabPress}
|
||||
isLoggedIn={Boolean(user)}
|
||||
@@ -503,12 +505,14 @@ function FriendsPanel({
|
||||
|
||||
function TourneysPanel({
|
||||
events,
|
||||
showScheduleNudge,
|
||||
onClose,
|
||||
onTabPress,
|
||||
isLoggedIn,
|
||||
skipAnimation,
|
||||
}: {
|
||||
events: NonNullable<SidebarData>["events"];
|
||||
showScheduleNudge: boolean;
|
||||
onClose: () => void;
|
||||
onTabPress: (panel: PanelType) => void;
|
||||
isLoggedIn: boolean;
|
||||
@@ -525,6 +529,7 @@ function TourneysPanel({
|
||||
isLoggedIn={isLoggedIn}
|
||||
skipAnimation={skipAnimation}
|
||||
>
|
||||
{showScheduleNudge ? <ScheduleNudge panel onNavigate={onClose} /> : null}
|
||||
<EventsList events={events} onClick={onClose} />
|
||||
<Link
|
||||
to={EVENTS_PAGE}
|
||||
|
||||
@@ -58,11 +58,13 @@ export function SendouAnchoredPopover({
|
||||
isOpen,
|
||||
onOpenChange,
|
||||
triggerRef,
|
||||
"aria-label": ariaLabel,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
isOpen: boolean;
|
||||
onOpenChange: (isOpen: boolean) => void;
|
||||
triggerRef: React.RefObject<HTMLElement | null>;
|
||||
"aria-label"?: string;
|
||||
}) {
|
||||
return (
|
||||
<Popover
|
||||
@@ -71,7 +73,9 @@ export function SendouAnchoredPopover({
|
||||
onOpenChange={onOpenChange}
|
||||
triggerRef={triggerRef}
|
||||
>
|
||||
<Dialog className={styles.dialog}>{children}</Dialog>
|
||||
<Dialog className={styles.dialog} aria-label={ariaLabel}>
|
||||
{children}
|
||||
</Dialog>
|
||||
</Popover>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -116,7 +116,7 @@ export function SelectShellItem({
|
||||
<ListBoxItem
|
||||
{...rest}
|
||||
className={({ isFocused, isSelected }) =>
|
||||
clsx(className, {
|
||||
clsx(className, styles.item, {
|
||||
[styles.itemFocused]: isFocused,
|
||||
[styles.itemSelected]: isSelected,
|
||||
})
|
||||
|
||||
@@ -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")}
|
||||
</SideNavHeader>
|
||||
{showScheduleNudge ? <ScheduleNudge /> : null}
|
||||
{events.length > 0 ? (
|
||||
events.map((event) => (
|
||||
<ListLink
|
||||
|
||||
349
app/db/seed/dev/availability.ts
Normal file
349
app/db/seed/dev/availability.ts
Normal file
@@ -0,0 +1,349 @@
|
||||
import { addDays, addWeeks, subMonths } from "date-fns";
|
||||
import type { TimeRange } from "~/features/availability/availability-types";
|
||||
import * as Availability from "~/features/availability/core/Availability";
|
||||
import { dateToYYYYMMDD } from "~/utils/dates";
|
||||
import * as AvailabilityWeekFactory from "../factories/AvailabilityWeekFactory";
|
||||
import * as TeamEventFactory from "../factories/TeamEventFactory";
|
||||
import type { SeededMisc } from "./misc";
|
||||
import type { SeededScrims } from "./scrims-lfg";
|
||||
import type { SeededTeams } from "./teams";
|
||||
import type { SeededTournaments } from "./tournaments";
|
||||
import type { SeededUsers } from "./users";
|
||||
|
||||
const HOUR = 60 * 60;
|
||||
/** How long ago the week seeded for the cleanup routine to delete ended. */
|
||||
const OLD_WEEK_MONTHS = 4;
|
||||
/** A showcase user who is neither a teammate nor a friend of the admin, so their availability must show up nowhere. */
|
||||
const STRANGER_SHOWCASE_INDEX = 30;
|
||||
|
||||
/** Ranges of one day, `HH:mm`. An end at or before the start crosses midnight. */
|
||||
type DaySchedule = Array<[start: string, end: string]>;
|
||||
|
||||
/** 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<number, string>;
|
||||
/** 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<SeededSchedule> = [
|
||||
// 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<TimeRange & { userId: number }>;
|
||||
}) {
|
||||
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)),
|
||||
);
|
||||
}
|
||||
@@ -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<SeededMisc> {
|
||||
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",
|
||||
|
||||
@@ -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<SeededScrims> {
|
||||
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) {
|
||||
|
||||
@@ -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<SeededTeams> {
|
||||
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<SeededTeams> {
|
||||
ids.push(team.id);
|
||||
}
|
||||
|
||||
return { allianceRogueId: allianceRogue.id, ids, squads };
|
||||
return {
|
||||
allianceRogueId: allianceRogue.id,
|
||||
allianceRogue: {
|
||||
playerUserIds: allianceRoguePlayers,
|
||||
subUserId: allianceRogueSubId,
|
||||
coachUserId: allianceRogueCoachId,
|
||||
},
|
||||
ids,
|
||||
squads,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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];
|
||||
|
||||
25
app/db/seed/factories/AvailabilityWeekFactory.ts
Normal file
25
app/db/seed/factories/AvailabilityWeekFactory.ts
Normal file
@@ -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<typeof AvailabilityRepository.upsertOwnWeek>[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,
|
||||
}),
|
||||
});
|
||||
22
app/db/seed/factories/TeamEventFactory.ts
Normal file
22
app/db/seed/factories/TeamEventFactory.ts
Normal file
@@ -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),
|
||||
),
|
||||
}),
|
||||
});
|
||||
@@ -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<number, MemberRole>;
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -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,
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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<number>;
|
||||
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<number>;
|
||||
updatedAt: Generated<number>;
|
||||
}
|
||||
|
||||
/** A range the user is available for. Absolute, so a range crossing midnight is one row like any other. */
|
||||
export interface AvailabilitySlot {
|
||||
id: GeneratedAlways<number>;
|
||||
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<number>;
|
||||
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<number>;
|
||||
}
|
||||
|
||||
export type Tables = { [P in keyof DB]: Selectable<DB[P]> };
|
||||
export type TablesInsertable = { [P in keyof DB]: Insertable<DB[P]> };
|
||||
|
||||
@@ -1495,4 +1534,8 @@ export interface DB {
|
||||
NotificationUserSubscription: NotificationUserSubscription;
|
||||
SavedCalendarEvent: SavedCalendarEvent;
|
||||
SplatoonRotation: SplatoonRotation;
|
||||
AvailabilityWeek: AvailabilityWeek;
|
||||
AvailabilitySlot: AvailabilitySlot;
|
||||
AvailabilityDayNote: AvailabilityDayNote;
|
||||
TeamEvent: TeamEvent;
|
||||
}
|
||||
|
||||
493
app/features/availability/AvailabilityRepository.server.test.ts
Normal file
493
app/features/availability/AvailabilityRepository.server.test.ts
Normal file
@@ -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([]);
|
||||
});
|
||||
});
|
||||
395
app/features/availability/AvailabilityRepository.server.ts
Normal file
395
app/features/availability/AvailabilityRepository.server.ts
Normal file
@@ -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<number>;
|
||||
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<number>();
|
||||
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<number>;
|
||||
startsAt: number;
|
||||
endsAt: number;
|
||||
}) {
|
||||
if (userIds.length === 0) return Promise.resolve([]);
|
||||
|
||||
return db
|
||||
.selectFrom("TeamEvent")
|
||||
.innerJoin(
|
||||
"TeamMemberWithSecondary",
|
||||
"TeamMemberWithSecondary.teamId",
|
||||
"TeamEvent.teamId",
|
||||
)
|
||||
.select([
|
||||
"TeamMemberWithSecondary.userId",
|
||||
"TeamEvent.name",
|
||||
"TeamEvent.startsAt",
|
||||
"TeamEvent.endsAt",
|
||||
])
|
||||
.where("TeamMemberWithSecondary.userId", "in", userIds)
|
||||
.where("TeamEvent.startsAt", "<", endsAt)
|
||||
.where("TeamEvent.endsAt", ">", startsAt)
|
||||
.execute();
|
||||
}
|
||||
|
||||
/** 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<TimeRange>;
|
||||
dayNotes: Array<
|
||||
Pick<TablesInsertable["AvailabilityDayNote"], "date" | "text">
|
||||
>;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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<TablesInsertable["TeamEvent"], "authorId">,
|
||||
) {
|
||||
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();
|
||||
}
|
||||
85
app/features/availability/actions/events.server.test.ts
Normal file
85
app/features/availability/actions/events.server.test.ts
Normal file
@@ -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<typeof saveWeekSchema>({
|
||||
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");
|
||||
});
|
||||
});
|
||||
103
app/features/availability/actions/events.server.ts
Normal file
103
app/features/availability/actions/events.server.ts
Normal file
@@ -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;
|
||||
};
|
||||
@@ -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);
|
||||
}
|
||||
};
|
||||
26
app/features/availability/availability-constants.ts
Normal file
26
app/features/availability/availability-constants.ts
Normal file
@@ -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;
|
||||
47
app/features/availability/availability-schemas.test.ts
Normal file
47
app/features/availability/availability-schemas.test.ts
Normal file
@@ -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);
|
||||
});
|
||||
});
|
||||
97
app/features/availability/availability-schemas.ts
Normal file
97
app/features/availability/availability-schemas.ts
Normal file
@@ -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,
|
||||
]);
|
||||
11
app/features/availability/availability-search-params.test.ts
Normal file
11
app/features/availability/availability-search-params.test.ts
Normal file
@@ -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"],
|
||||
});
|
||||
});
|
||||
});
|
||||
10
app/features/availability/availability-search-params.ts
Normal file
10
app/features/availability/availability-search-params.ts
Normal file
@@ -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,
|
||||
}),
|
||||
});
|
||||
118
app/features/availability/availability-types.ts
Normal file
118
app/features/availability/availability-types.ts
Normal file
@@ -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<TimeRange>;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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<number>;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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<DayTimeRange>;
|
||||
note: string;
|
||||
}
|
||||
|
||||
/** The schedule editor's value: the seven days of one week, Monday first. */
|
||||
export type AvailabilityEditorWeek = Array<AvailabilityEditorDay>;
|
||||
|
||||
/** 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<TimeRange> }
|
||||
| { 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<string>;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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<TimeRange>;
|
||||
/** Their commitments overlapping the window. */
|
||||
busy: Array<BusyBlock>;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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<TimeRange> }>;
|
||||
}
|
||||
27
app/features/availability/components/MySchedule.module.css
Normal file
27
app/features/availability/components/MySchedule.module.css
Normal file
@@ -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);
|
||||
}
|
||||
185
app/features/availability/components/MySchedule.tsx
Normal file
185
app/features/availability/components/MySchedule.tsx
Normal file
@@ -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<Array<AvailabilityEditorWeek>>(() =>
|
||||
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<typeof useUnsavedChangesChecker>[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 (
|
||||
<section className="stack sm" data-testid="my-schedule">
|
||||
<div className={styles.header}>
|
||||
<h2 className="text-lg mx-2">{t("schedule:editor.title")}</h2>
|
||||
<WeekToggle
|
||||
name="my-schedule-week"
|
||||
value={week}
|
||||
onChange={(value) => setParams({ week: value })}
|
||||
renderExtra={(value) =>
|
||||
!data.weeks[value === "next" ? 1 : 0].submitted ? (
|
||||
<span
|
||||
className={styles.notFilled}
|
||||
data-testid={`week-not-filled-${value}`}
|
||||
>
|
||||
• {t("schedule:editor.notFilled")}
|
||||
</span>
|
||||
) : null
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<h3 className={styles.weekHeading}>
|
||||
{t("schedule:team.weekHeading", {
|
||||
week: data.weeks[weekIndex].weekNumber,
|
||||
})}{" "}
|
||||
·{" "}
|
||||
{headingFormatter.formatRange(
|
||||
dateAtNoon(shownDays[0].date),
|
||||
dateAtNoon(shownDays[6].date),
|
||||
)}
|
||||
</h3>
|
||||
<WeekAvailabilityEditor
|
||||
key={data.weeks[weekIndex].weekStartsAt}
|
||||
value={shownDays}
|
||||
commitments={data.commitments.map((commitment) => ({
|
||||
date: commitment.date,
|
||||
range: commitment.range,
|
||||
name: commitment.name ?? t("schedule:commitment.scrim"),
|
||||
}))}
|
||||
onChange={(value) =>
|
||||
setWeeks(
|
||||
weeks.map((days, index) => (index === weekIndex ? value : days)),
|
||||
)
|
||||
}
|
||||
onPendingDraftChange={(hasPendingDraft) => {
|
||||
hasPendingDraftRef.current = hasPendingDraft;
|
||||
}}
|
||||
/>
|
||||
<div className={styles.actions}>
|
||||
<SendouButton
|
||||
variant="outlined"
|
||||
size="small"
|
||||
isDisabled={!canCopy}
|
||||
onPress={copyPreviousWeek}
|
||||
testId="copy-last-week-button"
|
||||
>
|
||||
{t("schedule:editor.copyLastWeek")}
|
||||
</SendouButton>
|
||||
<SendouButton
|
||||
size="small"
|
||||
isDisabled={state !== "idle"}
|
||||
onPress={saveWeek}
|
||||
testId="save-week-button"
|
||||
>
|
||||
{t("schedule:editor.saveWeek")}
|
||||
</SendouButton>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function useSavedToast(fetcher: FetcherWithComponents<unknown>) {
|
||||
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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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<RegistrationAvailability>;
|
||||
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<AvailabilityPanelUser>;
|
||||
/** Friends not on the shown roster and not in the tournament, panel keeps the free ones. */
|
||||
subCandidates: Array<AvailabilityPanelUser>;
|
||||
}) {
|
||||
const { t } = useTranslation(["schedule"]);
|
||||
const { formatter: dateFormatter } = useDateTimeFormat({
|
||||
month: "long",
|
||||
day: "numeric",
|
||||
});
|
||||
|
||||
if (availability.beyondHorizon) {
|
||||
return (
|
||||
<section className={styles.panel}>
|
||||
<h4 className={styles.heading}>{t("schedule:registration.title")}</h4>
|
||||
<div className={styles.mutedText}>
|
||||
{t("schedule:registration.beyondHorizon", {
|
||||
date: dateFormatter.format(availability.beyondHorizon.opensAt),
|
||||
})}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
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 = (
|
||||
<ul className={styles.rows}>
|
||||
{freeSubs.map((user) => (
|
||||
<AvailabilityMemberRow
|
||||
key={user.id}
|
||||
user={user}
|
||||
entry={entryByUserId.get(user.id)}
|
||||
/>
|
||||
))}
|
||||
</ul>
|
||||
);
|
||||
|
||||
return (
|
||||
<section className={styles.panel}>
|
||||
<h4 className={styles.heading}>
|
||||
{t("schedule:registration.title")} ·{" "}
|
||||
<AvailabilityWindowText window={availability.window} />
|
||||
</h4>
|
||||
{roster.length > 0 ? (
|
||||
<>
|
||||
<ul className={styles.rows}>
|
||||
{roster.map((user) => (
|
||||
<AvailabilityMemberRow
|
||||
key={user.id}
|
||||
user={user}
|
||||
entry={entryByUserId.get(user.id)}
|
||||
/>
|
||||
))}
|
||||
</ul>
|
||||
<AvailabilitySummary
|
||||
statuses={roster.map((user) =>
|
||||
availabilityRowStatus(entryByUserId.get(user.id)),
|
||||
)}
|
||||
/>
|
||||
</>
|
||||
) : null}
|
||||
{freeSubs.length > 0 ? (
|
||||
roster.length > 0 ? (
|
||||
<div className={styles.subsSection}>
|
||||
<h5 className={styles.subsHeading}>
|
||||
{t("schedule:registration.friends")}
|
||||
</h5>
|
||||
{freeSubRows}
|
||||
</div>
|
||||
) : (
|
||||
freeSubRows
|
||||
)
|
||||
) : null}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 (
|
||||
<li
|
||||
className={styles.row}
|
||||
data-testid={`availability-row-${user.id}`}
|
||||
data-status={showAvailability ? status : undefined}
|
||||
>
|
||||
{showAvailability ? <StatusIcon status={status} /> : null}
|
||||
<Avatar user={user} size="xxs" />
|
||||
<span className={styles.nameBlock} data-testid={nameTestId}>
|
||||
<span className={styles.name}>{primaryName ?? user.username}</span>
|
||||
{secondaryName ? (
|
||||
<span className={styles.secondaryName}>{secondaryName}</span>
|
||||
) : null}
|
||||
</span>
|
||||
{showAvailability ? <AvailabilityRowDetail entry={entry} /> : null}
|
||||
{showAvailability
|
||||
? entry?.notes?.map((note, index) => (
|
||||
<span key={index} className={styles.note}>
|
||||
<Flag size={12} className={styles.noteFlag} /> {note}
|
||||
</span>
|
||||
))
|
||||
: null}
|
||||
{trailing ? <span className={styles.trailing}>{trailing}</span> : null}
|
||||
</li>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 <RangesText ranges={availability.ranges} />;
|
||||
case "unavailable":
|
||||
return (
|
||||
<span className={styles.detailText}>
|
||||
{t("schedule:team.notAvailable")}
|
||||
</span>
|
||||
);
|
||||
case "unknown":
|
||||
return (
|
||||
<span className={styles.detailText}>
|
||||
{t("schedule:team.noSchedule")}
|
||||
</span>
|
||||
);
|
||||
case "busy":
|
||||
return (
|
||||
<span className={styles.busy}>
|
||||
<span className={styles.busyName}>
|
||||
{availability.block.name ?? t("schedule:commitment.scrim")}
|
||||
</span>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/** 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<AvailabilityPanelData["window"]>;
|
||||
}) {
|
||||
const { t } = useTranslation(["schedule"]);
|
||||
const { formatter } = useDateTimeFormat({
|
||||
weekday: "short",
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
hour: "numeric",
|
||||
minute: "2-digit",
|
||||
});
|
||||
|
||||
return (
|
||||
<span className={styles.windowText}>
|
||||
{formatter.formatRange(window.startsAt, window.endsAt)} (
|
||||
{t("schedule:registration.estimated")})
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
/** Counts by status, e.g. "2 available · 1 partial · 1 out". */
|
||||
export function AvailabilitySummary({
|
||||
statuses,
|
||||
className,
|
||||
}: {
|
||||
statuses: Array<AvailabilityRowStatus>;
|
||||
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 (
|
||||
<span className={clsx(styles.summary, className)}>{parts.join(" · ")}</span>
|
||||
);
|
||||
}
|
||||
|
||||
/** 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<AvailabilityRowStatus>;
|
||||
}) {
|
||||
const shown = [
|
||||
...statuses.filter((status) => status === "available"),
|
||||
...statuses.filter((status) => status === "partial"),
|
||||
];
|
||||
if (shown.length === 0) return null;
|
||||
|
||||
return (
|
||||
<span className={styles.dots}>
|
||||
{shown.map((status, i) => (
|
||||
<span key={i} className={styles.dot} data-status={status} />
|
||||
))}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function RangesText({ ranges }: { ranges: Array<TimeRange> }) {
|
||||
const rangeText = useRangeText();
|
||||
|
||||
return (
|
||||
<span className={styles.ranges}>{ranges.map(rangeText).join(" · ")}</span>
|
||||
);
|
||||
}
|
||||
|
||||
function StatusIcon({ status }: { status: AvailabilityRowStatus }) {
|
||||
return (
|
||||
<span className={styles.statusCircle} data-status={status}>
|
||||
{statusGlyph(status)}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function statusGlyph(status: AvailabilityRowStatus) {
|
||||
switch (status) {
|
||||
case "available":
|
||||
return (
|
||||
<Check size={15} strokeWidth={3} className={styles.iconAvailable} />
|
||||
);
|
||||
case "partial":
|
||||
return <Clock size={13} strokeWidth={3} className={styles.iconPartial} />;
|
||||
case "unavailable":
|
||||
return <X size={15} strokeWidth={3} className={styles.iconUnavailable} />;
|
||||
case "busy":
|
||||
return (
|
||||
<CalendarX
|
||||
size={13}
|
||||
strokeWidth={3}
|
||||
className={styles.iconUnavailable}
|
||||
/>
|
||||
);
|
||||
case "unknown":
|
||||
return (
|
||||
<Ellipsis size={15} strokeWidth={3} className={styles.iconUnknown} />
|
||||
);
|
||||
case "hidden":
|
||||
return (
|
||||
<EyeOff size={13} strokeWidth={3} className={styles.iconUnknown} />
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
94
app/features/availability/components/ScheduleDayCell.tsx
Normal file
94
app/features/availability/components/ScheduleDayCell.tsx
Normal file
@@ -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<TimeRange>;
|
||||
busy?: Array<BusyBlock>;
|
||||
note?: string;
|
||||
}) {
|
||||
const { t } = useTranslation(["schedule"]);
|
||||
const rangeText = useRangeText();
|
||||
|
||||
const busyName = (block: BusyBlock) =>
|
||||
block.name ?? t("schedule:commitment.scrim");
|
||||
|
||||
return (
|
||||
<div className={styles.content}>
|
||||
{!reported ? (
|
||||
<span className={styles.unknown} title={t("schedule:team.noSchedule")}>
|
||||
?
|
||||
</span>
|
||||
) : ranges.length === 0 && busy.length === 0 ? (
|
||||
<span
|
||||
className={styles.unavailable}
|
||||
title={t("schedule:team.notAvailable")}
|
||||
>
|
||||
—
|
||||
</span>
|
||||
) : (
|
||||
ranges.map((range) => (
|
||||
<div
|
||||
key={range.startsAt}
|
||||
className={styles.range}
|
||||
data-testid="schedule-range"
|
||||
>
|
||||
{rangeText(range)}
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
{busy.map((block, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className={styles.busy}
|
||||
title={`${rangeText(block)} · ${busyName(block)}`}
|
||||
data-testid="schedule-busy"
|
||||
>
|
||||
<span className={styles.busyName}>{busyName(block)}</span>
|
||||
</div>
|
||||
))}
|
||||
{note ? (
|
||||
<span title={note}>
|
||||
<Flag className={styles.noteFlag} size={12} aria-hidden />
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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)}`;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
63
app/features/availability/components/ScheduleNudge.tsx
Normal file
63
app/features/availability/components/ScheduleNudge.tsx
Normal file
@@ -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 (
|
||||
<div
|
||||
className={clsx(styles.container, panel ? styles.panel : styles.sidebar)}
|
||||
>
|
||||
<Link
|
||||
to={scheduleWeekSearchParams.href(EVENTS_PAGE, { week: "next" })}
|
||||
className={styles.link}
|
||||
onClick={onNavigate}
|
||||
>
|
||||
<CalendarPlus size={14} />
|
||||
{t("front:sideNav.scheduleNudge")}
|
||||
</Link>
|
||||
<SendouButton
|
||||
icon={<X size={14} />}
|
||||
variant="minimal"
|
||||
size="miniscule"
|
||||
className={styles.dismissButton}
|
||||
aria-label={t("front:sideNav.scheduleNudge.dismiss")}
|
||||
onPress={dismiss}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
202
app/features/availability/components/ScheduleTracks.module.css
Normal file
202
app/features/availability/components/ScheduleTracks.module.css
Normal file
@@ -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);
|
||||
}
|
||||
}
|
||||
217
app/features/availability/components/ScheduleTracks.tsx
Normal file
217
app/features/availability/components/ScheduleTracks.tsx
Normal file
@@ -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<typeof useClockWindow>;
|
||||
|
||||
/**
|
||||
* 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<DayTimeRange>;
|
||||
/**
|
||||
* 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<DayTimeRange>;
|
||||
} = {}) {
|
||||
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<number> = [];
|
||||
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<DayTimeRange>) {
|
||||
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 (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
className={clsx(styles.axisToggle, styles.axisLead)}
|
||||
onClick={() => clockWindow.setEarlierShown(!clockWindow.earlierShown)}
|
||||
>
|
||||
{clockWindow.earlierShown ? (
|
||||
<ChevronRight size={12} aria-hidden />
|
||||
) : (
|
||||
<ChevronLeft size={12} aria-hidden />
|
||||
)}
|
||||
{t("schedule:editor.earlier")}
|
||||
</button>
|
||||
<div className={styles.axis}>
|
||||
{clockWindow.hours.map((hour) => (
|
||||
<span
|
||||
key={hour}
|
||||
className={clsx(styles.axisLabel, {
|
||||
[styles.axisLabelFirst]:
|
||||
hour * MINUTES_IN_HOUR === clockWindow.trackStart,
|
||||
[styles.axisLabelLast]:
|
||||
hour * MINUTES_IN_HOUR === clockWindow.trackEnd,
|
||||
})}
|
||||
style={{ left: `${clockWindow.pct(hour * MINUTES_IN_HOUR)}%` }}
|
||||
>
|
||||
{formatter.format(hourAt(hour))}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className={clsx(styles.axisToggle, styles.axisTrail)}
|
||||
onClick={() => clockWindow.setLaterShown(!clockWindow.laterShown)}
|
||||
>
|
||||
{t("schedule:editor.later")}
|
||||
{clockWindow.laterShown ? (
|
||||
<ChevronLeft size={12} aria-hidden />
|
||||
) : (
|
||||
<ChevronRight size={12} aria-hidden />
|
||||
)}
|
||||
</button>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
/** 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) => (
|
||||
<div
|
||||
key={hour}
|
||||
className={clsx(styles.tick, {
|
||||
[styles.tickMidnight]: hour === 24,
|
||||
})}
|
||||
style={{ left: `${clockWindow.pct(hour * MINUTES_IN_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 (
|
||||
<div
|
||||
className={styles.commitment}
|
||||
style={clockWindow.barStyle(range)}
|
||||
title={name}
|
||||
data-testid="availability-commitment"
|
||||
>
|
||||
<span className={styles.commitmentName}>{name}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
85
app/features/availability/components/ScheduleWeekDialog.tsx
Normal file
85
app/features/availability/components/ScheduleWeekDialog.tsx
Normal file
@@ -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<ScheduleWeekView>;
|
||||
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 (
|
||||
<SendouDialog
|
||||
heading={t("schedule:friends.availabilityOf", { name: username })}
|
||||
onClose={onClose}
|
||||
isDismissable
|
||||
>
|
||||
<div className="stack md">
|
||||
<div className={styles.header}>
|
||||
<span className={styles.weekLabel}>
|
||||
{t("schedule:team.weekHeading", { week: shownWeek.weekNumber })} ·{" "}
|
||||
{headingFormatter.formatRange(
|
||||
shownWeek.days[0].noonAt,
|
||||
shownWeek.days[6].noonAt,
|
||||
)}
|
||||
</span>
|
||||
<WeekToggle
|
||||
name="friend-schedule-week"
|
||||
value={week}
|
||||
onChange={(value) => setParams({ week: value })}
|
||||
/>
|
||||
</div>
|
||||
{shownWeek.reported ? (
|
||||
<WeekDays week={shownWeek} />
|
||||
) : (
|
||||
<div className="text-lighter text-sm" data-testid="schedule-no-week">
|
||||
{t("schedule:team.noSchedule")}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</SendouDialog>
|
||||
);
|
||||
}
|
||||
|
||||
function WeekDays({ week }: { week: ScheduleWeekView }) {
|
||||
const { formatter: dayFormatter } = useDateTimeFormat({
|
||||
weekday: "short",
|
||||
day: "numeric",
|
||||
});
|
||||
|
||||
return (
|
||||
<ul className={styles.days} data-testid="schedule-week-days">
|
||||
{week.days.map((day) => (
|
||||
<li key={day.noonAt} className={styles.day}>
|
||||
<span className={styles.dayLabel}>
|
||||
{dayFormatter.format(day.noonAt)}
|
||||
</span>
|
||||
<ScheduleDayCell reported ranges={day.ranges} />
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
778
app/features/availability/components/WeekAvailabilityEditor.tsx
Normal file
778
app/features/availability/components/WeekAvailabilityEditor.tsx
Normal file
@@ -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<DraftRange>;
|
||||
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<EditorCommitment>;
|
||||
/** 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<Gesture | null>(null);
|
||||
const gestureRef = React.useRef<Gesture | null>(null);
|
||||
const [openDayDate, setOpenDayDate] = React.useState<string | null>(null);
|
||||
const [openDayAddRow, setOpenDayAddRow] = React.useState(false);
|
||||
const popoverAnchorRef = React.useRef<HTMLElement | null>(null);
|
||||
const dayDraftRef = React.useRef<DayDraft | null>(null);
|
||||
const trackRefs = React.useRef<Array<HTMLDivElement | null>>([]);
|
||||
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<DayTimeRange>) => {
|
||||
onChange(
|
||||
value.map((day, index) =>
|
||||
index === dayIndex ? { ...day, ranges } : day,
|
||||
),
|
||||
);
|
||||
};
|
||||
|
||||
const handleTrackPointerDown =
|
||||
(dayIndex: number) => (event: React.PointerEvent<HTMLDivElement>) => {
|
||||
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<HTMLButtonElement>) => {
|
||||
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<HTMLSpanElement>) => {
|
||||
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<HTMLSpanElement>) => {
|
||||
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<HTMLElement>,
|
||||
) => {
|
||||
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 (
|
||||
<React.Fragment key={day.date}>
|
||||
<div className={trackStyles.dayLabel}>
|
||||
{dayLabelText(day)}
|
||||
{day.note ? (
|
||||
<Flag className={trackStyles.noteFlag} size={12} aria-hidden />
|
||||
) : null}
|
||||
</div>
|
||||
<div
|
||||
ref={(element) => {
|
||||
trackRefs.current[dayIndex] = element;
|
||||
}}
|
||||
className={clsx(trackStyles.track, styles.paintable)}
|
||||
data-testid={`availability-track-${dayIndex}`}
|
||||
onPointerDown={handleTrackPointerDown(dayIndex)}
|
||||
onPointerMove={handleGestureMove}
|
||||
onPointerUp={handleGestureEnd}
|
||||
onPointerCancel={handleGestureCancel}
|
||||
>
|
||||
<TrackTicks clockWindow={clockWindow} />
|
||||
{dayCommitments.map((commitment, index) => (
|
||||
<TrackCommitment
|
||||
key={index}
|
||||
clockWindow={clockWindow}
|
||||
range={commitment.range}
|
||||
name={commitment.name}
|
||||
/>
|
||||
))}
|
||||
{day.ranges.map((range) => {
|
||||
const isDragged =
|
||||
(dayGesture?.type === "move" || dayGesture?.type === "resize") &&
|
||||
sameRange(range, dayGesture.original);
|
||||
const shown = isDragged && dayGesture ? dayGesture.range : range;
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
key={`${range.start}-${range.end}`}
|
||||
className={styles.bar}
|
||||
style={barStyle(shown)}
|
||||
data-testid="availability-bar"
|
||||
aria-label={`${t("schedule:editor.editDay", {
|
||||
day: dayLabelText(day),
|
||||
})} (${rangeText(day.date, range)})`}
|
||||
title={rangeText(day.date, shown)}
|
||||
onPointerDown={handleBarPointerDown(dayIndex, range)}
|
||||
onClick={(event) => handleBarClick(day.date, event)}
|
||||
>
|
||||
<span className={styles.barTimes}>
|
||||
{rangeText(day.date, shown)}
|
||||
</span>
|
||||
<span className={styles.barTimesShort}>
|
||||
{timeFormatter.format(dateAt(day.date, shown.start))}
|
||||
</span>
|
||||
<span
|
||||
className={clsx(styles.handle, styles.handleStart)}
|
||||
onPointerDown={handleResizePointerDown(
|
||||
dayIndex,
|
||||
range,
|
||||
"start",
|
||||
)}
|
||||
/>
|
||||
<span
|
||||
className={clsx(styles.handle, styles.handleEnd)}
|
||||
onPointerDown={handleResizePointerDown(
|
||||
dayIndex,
|
||||
range,
|
||||
"end",
|
||||
)}
|
||||
/>
|
||||
<span
|
||||
className={styles.fillHandle}
|
||||
onPointerDown={handleFillPointerDown(dayIndex, range)}
|
||||
/>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
{gesture?.type === "paint" &&
|
||||
gesture.dayIndex === dayIndex &&
|
||||
gesture.range ? (
|
||||
<div
|
||||
className={clsx(styles.bar, styles.barPreview)}
|
||||
style={barStyle(gesture.range)}
|
||||
/>
|
||||
) : null}
|
||||
{fillPreview.map((piece) => (
|
||||
<div
|
||||
key={`${piece.start}-${piece.end}`}
|
||||
className={clsx(styles.bar, styles.barPreview)}
|
||||
style={barStyle(piece)}
|
||||
/>
|
||||
))}
|
||||
{liveRange ? (
|
||||
<span
|
||||
className={styles.liveLabel}
|
||||
style={{ left: `${pct(liveRange.start)}%` }}
|
||||
>
|
||||
{rangeText(day.date, liveRange)}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className={styles.editButton}
|
||||
data-testid={`availability-day-edit-${dayIndex}`}
|
||||
aria-label={t("schedule:editor.editDay", { day: dayLabelText(day) })}
|
||||
onClick={(event) => openDayEditor(day.date, event.currentTarget)}
|
||||
>
|
||||
<SquarePen size={14} aria-hidden />
|
||||
</button>
|
||||
</React.Fragment>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={trackStyles.container}>
|
||||
<div className={styles.editor}>
|
||||
<div className={trackStyles.tracks}>
|
||||
<ClockAxis
|
||||
clockWindow={clockWindow}
|
||||
dayStartsAt={dateAt(value[0].date, 0)}
|
||||
/>
|
||||
{value.map((day, dayIndex) => dayRow(day, dayIndex))}
|
||||
</div>
|
||||
<div className={trackStyles.list}>
|
||||
{value.map((day) => {
|
||||
const dayCommitments = commitments.filter(
|
||||
(commitment) => commitment.date === day.date,
|
||||
);
|
||||
|
||||
return (
|
||||
<div key={day.date} className={trackStyles.listDay}>
|
||||
<div className={trackStyles.listDayHeader}>
|
||||
{dayLabelText(day)}
|
||||
</div>
|
||||
<div className={trackStyles.listDayBody}>
|
||||
{day.ranges.map((range) => (
|
||||
<button
|
||||
type="button"
|
||||
key={`${range.start}-${range.end}`}
|
||||
className={trackStyles.timeChip}
|
||||
onClick={(event) =>
|
||||
openDayEditor(day.date, event.currentTarget)
|
||||
}
|
||||
>
|
||||
{rangeText(day.date, range)}
|
||||
</button>
|
||||
))}
|
||||
{dayCommitments.map((commitment, index) => (
|
||||
<span key={index} className={trackStyles.commitmentChip}>
|
||||
{commitment.name} ·{" "}
|
||||
{rangeText(day.date, commitment.range)}
|
||||
</span>
|
||||
))}
|
||||
<button
|
||||
type="button"
|
||||
className={styles.addChip}
|
||||
onClick={(event) =>
|
||||
openDayEditor(day.date, event.currentTarget, true)
|
||||
}
|
||||
>
|
||||
<Plus size={14} aria-hidden />
|
||||
{t("schedule:editor.addTime")}
|
||||
</button>
|
||||
</div>
|
||||
{day.note ? (
|
||||
<div className={styles.listNote}>
|
||||
<Flag
|
||||
size={12}
|
||||
aria-hidden
|
||||
className={trackStyles.noteFlag}
|
||||
/>
|
||||
{day.note}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<p className={styles.footer}>
|
||||
{t("schedule:editor.timesInYourTimezone")} ·{" "}
|
||||
{t("schedule:editor.visibility")}
|
||||
</p>
|
||||
</div>
|
||||
{openDay ? (
|
||||
<SendouAnchoredPopover
|
||||
isOpen
|
||||
onOpenChange={(isOpen) => {
|
||||
if (!isOpen) closeDayEditor();
|
||||
}}
|
||||
triggerRef={popoverAnchorRef}
|
||||
aria-label={t("schedule:editor.editDay", {
|
||||
day: dayLabelText(openDay),
|
||||
})}
|
||||
>
|
||||
<DayEditor
|
||||
day={openDay}
|
||||
dayLabel={dayLabelText(openDay)}
|
||||
startWithNewRow={openDayAddRow}
|
||||
onDraftChange={(draft) => {
|
||||
dayDraftRef.current = draft;
|
||||
onPendingDraftChange?.(
|
||||
!R.isDeepEqual(dayFromDraft(openDay, draft), openDay),
|
||||
);
|
||||
}}
|
||||
onRangeDelete={handleRangeDelete}
|
||||
/>
|
||||
</SendouAnchoredPopover>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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<Array<DraftRange>>(() => {
|
||||
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<DraftRange>, nextNote: string) => {
|
||||
setRanges(nextRanges);
|
||||
setNote(nextNote);
|
||||
onDraftChange({ ranges: nextRanges, note: nextNote });
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={styles.dayEditor}>
|
||||
<div className={styles.dayEditorTitle}>{dayLabel}</div>
|
||||
{ranges.map((range) => (
|
||||
<div key={range.id} className={styles.dayEditorRange}>
|
||||
<TimeRangeFormField
|
||||
name={`range-${range.id}`}
|
||||
value={{ start: range.start, end: range.end }}
|
||||
onChange={(next) =>
|
||||
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")}
|
||||
/>
|
||||
<SendouButton
|
||||
icon={<Trash />}
|
||||
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 });
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
<SendouButton
|
||||
icon={<Plus />}
|
||||
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")}
|
||||
</SendouButton>
|
||||
<div>
|
||||
<Label
|
||||
htmlFor={noteId}
|
||||
valueLimits={{
|
||||
current: note.length,
|
||||
max: AVAILABILITY.DAY_NOTE_MAX_LENGTH,
|
||||
}}
|
||||
>
|
||||
{t("schedule:editor.note")}
|
||||
</Label>
|
||||
<Input
|
||||
id={noteId}
|
||||
value={note}
|
||||
maxLength={AVAILABILITY.DAY_NOTE_MAX_LENGTH}
|
||||
onChange={(event) => update(ranges, event.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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);
|
||||
54
app/features/availability/components/WeekToggle.tsx
Normal file
54
app/features/availability/components/WeekToggle.tsx
Normal file
@@ -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 (
|
||||
<SendouChipRadioGroup>
|
||||
{WEEK_VALUES.map((week) => (
|
||||
<SendouChipRadio
|
||||
key={week}
|
||||
name={name}
|
||||
value={week}
|
||||
checked={value === week}
|
||||
onChange={() => onChange(week)}
|
||||
>
|
||||
{renderExtra ? (
|
||||
<span>
|
||||
{label(week)}
|
||||
{renderExtra(week)}
|
||||
</span>
|
||||
) : (
|
||||
label(week)
|
||||
)}
|
||||
</SendouChipRadio>
|
||||
))}
|
||||
</SendouChipRadioGroup>
|
||||
);
|
||||
}
|
||||
867
app/features/availability/core/Availability.test.ts
Normal file
867
app/features/availability/core/Availability.test.ts
Normal file
@@ -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<Array<[start: string, end: string, endDate?: string]>>,
|
||||
) =>
|
||||
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));
|
||||
});
|
||||
});
|
||||
603
app/features/availability/core/Availability.ts
Normal file
603
app/features/availability/core/Availability.ts
Normal file
@@ -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<TimeRange>): Array<TimeRange> {
|
||||
const sorted = R.sortBy(
|
||||
ranges.filter((range) => range.endsAt > range.startsAt),
|
||||
(range) => range.startsAt,
|
||||
);
|
||||
|
||||
const merged: Array<TimeRange> = [];
|
||||
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<TimeRange>,
|
||||
busy: Array<TimeRange>,
|
||||
): Array<TimeRange> {
|
||||
let remaining = normalize(ranges);
|
||||
|
||||
for (const block of normalize(busy)) {
|
||||
const next: Array<TimeRange> = [];
|
||||
|
||||
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<TimeRange>,
|
||||
window: TimeRange,
|
||||
): Array<TimeRange> {
|
||||
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<TimeRange>;
|
||||
busy: Array<BusyBlock>;
|
||||
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<MemberAvailability>;
|
||||
minPlayers?: number;
|
||||
minDurationMinutes?: number;
|
||||
}): Array<PlayableWindow> {
|
||||
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<MemberAvailability>) {
|
||||
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<typeof availabilitySegments>[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<AvailabilitySegment>;
|
||||
threshold: number;
|
||||
}) {
|
||||
const windows: Array<TimeRange & { userIds: Array<number> }> = [];
|
||||
|
||||
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<DayTimeRange>,
|
||||
): Array<DayTimeRange> {
|
||||
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>;
|
||||
}): 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<DayTimeRange>) =>
|
||||
mergedDayRanges(walls).some(
|
||||
(wall) => wall.start <= point && point < wall.end,
|
||||
);
|
||||
304
app/features/availability/core/Commitments.server.test.ts
Normal file
304
app/features/availability/core/Commitments.server.test.ts
Normal file
@@ -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<TournamentSettings>,
|
||||
) {
|
||||
const { settings } = await db
|
||||
.selectFrom("Tournament")
|
||||
.select("settings")
|
||||
.where("id", "=", tournamentId)
|
||||
.executeTakeFirstOrThrow();
|
||||
|
||||
// biome-ignore lint/plugin: leagues are not created through app code, so no production write reaches isLeague
|
||||
await db
|
||||
.updateTable("Tournament")
|
||||
.set({ settings: JSON.stringify({ ...settings, ...patch }) })
|
||||
.where("id", "=", tournamentId)
|
||||
.execute();
|
||||
}
|
||||
102
app/features/availability/core/Commitments.server.ts
Normal file
102
app/features/availability/core/Commitments.server.ts
Normal file
@@ -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<number>;
|
||||
startsAt: number;
|
||||
endsAt: number;
|
||||
excludeTournamentId?: number;
|
||||
}): Promise<Map<number, Array<BusyBlock>>> {
|
||||
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<BusyBlock & { userId: number }> = [
|
||||
...registrations
|
||||
.filter((registration) => !registration.settings.isLeague)
|
||||
.map((registration) => ({
|
||||
userId: registration.userId,
|
||||
type: "tournament" as const,
|
||||
name: registration.name,
|
||||
startsAt: registration.startsAt,
|
||||
endsAt: 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,
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
107
app/features/availability/core/FriendSchedule.server.test.ts
Normal file
107
app/features/availability/core/FriendSchedule.server.test.ts
Normal file
@@ -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 },
|
||||
]);
|
||||
});
|
||||
});
|
||||
77
app/features/availability/core/FriendSchedule.server.ts
Normal file
77
app/features/availability/core/FriendSchedule.server.ts
Normal file
@@ -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<number>;
|
||||
timezone: string;
|
||||
}): Promise<Map<number, Array<ScheduleWeekView>>> {
|
||||
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]] : [];
|
||||
}),
|
||||
);
|
||||
}
|
||||
129
app/features/availability/core/MySchedule.server.ts
Normal file
129
app/features/availability/core/MySchedule.server.ts
Normal file
@@ -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<ReturnType<typeof myScheduleData>>
|
||||
>;
|
||||
|
||||
/**
|
||||
* 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<typeof AvailabilityRepository.findAllWeeksByUserIds>
|
||||
>[number];
|
||||
|
||||
function editorWeek({
|
||||
range,
|
||||
timezone,
|
||||
reportedWeeks,
|
||||
}: {
|
||||
range: TimeRange;
|
||||
timezone: string;
|
||||
reportedWeeks: Array<ReportedWeek>;
|
||||
}) {
|
||||
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 ?? ""
|
||||
);
|
||||
}
|
||||
@@ -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"]);
|
||||
});
|
||||
});
|
||||
@@ -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<typeof registrationAvailability>
|
||||
>;
|
||||
|
||||
/**
|
||||
* 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<Tables["TournamentStage"]["type"]>;
|
||||
teamCount: number;
|
||||
};
|
||||
userIds: Array<number>;
|
||||
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 };
|
||||
}
|
||||
191
app/features/availability/core/RosterSchedule.server.test.ts
Normal file
191
app/features/availability/core/RosterSchedule.server.test.ts
Normal file
@@ -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<number>) =>
|
||||
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<ReturnType<typeof window>>,
|
||||
userIds: Array<number> = [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([]);
|
||||
});
|
||||
});
|
||||
185
app/features/availability/core/RosterSchedule.server.ts
Normal file
185
app/features/availability/core/RosterSchedule.server.ts
Normal file
@@ -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<ReturnType<typeof rosterScheduleData>>
|
||||
>;
|
||||
|
||||
/**
|
||||
* 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<number>;
|
||||
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<TimeRange & { id: number }>;
|
||||
userIds: Array<number>;
|
||||
}) {
|
||||
// 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,
|
||||
};
|
||||
}),
|
||||
}));
|
||||
}
|
||||
124
app/features/availability/core/ScheduleWeek.ts
Normal file
124
app/features/availability/core/ScheduleWeek.ts
Normal file
@@ -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<TimeRange>;
|
||||
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<TimeRange>; busy: Array<BusyBlock> }>;
|
||||
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<ScheduleWeekDay> {
|
||||
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<ScheduleWeekDay>;
|
||||
timezone: string;
|
||||
reportedWeeks: Array<ReportedWeek>;
|
||||
range: TimeRange;
|
||||
busy: Array<BusyBlock>;
|
||||
}): 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<TimeRange>,
|
||||
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 }];
|
||||
}),
|
||||
),
|
||||
};
|
||||
}
|
||||
38
app/features/availability/core/TournamentDuration.server.ts
Normal file
38
app/features/availability/core/TournamentDuration.server.ts
Normal file
@@ -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<Tables["TournamentStage"]["type"]>;
|
||||
/** 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),
|
||||
})
|
||||
);
|
||||
}
|
||||
105
app/features/availability/core/TournamentDuration.test.ts
Normal file
105
app/features/availability/core/TournamentDuration.test.ts
Normal file
@@ -0,0 +1,105 @@
|
||||
import { describe, expect, test } from "vitest";
|
||||
import type { Tables } from "~/db/tables";
|
||||
import * as TournamentDuration from "./TournamentDuration";
|
||||
|
||||
const HOUR = 60 * 60;
|
||||
|
||||
const DOUBLE_ELIMINATION: Array<Tables["TournamentStage"]["type"]> = [
|
||||
"double_elimination",
|
||||
];
|
||||
const GROUPS_TO_TOP_CUT: Array<Tables["TournamentStage"]["type"]> = [
|
||||
"round_robin",
|
||||
"single_elimination",
|
||||
];
|
||||
|
||||
describe("TournamentDuration.estimateSeconds", () => {
|
||||
test.each([
|
||||
{
|
||||
why: "regular 4v4",
|
||||
minMembersPerTeam: 4,
|
||||
bracketTypes: DOUBLE_ELIMINATION,
|
||||
teamCount: 16,
|
||||
expected: 4 * HOUR,
|
||||
},
|
||||
{
|
||||
why: "large 4v4",
|
||||
minMembersPerTeam: 4,
|
||||
bracketTypes: GROUPS_TO_TOP_CUT,
|
||||
teamCount: 32,
|
||||
expected: 4.5 * HOUR,
|
||||
},
|
||||
{
|
||||
why: "single elimination only is the short outlier",
|
||||
minMembersPerTeam: 4,
|
||||
bracketTypes: ["single_elimination"] as const,
|
||||
teamCount: 16,
|
||||
expected: 2 * HOUR,
|
||||
},
|
||||
{
|
||||
why: "single elimination feeding from groups is not the outlier",
|
||||
minMembersPerTeam: 4,
|
||||
bracketTypes: GROUPS_TO_TOP_CUT,
|
||||
teamCount: 16,
|
||||
expected: 4 * HOUR,
|
||||
},
|
||||
{
|
||||
why: "1v1",
|
||||
minMembersPerTeam: 1,
|
||||
bracketTypes: DOUBLE_ELIMINATION,
|
||||
teamCount: 16,
|
||||
expected: 2.5 * HOUR,
|
||||
},
|
||||
{
|
||||
why: "2v2",
|
||||
minMembersPerTeam: 2,
|
||||
bracketTypes: DOUBLE_ELIMINATION,
|
||||
teamCount: 16,
|
||||
expected: 2.5 * HOUR,
|
||||
},
|
||||
{
|
||||
why: "3v3 stays small-sized regardless of team count",
|
||||
minMembersPerTeam: 3,
|
||||
bracketTypes: DOUBLE_ELIMINATION,
|
||||
teamCount: 64,
|
||||
expected: 2.5 * HOUR,
|
||||
},
|
||||
{
|
||||
why: "small-sized single elimination only",
|
||||
minMembersPerTeam: 1,
|
||||
bracketTypes: ["single_elimination"] as const,
|
||||
teamCount: 8,
|
||||
expected: 2 * HOUR,
|
||||
},
|
||||
])(
|
||||
"returns $expected seconds for $why",
|
||||
({ minMembersPerTeam, bracketTypes, teamCount, expected }) => {
|
||||
expect(
|
||||
TournamentDuration.estimateSeconds({
|
||||
minMembersPerTeam,
|
||||
bracketTypes: [...bracketTypes],
|
||||
teamCount,
|
||||
}),
|
||||
).toBe(expected);
|
||||
},
|
||||
);
|
||||
|
||||
test("no estimate exceeds MAX_ESTIMATE_SECONDS", () => {
|
||||
for (const minMembersPerTeam of [1, 2, 3, 4]) {
|
||||
for (const bracketTypes of [
|
||||
DOUBLE_ELIMINATION,
|
||||
GROUPS_TO_TOP_CUT,
|
||||
["single_elimination" as const],
|
||||
]) {
|
||||
for (const teamCount of [4, 16, 32, 100]) {
|
||||
expect(
|
||||
TournamentDuration.estimateSeconds({
|
||||
minMembersPerTeam,
|
||||
bracketTypes,
|
||||
teamCount,
|
||||
}),
|
||||
).toBeLessThanOrEqual(TournamentDuration.MAX_ESTIMATE_SECONDS);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
73
app/features/availability/core/TournamentDuration.ts
Normal file
73
app/features/availability/core/TournamentDuration.ts
Normal file
@@ -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<Tables["TournamentStage"]["type"]>;
|
||||
/** 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;
|
||||
}
|
||||
@@ -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<typeof loader>;
|
||||
|
||||
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<typeof AvailabilityRepository.findTeamEventsByTeamId>
|
||||
>[number];
|
||||
|
||||
function weekView({
|
||||
range,
|
||||
timezone,
|
||||
memberIds,
|
||||
playerIds,
|
||||
reportedWeeks,
|
||||
busyByUserId,
|
||||
teamEvents,
|
||||
}: {
|
||||
range: TimeRange;
|
||||
timezone: string;
|
||||
memberIds: Array<number>;
|
||||
playerIds: Array<number>;
|
||||
reportedWeeks: Array<ScheduleWeek.ReportedWeek>;
|
||||
busyByUserId: Map<number, Array<BusyBlock>>;
|
||||
teamEvents: Array<TeamEventRow>;
|
||||
}) {
|
||||
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<TimeRange & { tier: PlayableWindowTier }>;
|
||||
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;
|
||||
}
|
||||
@@ -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 <main> 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;
|
||||
}
|
||||
407
app/features/availability/routes/t.$customUrl.schedule.tsx
Normal file
407
app/features/availability/routes/t.$customUrl.schedule.tsx
Normal file
@@ -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<TeamScheduleLoaderData["weeks"]>[number];
|
||||
type MemberWeekRow = WeekData["members"][number];
|
||||
type TeamMember = TeamLoaderData["team"]["members"][number];
|
||||
|
||||
export default function TeamSchedulePage() {
|
||||
const { t } = useTranslation(["schedule"]);
|
||||
const data = useLoaderData<typeof loader>();
|
||||
|
||||
return (
|
||||
<div className="stack md">
|
||||
<TeamGoBackButton />
|
||||
{data.weeks ? (
|
||||
<ScheduleWeeks weeks={data.weeks} />
|
||||
) : (
|
||||
<div data-testid="schedule-hidden">
|
||||
<Alert variation="INFO">{t("schedule:team.hidden")}</Alert>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ScheduleWeeks({ weeks }: { weeks: Array<WeekData> }) {
|
||||
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 (
|
||||
<div className="stack md">
|
||||
<div className={styles.header}>
|
||||
<h2 className={styles.heading}>
|
||||
{t("schedule:team.weekHeading", { week: shownWeek.weekNumber })} ·{" "}
|
||||
{headingFormatter.formatRange(
|
||||
shownWeek.days[0].noonAt,
|
||||
shownWeek.days[6].noonAt,
|
||||
)}
|
||||
</h2>
|
||||
<WeekToggle
|
||||
name="schedule-week"
|
||||
value={week}
|
||||
onChange={(value) => setParams({ week: value })}
|
||||
/>
|
||||
</div>
|
||||
<TeamEvents week={shownWeek} />
|
||||
<ScheduleGrid week={shownWeek} />
|
||||
<PlayableWindowsSummary week={shownWeek} />
|
||||
<WeekNotes week={shownWeek} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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 }) => (
|
||||
<tr key={row.userId} data-testid={`schedule-row-${row.userId}`}>
|
||||
<th scope="row" className={styles.memberCell}>
|
||||
<UserLink user={row.member} className={styles.memberLink} />
|
||||
</th>
|
||||
{row.days.map((day, dayIndex) => (
|
||||
<ScheduleCell
|
||||
key={week.days[dayIndex].date}
|
||||
row={row}
|
||||
day={day}
|
||||
dayIndex={dayIndex}
|
||||
/>
|
||||
))}
|
||||
</tr>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className={styles.gridScroll}>
|
||||
<table className={styles.grid} data-testid="schedule-grid">
|
||||
<thead>
|
||||
<tr>
|
||||
<td />
|
||||
{week.days.map((day, dayIndex) => (
|
||||
<th key={day.date} scope="col" className={styles.dayHeader}>
|
||||
{day.windowTier ? (
|
||||
<span
|
||||
className={clsx(styles.tierDot, styles.dayDot, {
|
||||
[styles.tierDotFull]: day.windowTier === "FULL",
|
||||
})}
|
||||
data-testid={`schedule-day-dot-${dayIndex}`}
|
||||
/>
|
||||
) : null}
|
||||
{dayFormatter.format(day.noonAt)}
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{playerRows.map(renderRow)}
|
||||
{otherRows.length > 0 ? (
|
||||
<tr>
|
||||
<th scope="colgroup" colSpan={8}>
|
||||
{t("team:roster.sections.other")}
|
||||
</th>
|
||||
</tr>
|
||||
) : null}
|
||||
{otherRows.map(renderRow)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ScheduleCell({
|
||||
row,
|
||||
day,
|
||||
dayIndex,
|
||||
}: {
|
||||
row: MemberWeekRow;
|
||||
day: MemberWeekRow["days"][number];
|
||||
dayIndex: number;
|
||||
}) {
|
||||
const note = row.notes.find((note) => note.dayIndex === dayIndex);
|
||||
|
||||
return (
|
||||
<td data-testid={`schedule-cell-${row.userId}-${dayIndex}`}>
|
||||
<ScheduleDayCell
|
||||
reported={row.reported}
|
||||
ranges={day.ranges}
|
||||
busy={day.busy}
|
||||
note={note?.text}
|
||||
/>
|
||||
</td>
|
||||
);
|
||||
}
|
||||
|
||||
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 (
|
||||
<div className={styles.summary} data-testid="schedule-summary">
|
||||
<div className={styles.summaryRow}>
|
||||
<span className={clsx(styles.tierDot, styles.tierDotFull)} />
|
||||
<span className={styles.summaryLabel}>
|
||||
{t("schedule:team.canPlay", { players: week.minPlayers })}
|
||||
</span>
|
||||
<WindowList windows={fullWindows} />
|
||||
</div>
|
||||
{week.minPlayers > 1 && oneShortWindows.length > 0 ? (
|
||||
<div className={styles.summaryRow}>
|
||||
<span className={styles.tierDot} />
|
||||
<span className={styles.summaryLabel}>
|
||||
{t("schedule:team.withSub", { players: week.minPlayers - 1 })}
|
||||
</span>
|
||||
<WindowList windows={oneShortWindows} />
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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 <span className="text-lighter">{t("schedule:team.noWindows")}</span>;
|
||||
}
|
||||
|
||||
return (
|
||||
<span className={styles.windowList}>
|
||||
{windows.map((window) => (
|
||||
<span
|
||||
key={window.startsAt}
|
||||
className={styles.window}
|
||||
data-testid="schedule-window"
|
||||
>
|
||||
{windowFormatter.formatRange(window.startsAt, window.endsAt)}
|
||||
</span>
|
||||
))}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
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 (
|
||||
<ul className={styles.notes}>
|
||||
{notes.map((note) => (
|
||||
<li
|
||||
key={`${note.userId}-${note.dayIndex}`}
|
||||
className={styles.note}
|
||||
data-testid="schedule-note"
|
||||
>
|
||||
<Flag size={12} aria-hidden className={styles.noteFlag} />
|
||||
<span className={styles.noteDay}>
|
||||
{dayFormatter.format(week.days[note.dayIndex].noonAt)}
|
||||
</span>
|
||||
<span className={styles.noteAuthor}>
|
||||
{members.find((member) => member.id === note.userId)?.username}
|
||||
</span>
|
||||
{note.text}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
);
|
||||
}
|
||||
|
||||
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 (
|
||||
<div className={styles.events} data-testid="schedule-events">
|
||||
<div className={styles.eventsHeader}>
|
||||
<h3 className={styles.eventsHeading}>{t("schedule:events.title")}</h3>
|
||||
{canEdit ? (
|
||||
<SendouButton
|
||||
size="small"
|
||||
variant="outlined"
|
||||
icon={<Plus />}
|
||||
onPress={() => setAddDialogOpen(true)}
|
||||
data-testid="add-team-event-button"
|
||||
>
|
||||
{t("schedule:events.add")}
|
||||
</SendouButton>
|
||||
) : null}
|
||||
</div>
|
||||
{week.teamEvents.length === 0 ? (
|
||||
<div className="text-lighter text-xs">{t("schedule:events.none")}</div>
|
||||
) : (
|
||||
<ul className={styles.eventsList}>
|
||||
{week.teamEvents.map((event) => (
|
||||
<li
|
||||
key={event.id}
|
||||
className={styles.event}
|
||||
data-testid="schedule-team-event"
|
||||
>
|
||||
<span className={styles.eventDay}>
|
||||
{dayFormatter.format(databaseTimestampToDate(event.startsAt))}
|
||||
</span>
|
||||
<span className={styles.eventTime}>
|
||||
{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))}`}
|
||||
</span>
|
||||
<span className={styles.eventName}>{event.name}</span>
|
||||
{canEdit ? (
|
||||
<ActionButton
|
||||
schema={teamScheduleActionSchema}
|
||||
action="DELETE_EVENT"
|
||||
fields={{ eventId: event.id }}
|
||||
variant="minimal-destructive"
|
||||
size="miniscule"
|
||||
icon={<Trash />}
|
||||
aria-label={t("schedule:events.delete")}
|
||||
testId={`delete-team-event-${event.id}`}
|
||||
confirm={{
|
||||
dialogHeading: t("schedule:events.deleteConfirm", {
|
||||
name: event.name,
|
||||
}),
|
||||
}}
|
||||
/>
|
||||
) : null}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
{addDialogOpen ? (
|
||||
<AddTeamEventDialog close={() => setAddDialogOpen(false)} />
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function AddTeamEventDialog({ close }: { close: () => void }) {
|
||||
const { t } = useTranslation(["schedule"]);
|
||||
|
||||
return (
|
||||
<SendouDialog heading={t("schedule:events.addDialogTitle")} onClose={close}>
|
||||
<SendouForm schema={addTeamEventSchema} onSuccess={close}>
|
||||
{({ FormField }) => (
|
||||
<>
|
||||
<FormField name="name" />
|
||||
<FormField name="startsAt" />
|
||||
<FormField name="duration" />
|
||||
<FormMessage type="info">
|
||||
{t("schedule:events.membersWillSee")}
|
||||
</FormMessage>
|
||||
</>
|
||||
)}
|
||||
</SendouForm>
|
||||
</SendouDialog>
|
||||
);
|
||||
}
|
||||
|
||||
function useTeam() {
|
||||
const [, parentRoute] = useMatches();
|
||||
invariant(parentRoute);
|
||||
const layoutData = parentRoute.loaderData as TeamLoaderData;
|
||||
|
||||
return layoutData.team;
|
||||
}
|
||||
|
||||
function useTeamMembers() {
|
||||
return useTeam().members;
|
||||
}
|
||||
@@ -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",
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -14,6 +14,7 @@ export const VIEW_FILTERS = [
|
||||
"registered",
|
||||
"hosting",
|
||||
"scrims",
|
||||
"team",
|
||||
"saved",
|
||||
"organization",
|
||||
] as const;
|
||||
|
||||
@@ -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 };
|
||||
};
|
||||
|
||||
@@ -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<EventsLoaderData>();
|
||||
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 (
|
||||
<Main halfWidth>
|
||||
<div className={styles.eventsListHeader}>
|
||||
<h2 className="text-lg mx-2">{t("calendar:events.title")}</h2>
|
||||
{hasNoEventsAtAll ? null : (
|
||||
<SubNav secondary className={styles.subNav}>
|
||||
{VIEW_FILTERS.map((value) => (
|
||||
<SubNavLink
|
||||
key={value}
|
||||
to={calendarEventsSearchParams.href("", { view: value })}
|
||||
secondary
|
||||
controlled
|
||||
active={filter === value}
|
||||
defaultShouldRevalidate={false}
|
||||
>
|
||||
{viewLabels[value]}
|
||||
</SubNavLink>
|
||||
))}
|
||||
</SubNav>
|
||||
<Main className="stack lg">
|
||||
{/* keyed on the week so a revalidation across Monday midnight resets
|
||||
the editor instead of leaving it holding the rolled-over week */}
|
||||
<MySchedule
|
||||
key={data.mySchedule.weeks[0].weekStartsAt}
|
||||
data={data.mySchedule}
|
||||
/>
|
||||
<div>
|
||||
<div className={styles.eventsListHeader}>
|
||||
<h2 className="text-lg mx-2">{t("calendar:events.title")}</h2>
|
||||
{hasNoEventsAtAll ? null : (
|
||||
<SubNav secondary className={styles.subNav}>
|
||||
{VIEW_FILTERS.map((value) => (
|
||||
<SubNavLink
|
||||
key={value}
|
||||
to={scheduleWeekSearchParams.href(
|
||||
calendarEventsSearchParams.href("", { view: value }),
|
||||
{ week },
|
||||
)}
|
||||
secondary
|
||||
controlled
|
||||
active={filter === value}
|
||||
defaultShouldRevalidate={false}
|
||||
>
|
||||
{viewLabels[value]}
|
||||
</SubNavLink>
|
||||
))}
|
||||
</SubNav>
|
||||
)}
|
||||
</div>
|
||||
{hasNoEventsAtAll ? (
|
||||
<EmptyState navItem="calendar">
|
||||
{t("calendar:events.emptyAll")}{" "}
|
||||
<Link to={CALENDAR_PAGE}>
|
||||
{t("calendar:events.findOnCalendar")}
|
||||
</Link>
|
||||
</EmptyState>
|
||||
) : shownEvents.length === 0 ? (
|
||||
<EmptyState navItem="calendar">
|
||||
{t("calendar:events.empty")}
|
||||
</EmptyState>
|
||||
) : (
|
||||
<EventsList events={shownEvents} />
|
||||
)}
|
||||
</div>
|
||||
{hasNoEventsAtAll ? (
|
||||
<EmptyState navItem="calendar">
|
||||
{t("calendar:events.emptyAll")}{" "}
|
||||
<Link to={CALENDAR_PAGE}>{t("calendar:events.findOnCalendar")}</Link>
|
||||
</EmptyState>
|
||||
) : shownEvents.length === 0 ? (
|
||||
<EmptyState navItem="calendar">{t("calendar:events.empty")}</EmptyState>
|
||||
) : (
|
||||
<EventsList events={shownEvents} />
|
||||
)}
|
||||
</Main>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -35,3 +35,7 @@
|
||||
.trophyExampleLarge {
|
||||
width: 200px;
|
||||
}
|
||||
|
||||
.scheduleNarrow {
|
||||
max-width: 360px;
|
||||
}
|
||||
|
||||
@@ -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<EditorCommitment> = [
|
||||
{
|
||||
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 (
|
||||
<Section>
|
||||
<SectionTitle id={id}>Schedule</SectionTitle>
|
||||
|
||||
<div className="stack md">
|
||||
<div className="stack sm">
|
||||
<div className={styles.componentLabel}>Week availability editor</div>
|
||||
<WeekAvailabilityEditor
|
||||
value={week}
|
||||
onChange={setWeek}
|
||||
commitments={SCHEDULE_EXAMPLE_COMMITMENTS}
|
||||
/>
|
||||
<div className="stack horizontal sm">
|
||||
<SendouButton
|
||||
variant="outlined"
|
||||
size="small"
|
||||
onPress={() => setWeek(SCHEDULE_EXAMPLE_WEEK)}
|
||||
>
|
||||
Reset
|
||||
</SendouButton>
|
||||
<SendouButton
|
||||
size="small"
|
||||
onPress={() =>
|
||||
toastQueue.add({
|
||||
message: `Saved week with ${rangeCount} time ranges`,
|
||||
variant: "success",
|
||||
})
|
||||
}
|
||||
>
|
||||
Save week
|
||||
</SendouButton>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ComponentRow label="Narrow container (mobile layout, shares state with the editor above)">
|
||||
<div className={styles.scheduleNarrow}>
|
||||
<WeekAvailabilityEditor
|
||||
value={week}
|
||||
onChange={setWeek}
|
||||
commitments={SCHEDULE_EXAMPLE_COMMITMENTS}
|
||||
/>
|
||||
</div>
|
||||
</ComponentRow>
|
||||
</div>
|
||||
</Section>
|
||||
);
|
||||
}
|
||||
|
||||
function MiscSection({ id }: { id: string }) {
|
||||
const [rangeValue, setRangeValue] = useState(50);
|
||||
const [colorValue, setColorValue] = useState("#3b82f6");
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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() {
|
||||
) : (
|
||||
<div className="stack xs">
|
||||
{shownItems.map((item) => (
|
||||
<FriendMenu key={item.id} name={item.username} {...item} />
|
||||
<FriendRow key={item.id} item={item} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
@@ -230,6 +233,58 @@ function FriendsListSection() {
|
||||
);
|
||||
}
|
||||
|
||||
function FriendRow({ item }: { item: ShownItem }) {
|
||||
return (
|
||||
<div className={styles.friendRow} data-testid={`friend-row-${item.id}`}>
|
||||
<FriendMenu name={item.username} {...item} />
|
||||
<div className={styles.scheduleSlot}>
|
||||
{item.schedule ? (
|
||||
<ScheduleButton
|
||||
userId={item.id}
|
||||
username={item.username}
|
||||
weeks={item.schedule}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ScheduleButton({
|
||||
userId,
|
||||
username,
|
||||
weeks,
|
||||
}: {
|
||||
userId: number;
|
||||
username: string;
|
||||
weeks: NonNullable<ShownItem["schedule"]>;
|
||||
}) {
|
||||
const { t } = useTranslation(["schedule"]);
|
||||
const [dialogOpen, setDialogOpen] = React.useState(false);
|
||||
|
||||
return (
|
||||
<>
|
||||
<SendouButton
|
||||
variant="minimal"
|
||||
size="small"
|
||||
icon={<CalendarDays size={18} />}
|
||||
aria-label={t("schedule:friends.availabilityOf", { name: username })}
|
||||
testId={`friend-schedule-button-${userId}`}
|
||||
onPress={() => setDialogOpen(true)}
|
||||
/>
|
||||
{dialogOpen ? (
|
||||
<ScheduleWeekDialog
|
||||
username={username}
|
||||
weeks={weeks}
|
||||
onClose={() => setDialogOpen(false)}
|
||||
/>
|
||||
) : null}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
type ShownItem = ReturnType<typeof resolveShownItems>[number];
|
||||
|
||||
function resolveShownItems(
|
||||
filter: ViewFilter,
|
||||
data: Awaited<ReturnType<FriendsLoaderData>>,
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -39,6 +39,8 @@ const NOTIFICATION_URGENCY: Record<Notification["type"], Urgency> = {
|
||||
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. */
|
||||
|
||||
@@ -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<Notification["type"], string | null>;
|
||||
|
||||
type ResolvableNotificationType = {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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<number>;
|
||||
startsAt: number;
|
||||
endsAt: number;
|
||||
}) {
|
||||
if (userIds.length === 0) return [];
|
||||
|
||||
const resolvedStartsAt = sql<number>`coalesce("ScrimPostRequest"."startsAt", "ScrimPost"."startsAt")`;
|
||||
|
||||
const acceptedInWindow = db
|
||||
.selectFrom("ScrimPost")
|
||||
.innerJoin("ScrimPostRequest", (join) =>
|
||||
join
|
||||
.onRef("ScrimPostRequest.scrimPostId", "=", "ScrimPost.id")
|
||||
.on("ScrimPostRequest.isAccepted", "=", 1),
|
||||
)
|
||||
.where("ScrimPost.canceledAt", "is", null)
|
||||
.where(resolvedStartsAt, ">=", startsAt)
|
||||
.where(resolvedStartsAt, "<=", endsAt);
|
||||
|
||||
const [postSideUsers, requestSideUsers] = await Promise.all([
|
||||
acceptedInWindow
|
||||
.innerJoin("ScrimPostUser", "ScrimPostUser.scrimPostId", "ScrimPost.id")
|
||||
.select(["ScrimPostUser.userId", resolvedStartsAt.as("startsAt")])
|
||||
.where("ScrimPostUser.userId", "in", userIds)
|
||||
.execute(),
|
||||
acceptedInWindow
|
||||
.innerJoin(
|
||||
"ScrimPostRequestUser",
|
||||
"ScrimPostRequestUser.scrimPostRequestId",
|
||||
"ScrimPostRequest.id",
|
||||
)
|
||||
.select(["ScrimPostRequestUser.userId", resolvedStartsAt.as("startsAt")])
|
||||
.where("ScrimPostRequestUser.userId", "in", userIds)
|
||||
.execute(),
|
||||
]);
|
||||
|
||||
return [...postSideUsers, ...requestSideUsers];
|
||||
}
|
||||
|
||||
/**
|
||||
* Finds pending (unaccepted, uncanceled, future) scrim posts and requests
|
||||
* involving any of the given users whose time overlaps [startTime, endTime].
|
||||
|
||||
@@ -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(
|
||||
|
||||
53
app/features/scrims/components/ScrimAvailability.module.css
Normal file
53
app/features/scrims/components/ScrimAvailability.module.css
Normal file
@@ -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;
|
||||
}
|
||||
145
app/features/scrims/components/ScrimAvailability.tsx
Normal file
145
app/features/scrims/components/ScrimAvailability.tsx
Normal file
@@ -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<AvailabilityPanelUser>;
|
||||
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<typeof scrimsLoader>();
|
||||
|
||||
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 (
|
||||
<SendouPopover
|
||||
trigger={
|
||||
<SendouButton
|
||||
variant="minimal"
|
||||
className={styles.stripe}
|
||||
testId="scrim-fit-indicator"
|
||||
>
|
||||
<span className={styles.stripeTeam}>{fit.team.name}</span>
|
||||
<AvailabilityStatusDots statuses={rosterStatuses(fit)} />
|
||||
<span className={styles.stripeCount}>
|
||||
{t("schedule:scrims.availableOfRoster", {
|
||||
amount: fit.fit.availableCount,
|
||||
total: fit.roster.length,
|
||||
})}
|
||||
</span>
|
||||
</SendouButton>
|
||||
}
|
||||
>
|
||||
<div className={styles.popover}>
|
||||
<AvailabilityWindowText window={fit.fit.window} />
|
||||
<ScrimAvailabilityRows fit={fit} />
|
||||
</div>
|
||||
</SendouPopover>
|
||||
);
|
||||
}
|
||||
|
||||
/** 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 (
|
||||
<div className={styles.rowsSection}>
|
||||
<ul className={styles.rows}>
|
||||
{fit.roster.map((member) => (
|
||||
<AvailabilityMemberRow
|
||||
key={member.id}
|
||||
user={member}
|
||||
entry={entryByUserId.get(member.id)}
|
||||
/>
|
||||
))}
|
||||
</ul>
|
||||
<AvailabilitySummary
|
||||
statuses={fit.roster.map((member) =>
|
||||
availabilityRowStatus(entryByUserId.get(member.id)),
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** 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)),
|
||||
);
|
||||
}
|
||||
@@ -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 ? <ScrimExpandableText text={post.text} /> : null}
|
||||
|
||||
{action === "REQUEST" || action === "VIEW_REQUEST" ? (
|
||||
<ScrimFitStripe post={post} />
|
||||
) : null}
|
||||
|
||||
<div
|
||||
className={clsx(styles.footer, isFilteredOut && styles.filteredFooter)}
|
||||
>
|
||||
|
||||
@@ -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 (
|
||||
<SendouDialog heading={t("scrims:requestModal.title")} onClose={close}>
|
||||
@@ -77,6 +92,7 @@ export function ScrimRequestModal({
|
||||
{post.rangeEndsAt ? (
|
||||
<FormField name="at" options={timeOptions} />
|
||||
) : null}
|
||||
<ScrimRequestAvailability post={post} />
|
||||
<FormField name="message" />
|
||||
<FormMessage type="info">{t("scrims:autoCancelInfo")}</FormMessage>
|
||||
</>
|
||||
@@ -85,3 +101,32 @@ export function ScrimRequestModal({
|
||||
</SendouDialog>
|
||||
);
|
||||
}
|
||||
|
||||
/** 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 (
|
||||
<div className="stack sm">
|
||||
<div className="text-sm font-semi-bold">
|
||||
{t("schedule:registration.title")}
|
||||
</div>
|
||||
<AvailabilityWindowText window={fit.fit.window} />
|
||||
<ScrimAvailabilityRows fit={fit} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
128
app/features/scrims/components/ScrimSchedulePicker.module.css
Normal file
128
app/features/scrims/components/ScrimSchedulePicker.module.css
Normal file
@@ -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);
|
||||
}
|
||||
}
|
||||
388
app/features/scrims/components/ScrimSchedulePicker.tsx
Normal file
388
app/features/scrims/components/ScrimSchedulePicker.tsx
Normal file
@@ -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<number | null | undefined> };
|
||||
|
||||
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 (
|
||||
<RosterTimeline
|
||||
schedule={schedule}
|
||||
names={[
|
||||
...teams.flatMap((team) => 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<number>;
|
||||
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<number>) =>
|
||||
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 (
|
||||
<React.Fragment key={day.startsAt}>
|
||||
<div className={trackStyles.dayLabel}>
|
||||
{dayFormatter.format(day.noonAt)}
|
||||
</div>
|
||||
<div className={trackStyles.track}>
|
||||
<TrackTicks clockWindow={clockWindow} />
|
||||
{daySlots.map((slot) => (
|
||||
<SlotBar
|
||||
key={slot.startsAt}
|
||||
clockWindow={clockWindow}
|
||||
slot={slot}
|
||||
label={`${rangeText(slot)} · ${t("schedule:picker.free", {
|
||||
amount: slot.userIds.length,
|
||||
})}`}
|
||||
members={namesOf(slot.userIds).join(", ")}
|
||||
isPicked={slot.pick.startsAt === pickedAt}
|
||||
onPick={() => pick(slot)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
{/* keeps the day rows in step with the axis row's "later" expander */}
|
||||
<div />
|
||||
</React.Fragment>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<section className={styles.picker} data-testid="scrim-schedule-picker">
|
||||
<div className={styles.header}>
|
||||
<h3 className={styles.heading}>{t("schedule:picker.title")}</h3>
|
||||
<WeekToggle
|
||||
name="scrim-schedule-week"
|
||||
value={weekIndex === 0 ? "current" : "next"}
|
||||
onChange={(value) => setWeekIndex(value === "next" ? 1 : 0)}
|
||||
/>
|
||||
</div>
|
||||
<div className={trackStyles.container}>
|
||||
<div className={trackStyles.tracks}>
|
||||
<ClockAxis
|
||||
clockWindow={clockWindow}
|
||||
dayStartsAt={databaseTimestampToDate(week.days[0].startsAt)}
|
||||
/>
|
||||
{dayRows.map(dayRow)}
|
||||
</div>
|
||||
<div className={trackStyles.list}>
|
||||
{dayRows.map(({ day, slots: daySlots }) => {
|
||||
return (
|
||||
<div key={day.startsAt} className={trackStyles.listDay}>
|
||||
<div className={trackStyles.listDayHeader}>
|
||||
{dayFormatter.format(day.noonAt)}
|
||||
</div>
|
||||
{daySlots.length === 0 ? (
|
||||
<span className="text-lighter text-xs">—</span>
|
||||
) : (
|
||||
<div className={trackStyles.listDayBody}>
|
||||
{daySlots.map((slot) => (
|
||||
<button
|
||||
key={slot.startsAt}
|
||||
type="button"
|
||||
className={clsx(trackStyles.timeChip, styles.slotChip, {
|
||||
[styles.oneShort]: slot.tier === "ONE_SHORT",
|
||||
[styles.picked]: slot.pick.startsAt === pickedAt,
|
||||
})}
|
||||
title={namesOf(slot.userIds).join(", ")}
|
||||
onClick={() => pick(slot)}
|
||||
>
|
||||
{rangeText(slot)} ·{" "}
|
||||
{t("schedule:picker.free", {
|
||||
amount: slot.userIds.length,
|
||||
})}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
<Legend minPlayers={minPlayers} />
|
||||
{unknownUserIds.length > 0 ? (
|
||||
<div className={styles.unknown} data-testid="scrim-schedule-unknown">
|
||||
{t("schedule:picker.noSchedule", {
|
||||
users: [
|
||||
...unknownNamed,
|
||||
...(unknownUnnamed > 0
|
||||
? [t("schedule:picker.andOthers", { amount: unknownUnnamed })]
|
||||
: []),
|
||||
].join(", "),
|
||||
})}
|
||||
</div>
|
||||
) : null}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
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 (
|
||||
<button
|
||||
type="button"
|
||||
className={clsx(styles.slotBar, {
|
||||
[styles.oneShort]: slot.tier === "ONE_SHORT",
|
||||
[styles.picked]: isPicked,
|
||||
})}
|
||||
style={clockWindow.barStyle(slot.range)}
|
||||
title={members ? `${label} · ${members}` : label}
|
||||
aria-label={label}
|
||||
data-testid="scrim-schedule-slot"
|
||||
data-tier={slot.tier}
|
||||
data-picked={isPicked || undefined}
|
||||
onClick={onPick}
|
||||
>
|
||||
{slot.fullRange ? (
|
||||
<span
|
||||
className={styles.slotFull}
|
||||
style={{
|
||||
left: `${withinBar(slot.fullRange.start)}%`,
|
||||
right: `${100 - withinBar(slot.fullRange.end)}%`,
|
||||
}}
|
||||
/>
|
||||
) : null}
|
||||
<span className={styles.slotLabel}>{label}</span>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
function Legend({ minPlayers }: { minPlayers: number }) {
|
||||
const { t } = useTranslation(["schedule"]);
|
||||
|
||||
return (
|
||||
<div className={styles.legend}>
|
||||
<span className={styles.legendItem}>
|
||||
<span className={styles.slotSwatch} />
|
||||
{t("schedule:picker.legend.full", { players: minPlayers })}
|
||||
</span>
|
||||
{minPlayers > 1 ? (
|
||||
<span className={styles.legendItem}>
|
||||
<span className={clsx(styles.slotSwatch, styles.oneShort)} />
|
||||
{t("schedule:picker.legend.oneShort", { players: minPlayers - 1 })}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function rosterUserIds({
|
||||
from,
|
||||
teams,
|
||||
viewerId,
|
||||
}: {
|
||||
from: FromValue;
|
||||
teams: ScrimsNewLoaderData["teams"];
|
||||
viewerId?: number;
|
||||
}): Array<number> {
|
||||
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<Scrim.PickableSlot>;
|
||||
day: Day;
|
||||
}): Array<DaySlot> {
|
||||
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,
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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<number>;
|
||||
/** 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<MemberAvailability>;
|
||||
minPlayers: number;
|
||||
}): Array<PickableSlot> {
|
||||
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<T>): Array<T> {
|
||||
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<WindowAvailabilityEntry>;
|
||||
/** 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<number>;
|
||||
members: Array<WindowSchedule>;
|
||||
}): 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<WindowSchedule>;
|
||||
}): 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
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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<typeof loader>;
|
||||
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,
|
||||
),
|
||||
};
|
||||
};
|
||||
|
||||
@@ -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<ScrimPost>;
|
||||
teams: Awaited<ReturnType<typeof TeamRepository.findAllByMemberUserId>>;
|
||||
}) {
|
||||
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,
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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<typeof scrimsNewFormSchema>;
|
||||
@@ -87,6 +88,8 @@ export default function NewScrimPage() {
|
||||
)}
|
||||
</FormField>
|
||||
|
||||
<SchedulePicker />
|
||||
|
||||
<FormField name="at" />
|
||||
<FormField name="rangeEnd" />
|
||||
|
||||
@@ -117,6 +120,28 @@ export default function NewScrimPage() {
|
||||
);
|
||||
}
|
||||
|
||||
function SchedulePicker() {
|
||||
const data = useLoaderData<typeof loader>();
|
||||
const { values, setValue } = useFormFieldContext();
|
||||
|
||||
const from = values.from as FormFields["from"] | null;
|
||||
if (!from) return null;
|
||||
|
||||
return (
|
||||
<ScrimSchedulePicker
|
||||
schedule={data.schedule}
|
||||
scheduleUsers={data.scheduleUsers}
|
||||
teams={data.teams}
|
||||
from={from}
|
||||
at={values.at as Date | undefined}
|
||||
onPick={({ at, rangeEnd }) => {
|
||||
setValue("at", at);
|
||||
setValue("rangeEnd", rangeEnd);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function BaseVisibilityFormField({
|
||||
associations,
|
||||
name,
|
||||
|
||||
@@ -49,7 +49,7 @@ import styles from "./scrims.module.css";
|
||||
export type NewRequestFormFields = v.InferOutput<typeof newRequestSchema>;
|
||||
|
||||
export const handle: SendouRouteHandle = {
|
||||
i18n: ["calendar", "scrims", "user", "q"],
|
||||
i18n: ["calendar", "schedule", "scrims", "user", "q"],
|
||||
breadcrumb: () => ({
|
||||
imgPath: navIconUrl("scrims"),
|
||||
href: scrimsPage(),
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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") });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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<ScrimPost, "startsAt" | "rangeEndsAt">;
|
||||
now: number;
|
||||
}): Array<number> {
|
||||
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<ScrimPost, "startsAt" | "rangeEndsAt">;
|
||||
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<number>();
|
||||
|
||||
|
||||
@@ -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<any>`null`.as("teamId"),
|
||||
sql<Tables["TeamMember"]["role"]>`null`.as("role"),
|
||||
sql<Tables["TeamMember"]["roleType"]>`null`.as("roleType"),
|
||||
]),
|
||||
)
|
||||
.execute();
|
||||
|
||||
@@ -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<number>();
|
||||
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<SidebarStream[]> {
|
||||
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<typeof AvailabilityRepository.findAllUpcomingTeamEventsByUserId>
|
||||
>[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 {
|
||||
|
||||
@@ -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 (
|
||||
<div className={styles.actionButtons}>
|
||||
{isMember ? (
|
||||
<LinkButton
|
||||
size="small"
|
||||
to="schedule"
|
||||
variant="outlined"
|
||||
prefetch="intent"
|
||||
icon={<CalendarDays />}
|
||||
testId="team-schedule-button"
|
||||
>
|
||||
{t("team:actionButtons.schedule")}
|
||||
</LinkButton>
|
||||
) : null}
|
||||
{canManageRoster ? (
|
||||
<LinkButton
|
||||
size="small"
|
||||
|
||||
@@ -4,7 +4,7 @@ import { Outlet, useLoaderData } from "react-router";
|
||||
import * as R from "remeda";
|
||||
import { Flag } from "~/components/Flag";
|
||||
import { BskyIcon } from "~/components/icons/Bsky";
|
||||
import { Main } from "~/components/Main";
|
||||
import { containerClassName, Main } from "~/components/Main";
|
||||
import { metaTags, type SerializeFrom } from "~/utils/remix";
|
||||
import type { SendouRouteHandle } from "~/utils/remix.server";
|
||||
import { bskyUrl, navIconUrl, TEAM_SEARCH_PAGE, teamPage } from "~/utils/urls";
|
||||
@@ -56,13 +56,17 @@ export const handle: SendouRouteHandle = {
|
||||
};
|
||||
|
||||
export default function TeamPage() {
|
||||
// breakout container so the schedule tab's table can size against the full
|
||||
// content area; the wrapper keeps every page at the normal width
|
||||
return (
|
||||
<Main className="stack sm">
|
||||
<div className="stack sm">
|
||||
<TeamBanner />
|
||||
<Main breakoutContainer>
|
||||
<div className={clsx(containerClassName("normal"), "stack sm")}>
|
||||
<div className="stack sm">
|
||||
<TeamBanner />
|
||||
</div>
|
||||
<MobileTeamNameCountry />
|
||||
<Outlet />
|
||||
</div>
|
||||
<MobileTeamNameCountry />
|
||||
<Outlet />
|
||||
</Main>
|
||||
);
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user