Team schedule calendar on team page

This commit is contained in:
Kalle
2026-08-22 15:13:40 +03:00
parent 0ce78f155b
commit be973fa8c1
46 changed files with 1220 additions and 28 deletions

View File

@@ -0,0 +1,11 @@
import { describe, test } from "vitest";
import { assertRoundTrips } from "~/modules/search-params/search-params-test-utils";
import { teamScheduleSearchParams } from "./availability-search-params";
describe("teamScheduleSearchParams", () => {
test("round-trips", () => {
assertRoundTrips(teamScheduleSearchParams, {
week: ["current", "next"],
});
});
});

View 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 teamScheduleSearchParams = SearchParams.define({
week: SP.param(v.picklist(["current", "next"]), {
default: "current",
loader: false,
}),
});

View File

@@ -172,6 +172,60 @@ describe("Availability.subtract", () => {
});
});
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.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.playableWindows", () => {
const members = (
ranges: Array<Array<[start: string, end: string, endDate?: string]>>,

View File

@@ -1,5 +1,5 @@
import { TZDate } from "@date-fns/tz";
import { addWeeks, format, startOfWeek } from "date-fns";
import { addWeeks, format, getISOWeek, startOfWeek } from "date-fns";
import * as R from "remeda";
import {
databaseTimestampToJavascriptTimestamp,
@@ -41,6 +41,11 @@ export function weekRange(date: Date, timezone: string): TimeRange {
};
}
/** 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));
}
/**
* 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
@@ -144,6 +149,22 @@ export function subtract(
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 }] : [];
});
}
/**
* The windows the team could play in: spans
* where `minPlayers` of the members (`FULL`) or one fewer (`ONE_SHORT`) are all

View File

@@ -0,0 +1,212 @@
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 * 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 { PlayableWindowTier, TimeRange } from "../availability-types";
import * as Availability from "../core/Availability";
const DAY_SECONDS = 24 * 60 * 60;
/** A member's reported week belongs to a viewer week when their starts are closer than this — timezones set them apart by hours, never by days. */
const WEEK_MATCH_MAX_DISTANCE_SECONDS = 3.5 * DAY_SECONDS;
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),
);
if (!isTeamMember({ team, user: getUser() })) {
return { weeks: null };
}
const members = team.members.filter(
(member) => member.role !== "CHEERLEADER",
);
const timezone = getViewerTimezone() ?? "UTC";
const now = new Date();
const reportedWeeks = await AvailabilityRepository.findAllWeeksByUserIds({
userIds: members.map((member) => member.id),
startsAt: Availability.weekRange(now, timezone).startsAt,
endsAt: Availability.weekRange(
addWeeks(now, AVAILABILITY.WEEK_HORIZON - 1),
timezone,
).endsAt,
});
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,
}),
),
};
};
type ReportedWeek = Awaited<
ReturnType<typeof AvailabilityRepository.findAllWeeksByUserIds>
>[number];
function weekView({
range,
timezone,
memberIds,
playerIds,
reportedWeeks,
}: {
range: TimeRange;
timezone: string;
memberIds: Array<number>;
playerIds: Array<number>;
reportedWeeks: Array<ReportedWeek>;
}) {
const minPlayers = Math.min(
AVAILABILITY.DEFAULT_MIN_PLAYERS,
playerIds.length,
);
const windows = Availability.playableWindows({
members: playerIds.map((userId) => ({
userId,
ranges: Availability.clip(
reportedWeeks
.filter((week) => week.userId === userId)
.flatMap((week) => week.slots),
range,
),
})),
minPlayers,
}).map((window) => R.omit(window, ["userIds"]));
const days = R.range(0, 7).map((dayIndex) => {
const noonAt = range.startsAt + dayIndex * DAY_SECONDS + DAY_SECONDS / 2;
const date = Availability.dateInTimezone(noonAt, timezone);
return {
date,
noonAt,
windowTier: bestWindowTierOfDay({ date, windows, timezone }),
};
});
const members = memberIds.map((userId) =>
memberWeekRow({ userId, days, timezone, reportedWeeks, range }),
);
return {
startsAt: range.startsAt,
weekNumber: Availability.isoWeekNumber(
range.startsAt + DAY_SECONDS / 2,
timezone,
),
days,
members,
windows,
minPlayers,
};
}
/**
* 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;
}
function memberWeekRow({
userId,
days,
timezone,
reportedWeeks,
range,
}: {
userId: number;
days: Array<{ date: string; noonAt: number }>;
timezone: string;
reportedWeeks: Array<ReportedWeek>;
range: TimeRange;
}) {
const memberWeeks = reportedWeeks.filter((week) => week.userId === userId);
const matchingWeek = memberWeeks.find(
(week) =>
Math.abs(week.weekStartsAt - range.startsAt) <
WEEK_MATCH_MAX_DISTANCE_SECONDS,
);
if (!matchingWeek) {
return {
userId,
reported: false,
days: days.map(() => []) as Array<Array<TimeRange>>,
notes: [] as Array<{ dayIndex: number; text: string }>,
};
}
// slots are placed on the viewer-local day they start on, wherever their
// author's week put them — the adjacent weeks' spillover included
const slots = memberWeeks.flatMap((week) => week.slots);
return {
userId,
reported: true,
days: days.map((day) =>
slots.filter(
(slot) =>
Availability.dateInTimezone(slot.startsAt, timezone) === day.date,
),
),
notes: memberWeeks.flatMap((week) =>
week.dayNotes.flatMap((note) => {
const noteDate = Availability.dateInTimezone(
Availability.localToTimestamp({
date: note.date,
time: "12:00",
timezone: week.timezone,
}),
timezone,
);
const dayIndex = days.findIndex((day) => day.date === noteDate);
return dayIndex === -1 ? [] : [{ dayIndex, text: note.text }];
}),
),
};
}

View File

@@ -0,0 +1,169 @@
.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;
}
}
.cellContent {
display: flex;
flex-direction: column;
gap: var(--s-0-5);
}
.range {
white-space: nowrap;
}
.unknown,
.unavailable {
color: var(--color-text-high);
}
.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;
}
.footer {
font-size: var(--font-2xs);
color: var(--color-text-high);
}

View File

@@ -0,0 +1,340 @@
import clsx from "clsx";
import { Flag } from "lucide-react";
import { useTranslation } from "react-i18next";
import { useLoaderData, useMatches } from "react-router";
import * as R from "remeda";
import { Alert } from "~/components/Alert";
import {
SendouChipRadio,
SendouChipRadioGroup,
} from "~/components/elements/ChipRadio";
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 { useDateTimeFormat } from "~/hooks/intl/useDateTimeFormat";
import { useSearchParamsTyped } from "~/modules/search-params/hooks";
import invariant from "~/utils/invariant";
import type { SendouRouteHandle } from "~/utils/remix.server";
import { teamScheduleSearchParams } from "../availability-search-params";
import type { TeamScheduleLoaderData } from "../loaders/t.$customUrl.schedule.server";
import { loader } from "../loaders/t.$customUrl.schedule.server";
export { 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(teamScheduleSearchParams);
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>
<SendouChipRadioGroup>
<SendouChipRadio
name="schedule-week"
value="current"
checked={week === "current"}
onChange={() => setParams({ week: "current" })}
>
{t("schedule:team.currentWeek")}
</SendouChipRadio>
<SendouChipRadio
name="schedule-week"
value="next"
checked={week === "next"}
onChange={() => setParams({ week: "next" })}
>
{t("schedule:team.nextWeek")}
</SendouChipRadio>
</SendouChipRadioGroup>
</div>
<ScheduleGrid week={shownWeek} />
<PlayableWindowsSummary week={shownWeek} />
<WeekNotes week={shownWeek} />
<p className={styles.footer}>
{t("schedule:editor.timesInYourTimezone")} ·{" "}
{t("schedule:team.visibility")}
</p>
</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((ranges, dayIndex) => (
<ScheduleCell
key={week.days[dayIndex].date}
row={row}
ranges={ranges}
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}
className={styles.sectionDivider}
>
{t("team:roster.sections.other")}
</th>
</tr>
) : null}
{otherRows.map(renderRow)}
</tbody>
</table>
</div>
);
}
function ScheduleCell({
row,
ranges,
dayIndex,
}: {
row: MemberWeekRow;
ranges: MemberWeekRow["days"][number];
dayIndex: number;
}) {
const { t } = useTranslation(["schedule"]);
const { formatter: timeFormatter } = useDateTimeFormat({
hour: "numeric",
minute: "2-digit",
});
const note = row.notes.find((note) => note.dayIndex === dayIndex);
return (
<td
className={styles.cell}
data-testid={`schedule-cell-${row.userId}-${dayIndex}`}
>
<div className={styles.cellContent}>
{!row.reported ? (
<span
className={styles.unknown}
title={t("schedule:team.noSchedule")}
>
?
</span>
) : ranges.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"
>
{timeFormatter.formatRange(range.startsAt, range.endsAt)}
</div>
))
)}
{note ? (
<span title={note.text}>
<Flag className={styles.noteFlag} size={12} aria-hidden />
</span>
) : null}
</div>
</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 useTeamMembers() {
const [, parentRoute] = useMatches();
invariant(parentRoute);
const layoutData = parentRoute.loaderData as TeamLoaderData;
return layoutData.team.members;
}

View File

@@ -1,4 +1,5 @@
import {
CalendarDays,
LogOut,
Menu,
SquarePen,
@@ -108,7 +109,6 @@ export default function TeamIndexPage() {
function ActionButtons() {
const { t } = useTranslation(["team"]);
const user = useUser();
const [, parentRoute] = useMatches();
invariant(parentRoute);
const layoutData = parentRoute.loaderData as TeamLoaderData;
@@ -116,12 +116,18 @@ function ActionButtons() {
const canManageRoster = useHasPermission(team, "MANAGE_ROSTER");
const canEditTeam = useHasPermission(team, "EDIT");
if (!isTeamMember({ user, team }) && !canManageRoster && !canEditTeam) {
return null;
}
return (
<div className={styles.actionButtons}>
<LinkButton
size="small"
to="schedule"
variant="outlined"
prefetch="intent"
icon={<CalendarDays />}
testId="team-schedule-button"
>
{t("team:actionButtons.schedule")}
</LinkButton>
{canManageRoster ? (
<LinkButton
size="small"

View File

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

View File

@@ -237,6 +237,7 @@ export default [
route("roster", "features/team/routes/t.$customUrl.roster.tsx"),
route("join", "features/team/routes/t.$customUrl.join.tsx"),
route("results", "features/team/routes/t.$customUrl.results.tsx"),
route("schedule", "features/availability/routes/t.$customUrl.schedule.tsx"),
]),
...prefix("/vods", [

View File

@@ -35,6 +35,9 @@ export async function loadFactories(parallelIndex: number) {
ApiTokenFactory: await import("~/db/seed/factories/ApiTokenFactory"),
ArtFactory: await import("~/db/seed/factories/ArtFactory"),
AssociationFactory: await import("~/db/seed/factories/AssociationFactory"),
AvailabilityWeekFactory: await import(
"~/db/seed/factories/AvailabilityWeekFactory"
),
BadgeFactory: await import("~/db/seed/factories/BadgeFactory"),
BuildFactory: await import("~/db/seed/factories/BuildFactory"),
CalendarEventFactory: await import(

View File

@@ -7,6 +7,7 @@ import {
} from "../../helpers/playwright";
import { TeamEditPage } from "./team-edit-page";
import { TeamRosterPage } from "./team-roster-page";
import { TeamSchedulePage } from "./team-schedule-page";
export class TeamPage {
private readonly page: Page;
@@ -24,6 +25,7 @@ export class TeamPage {
makeMainTeamButton: page.getByTestId("make-main-team-button"),
leaveTeamButton: page.getByTestId("leave-team-button"),
deleteTeamButton: page.getByTestId("delete-team-button"),
scheduleButton: page.getByTestId("team-schedule-button"),
otherRolesTab: page.getByRole("tab", { name: /Other/ }),
confirmDialog: page.getByRole("dialog"),
};
@@ -55,6 +57,11 @@ export class TeamPage {
return new TeamEditPage(this.page);
}
async openSchedule() {
await this.locators.scheduleButton.click();
return new TeamSchedulePage(this.page);
}
async openActionsMenu() {
await this.locators.actionsMenuButton.click();
}

View File

@@ -0,0 +1,38 @@
import type { Page } from "@playwright/test";
import { teamPage } from "~/utils/urls";
import { navigate } from "../../helpers/playwright";
export class TeamSchedulePage {
private readonly page: Page;
readonly locators;
constructor(page: Page) {
this.page = page;
this.locators = {
grid: page.getByTestId("schedule-grid"),
summary: page.getByTestId("schedule-summary"),
hiddenMessage: page.getByTestId("schedule-hidden"),
windows: page.getByTestId("schedule-window"),
notes: page.getByTestId("schedule-note"),
};
}
async goto(customUrl: string) {
await navigate({
page: this.page,
url: `${teamPage(customUrl)}/schedule`,
});
}
cell(userId: number, dayIndex: number) {
return this.page.getByTestId(`schedule-cell-${userId}-${dayIndex}`);
}
cellRange(userId: number, dayIndex: number) {
return this.cell(userId, dayIndex).getByTestId("schedule-range");
}
dayDot(dayIndex: number) {
return this.page.getByTestId(`schedule-day-dot-${dayIndex}`);
}
}

View File

@@ -1,5 +1,7 @@
import type { Page } from "@playwright/test";
import { NZAP_TEST_ID } from "~/db/seed/constants";
import { ADMIN_DISCORD_ID, ADMIN_ID } from "~/features/admin/admin-constants";
import * as Availability from "~/features/availability/core/Availability";
import type { Factories } from "./helpers/factories";
import {
expect,
@@ -14,11 +16,16 @@ import { JoinTeamPage } from "./pages/team/join-team-page";
import { NewTeamPage } from "./pages/team/new-team-page";
import { TeamEditPage } from "./pages/team/team-edit-page";
import { TeamPage } from "./pages/team/team-page";
import { TeamSchedulePage } from "./pages/team/team-schedule-page";
import { UserPage } from "./pages/user/user-page";
const TEAM_NAME = "Alliance Rogue";
const SECONDARY_TEAM_NAME = "Team Olive";
const ROSTER_SIZE = 4;
const WEDNESDAY = 2;
const THURSDAY = 3;
const DAY_SECONDS = 24 * 60 * 60;
const MACHINE_TIMEZONE = Intl.DateTimeFormat().resolvedOptions().timeZone;
test.describe("New team creation", () => {
test("creates new team", async ({ page }) => {
@@ -350,3 +357,136 @@ async function createFullTeam(factories: Factories) {
memberUserIds: [ADMIN_ID, ...members.map((member) => member.id)],
});
}
test.describe("Team schedule", () => {
test("member sees the grid states and playable windows", async ({
page,
factories,
}) => {
const noScheduleMember = await factories.UserFactory.create();
const { customUrl } = await factories.TeamFactory.create({
name: TEAM_NAME,
memberUserIds: [ADMIN_ID, NZAP_TEST_ID, noScheduleMember.id],
});
const { startsAt } = currentWeek();
await factories.AvailabilityWeekFactory.create({
userId: ADMIN_ID,
weekStartsAt: startsAt,
timezone: MACHINE_TIMEZONE,
// the small-hours slot guards day bucketing: on machines off UTC it
// falls on another UTC day, so it moves columns if the server ignores
// the viewer's timezone
slots: [
daySlot(WEDNESDAY, "18:00", "22:00"),
daySlot(THURSDAY, "00:30", "02:00"),
],
dayNotes: [
{ date: currentWeekDates()[WEDNESDAY], text: "Leaving early" },
],
});
await factories.AvailabilityWeekFactory.create({
userId: NZAP_TEST_ID,
weekStartsAt: startsAt,
timezone: MACHINE_TIMEZONE,
slots: [daySlot(WEDNESDAY, "19:00", "23:00")],
});
await impersonate(page, ADMIN_ID);
await setTimezoneCookie(page);
const team = new TeamPage(page);
await team.goto(customUrl);
const schedule = await team.openSchedule();
await expect(schedule.locators.grid).toBeVisible();
await expect(schedule.cellRange(ADMIN_ID, WEDNESDAY)).toBeVisible();
await expect(schedule.cellRange(ADMIN_ID, THURSDAY)).toBeVisible();
await expect(schedule.cell(ADMIN_ID, 0)).toHaveText("—");
await expect(schedule.cell(noScheduleMember.id, 0)).toHaveText("?");
await expect(schedule.locators.notes).toContainText("Leaving early");
// two members share Wed 19-22 while the third has no schedule, so the
// only playable window is the one-short tier
await expect(schedule.locators.windows).toHaveText(/Wed/);
await expect(schedule.dayDot(WEDNESDAY)).toBeVisible();
await isNotVisible(schedule.dayDot(0));
});
test("hides the schedule from non-members, a friend of a member included", async ({
page,
factories,
}) => {
const friend = await factories.UserFactory.create();
const { customUrl } = await factories.TeamFactory.create({
name: TEAM_NAME,
memberUserIds: [ADMIN_ID],
});
await factories.FriendshipFactory.create({
userOneId: ADMIN_ID,
userTwoId: friend.id,
});
await factories.AvailabilityWeekFactory.create({
userId: ADMIN_ID,
weekStartsAt: currentWeek().startsAt,
timezone: MACHINE_TIMEZONE,
slots: [daySlot(WEDNESDAY, "18:00", "22:00")],
});
await impersonate(page, friend.id);
const schedule = new TeamSchedulePage(page);
await schedule.goto(customUrl);
await expect(schedule.locators.hiddenMessage).toBeVisible();
await isNotVisible(schedule.locators.grid);
});
});
function currentWeek() {
return Availability.weekRange(new Date(), MACHINE_TIMEZONE);
}
function currentWeekDates() {
const { startsAt } = currentWeek();
return Array.from({ length: 7 }, (_, dayIndex) =>
Availability.dateInTimezone(
startsAt + dayIndex * DAY_SECONDS + DAY_SECONDS / 2,
MACHINE_TIMEZONE,
),
);
}
function daySlot(dayIndex: number, start: string, end: string) {
const dates = currentWeekDates();
return {
startsAt: Availability.localToTimestamp({
date: dates[dayIndex],
time: start,
timezone: MACHINE_TIMEZONE,
}),
endsAt: Availability.localToTimestamp({
date: dates[dayIndex],
time: end,
timezone: MACHINE_TIMEZONE,
}),
};
}
/**
* Writes the timezone cookie the browser would after hydration, so that the
* very first document request already renders in the machine's timezone the
* test computed its fixture times in.
*/
function setTimezoneCookie(page: Page) {
return page.context().addCookies([
{
name: "timezone",
value: MACHINE_TIMEZONE,
domain: "localhost",
path: "/",
},
]);
}

View File

@@ -5,5 +5,15 @@
"editor.later": "",
"editor.note": "",
"editor.timesInYourTimezone": "",
"editor.visibility": ""
"editor.visibility": "",
"team.canPlay": "",
"team.currentWeek": "",
"team.hidden": "",
"team.nextWeek": "",
"team.noSchedule": "",
"team.notAvailable": "",
"team.noWindows": "",
"team.visibility": "",
"team.weekHeading": "",
"team.withSub": ""
}

View File

@@ -14,6 +14,7 @@
"actionButtons.leaveTeam.confirm": "Forlad hold",
"actionButtons.editTeam": "Rediger hold",
"actionButtons.manageRoster": "",
"actionButtons.schedule": "",
"actionButtons.deleteTeam": "Slet hold",
"actionButtons.deleteTeam.profilePicture": "",
"actionButtons.deleteTeam.banner": "",

View File

@@ -5,5 +5,15 @@
"editor.later": "",
"editor.note": "",
"editor.timesInYourTimezone": "",
"editor.visibility": ""
"editor.visibility": "",
"team.canPlay": "",
"team.currentWeek": "",
"team.hidden": "",
"team.nextWeek": "",
"team.noSchedule": "",
"team.notAvailable": "",
"team.noWindows": "",
"team.visibility": "",
"team.weekHeading": "",
"team.withSub": ""
}

View File

@@ -14,6 +14,7 @@
"actionButtons.leaveTeam.confirm": "Verlassen",
"actionButtons.editTeam": "Team bearbeiten",
"actionButtons.manageRoster": "",
"actionButtons.schedule": "",
"actionButtons.deleteTeam": "Team löschen",
"actionButtons.deleteTeam.profilePicture": "",
"actionButtons.deleteTeam.banner": "",

View File

@@ -5,5 +5,15 @@
"editor.later": "Later",
"editor.note": "Note",
"editor.timesInYourTimezone": "Times in your time zone",
"editor.visibility": "Visible to your teammates and friends"
"editor.visibility": "Visible to your teammates and friends",
"team.canPlay": "Team can play ({{players}}+)",
"team.currentWeek": "This week",
"team.hidden": "Only team members can see the team schedule",
"team.nextWeek": "Next week",
"team.noSchedule": "No schedule",
"team.notAvailable": "Not available",
"team.noWindows": "No shared free time",
"team.visibility": "Visible to team members only",
"team.weekHeading": "Week {{week}}",
"team.withSub": "With a sub ({{players}})"
}

View File

@@ -14,6 +14,7 @@
"actionButtons.leaveTeam.confirm": "Leave",
"actionButtons.editTeam": "Edit Team",
"actionButtons.manageRoster": "Manage Members",
"actionButtons.schedule": "Schedule",
"actionButtons.deleteTeam": "Delete Team",
"actionButtons.deleteTeam.profilePicture": "Remove Profile Picture",
"actionButtons.deleteTeam.banner": "Remove Banner",

View File

@@ -5,5 +5,15 @@
"editor.later": "",
"editor.note": "",
"editor.timesInYourTimezone": "",
"editor.visibility": ""
"editor.visibility": "",
"team.canPlay": "",
"team.currentWeek": "",
"team.hidden": "",
"team.nextWeek": "",
"team.noSchedule": "",
"team.notAvailable": "",
"team.noWindows": "",
"team.visibility": "",
"team.weekHeading": "",
"team.withSub": ""
}

View File

@@ -14,6 +14,7 @@
"actionButtons.leaveTeam.confirm": "Abandonar",
"actionButtons.editTeam": "Editar equipo",
"actionButtons.manageRoster": "Gestionar miembros",
"actionButtons.schedule": "",
"actionButtons.deleteTeam": "Eliminar equipo",
"actionButtons.deleteTeam.profilePicture": "Eliminar foto de perfil",
"actionButtons.deleteTeam.banner": "Eliminar banner",

View File

@@ -5,5 +5,15 @@
"editor.later": "",
"editor.note": "",
"editor.timesInYourTimezone": "",
"editor.visibility": ""
"editor.visibility": "",
"team.canPlay": "",
"team.currentWeek": "",
"team.hidden": "",
"team.nextWeek": "",
"team.noSchedule": "",
"team.notAvailable": "",
"team.noWindows": "",
"team.visibility": "",
"team.weekHeading": "",
"team.withSub": ""
}

View File

@@ -14,6 +14,7 @@
"actionButtons.leaveTeam.confirm": "Abandonar",
"actionButtons.editTeam": "Editar Equipo",
"actionButtons.manageRoster": "Gestionar miembros",
"actionButtons.schedule": "",
"actionButtons.deleteTeam": "Eliminar Equipo",
"actionButtons.deleteTeam.profilePicture": "Eliminar foto de perfil",
"actionButtons.deleteTeam.banner": "Eliminar banner",

View File

@@ -5,5 +5,15 @@
"editor.later": "",
"editor.note": "",
"editor.timesInYourTimezone": "",
"editor.visibility": ""
"editor.visibility": "",
"team.canPlay": "",
"team.currentWeek": "",
"team.hidden": "",
"team.nextWeek": "",
"team.noSchedule": "",
"team.notAvailable": "",
"team.noWindows": "",
"team.visibility": "",
"team.weekHeading": "",
"team.withSub": ""
}

View File

@@ -14,6 +14,7 @@
"actionButtons.leaveTeam.confirm": "Quitter",
"actionButtons.editTeam": "Modifier l'équipe",
"actionButtons.manageRoster": "",
"actionButtons.schedule": "",
"actionButtons.deleteTeam": "Supprimer l'équipe",
"actionButtons.deleteTeam.profilePicture": "",
"actionButtons.deleteTeam.banner": "",

View File

@@ -5,5 +5,15 @@
"editor.later": "",
"editor.note": "",
"editor.timesInYourTimezone": "",
"editor.visibility": ""
"editor.visibility": "",
"team.canPlay": "",
"team.currentWeek": "",
"team.hidden": "",
"team.nextWeek": "",
"team.noSchedule": "",
"team.notAvailable": "",
"team.noWindows": "",
"team.visibility": "",
"team.weekHeading": "",
"team.withSub": ""
}

View File

@@ -14,6 +14,7 @@
"actionButtons.leaveTeam.confirm": "Quitter",
"actionButtons.editTeam": "Modifier l'équipe",
"actionButtons.manageRoster": "",
"actionButtons.schedule": "",
"actionButtons.deleteTeam": "Supprimer l'équipe",
"actionButtons.deleteTeam.profilePicture": "",
"actionButtons.deleteTeam.banner": "",

View File

@@ -5,5 +5,15 @@
"editor.later": "",
"editor.note": "",
"editor.timesInYourTimezone": "",
"editor.visibility": ""
"editor.visibility": "",
"team.canPlay": "",
"team.currentWeek": "",
"team.hidden": "",
"team.nextWeek": "",
"team.noSchedule": "",
"team.notAvailable": "",
"team.noWindows": "",
"team.visibility": "",
"team.weekHeading": "",
"team.withSub": ""
}

View File

@@ -14,6 +14,7 @@
"actionButtons.leaveTeam.confirm": "לעזוב",
"actionButtons.editTeam": "עריכת צוות",
"actionButtons.manageRoster": "",
"actionButtons.schedule": "",
"actionButtons.deleteTeam": "מחיקת צוות",
"actionButtons.deleteTeam.profilePicture": "",
"actionButtons.deleteTeam.banner": "",

View File

@@ -5,5 +5,15 @@
"editor.later": "",
"editor.note": "",
"editor.timesInYourTimezone": "",
"editor.visibility": ""
"editor.visibility": "",
"team.canPlay": "",
"team.currentWeek": "",
"team.hidden": "",
"team.nextWeek": "",
"team.noSchedule": "",
"team.notAvailable": "",
"team.noWindows": "",
"team.visibility": "",
"team.weekHeading": "",
"team.withSub": ""
}

View File

@@ -14,6 +14,7 @@
"actionButtons.leaveTeam.confirm": "Lascia",
"actionButtons.editTeam": "Modifica team",
"actionButtons.manageRoster": "",
"actionButtons.schedule": "",
"actionButtons.deleteTeam": "Delete team",
"actionButtons.deleteTeam.profilePicture": "",
"actionButtons.deleteTeam.banner": "",

View File

@@ -5,5 +5,15 @@
"editor.later": "",
"editor.note": "",
"editor.timesInYourTimezone": "",
"editor.visibility": ""
"editor.visibility": "",
"team.canPlay": "",
"team.currentWeek": "",
"team.hidden": "",
"team.nextWeek": "",
"team.noSchedule": "",
"team.notAvailable": "",
"team.noWindows": "",
"team.visibility": "",
"team.weekHeading": "",
"team.withSub": ""
}

View File

@@ -14,6 +14,7 @@
"actionButtons.leaveTeam.confirm": "チームを抜ける",
"actionButtons.editTeam": "チームを編集",
"actionButtons.manageRoster": "",
"actionButtons.schedule": "",
"actionButtons.deleteTeam": "チームを削除",
"actionButtons.deleteTeam.profilePicture": "",
"actionButtons.deleteTeam.banner": "",

View File

@@ -5,5 +5,15 @@
"editor.later": "",
"editor.note": "",
"editor.timesInYourTimezone": "",
"editor.visibility": ""
"editor.visibility": "",
"team.canPlay": "",
"team.currentWeek": "",
"team.hidden": "",
"team.nextWeek": "",
"team.noSchedule": "",
"team.notAvailable": "",
"team.noWindows": "",
"team.visibility": "",
"team.weekHeading": "",
"team.withSub": ""
}

View File

@@ -14,6 +14,7 @@
"actionButtons.leaveTeam.confirm": "",
"actionButtons.editTeam": "",
"actionButtons.manageRoster": "",
"actionButtons.schedule": "",
"actionButtons.deleteTeam": "",
"actionButtons.deleteTeam.profilePicture": "",
"actionButtons.deleteTeam.banner": "",

View File

@@ -5,5 +5,15 @@
"editor.later": "",
"editor.note": "",
"editor.timesInYourTimezone": "",
"editor.visibility": ""
"editor.visibility": "",
"team.canPlay": "",
"team.currentWeek": "",
"team.hidden": "",
"team.nextWeek": "",
"team.noSchedule": "",
"team.notAvailable": "",
"team.noWindows": "",
"team.visibility": "",
"team.weekHeading": "",
"team.withSub": ""
}

View File

@@ -14,6 +14,7 @@
"actionButtons.leaveTeam.confirm": "",
"actionButtons.editTeam": "",
"actionButtons.manageRoster": "",
"actionButtons.schedule": "",
"actionButtons.deleteTeam": "",
"actionButtons.deleteTeam.profilePicture": "",
"actionButtons.deleteTeam.banner": "",

View File

@@ -5,5 +5,15 @@
"editor.later": "",
"editor.note": "",
"editor.timesInYourTimezone": "",
"editor.visibility": ""
"editor.visibility": "",
"team.canPlay": "",
"team.currentWeek": "",
"team.hidden": "",
"team.nextWeek": "",
"team.noSchedule": "",
"team.notAvailable": "",
"team.noWindows": "",
"team.visibility": "",
"team.weekHeading": "",
"team.withSub": ""
}

View File

@@ -14,6 +14,7 @@
"actionButtons.leaveTeam.confirm": "Opuść",
"actionButtons.editTeam": "Edytuj Drużynę",
"actionButtons.manageRoster": "",
"actionButtons.schedule": "",
"actionButtons.deleteTeam": "Usuń drużynę",
"actionButtons.deleteTeam.profilePicture": "",
"actionButtons.deleteTeam.banner": "",

View File

@@ -5,5 +5,15 @@
"editor.later": "",
"editor.note": "",
"editor.timesInYourTimezone": "",
"editor.visibility": ""
"editor.visibility": "",
"team.canPlay": "",
"team.currentWeek": "",
"team.hidden": "",
"team.nextWeek": "",
"team.noSchedule": "",
"team.notAvailable": "",
"team.noWindows": "",
"team.visibility": "",
"team.weekHeading": "",
"team.withSub": ""
}

View File

@@ -14,6 +14,7 @@
"actionButtons.leaveTeam.confirm": "Sair",
"actionButtons.editTeam": "Editar Time",
"actionButtons.manageRoster": "",
"actionButtons.schedule": "",
"actionButtons.deleteTeam": "Apagar Time",
"actionButtons.deleteTeam.profilePicture": "",
"actionButtons.deleteTeam.banner": "",

View File

@@ -5,5 +5,15 @@
"editor.later": "",
"editor.note": "",
"editor.timesInYourTimezone": "",
"editor.visibility": ""
"editor.visibility": "",
"team.canPlay": "",
"team.currentWeek": "",
"team.hidden": "",
"team.nextWeek": "",
"team.noSchedule": "",
"team.notAvailable": "",
"team.noWindows": "",
"team.visibility": "",
"team.weekHeading": "",
"team.withSub": ""
}

View File

@@ -14,6 +14,7 @@
"actionButtons.leaveTeam.confirm": "Покинуть",
"actionButtons.editTeam": "Редактировать команду",
"actionButtons.manageRoster": "",
"actionButtons.schedule": "",
"actionButtons.deleteTeam": "Удалить команду",
"actionButtons.deleteTeam.profilePicture": "",
"actionButtons.deleteTeam.banner": "",

View File

@@ -5,5 +5,15 @@
"editor.later": "",
"editor.note": "",
"editor.timesInYourTimezone": "",
"editor.visibility": ""
"editor.visibility": "",
"team.canPlay": "",
"team.currentWeek": "",
"team.hidden": "",
"team.nextWeek": "",
"team.noSchedule": "",
"team.notAvailable": "",
"team.noWindows": "",
"team.visibility": "",
"team.weekHeading": "",
"team.withSub": ""
}

View File

@@ -14,6 +14,7 @@
"actionButtons.leaveTeam.confirm": "退出",
"actionButtons.editTeam": "编辑队伍",
"actionButtons.manageRoster": "管理队员",
"actionButtons.schedule": "",
"actionButtons.deleteTeam": "删除队伍",
"actionButtons.deleteTeam.profilePicture": "移除队徽",
"actionButtons.deleteTeam.banner": "移除横幅",