mirror of
https://github.com/Sendouc/sendou.ink.git
synced 2026-08-28 14:18:04 -05:00
Scrim schedule request
This commit is contained in:
@@ -74,3 +74,31 @@ export type WindowAvailability =
|
||||
| { 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>;
|
||||
}
|
||||
|
||||
@@ -14,7 +14,7 @@ import { Avatar } from "~/components/Avatar";
|
||||
import { useDateTimeFormat } from "~/hooks/intl/useDateTimeFormat";
|
||||
import { databaseTimestampToDate } from "~/utils/dates";
|
||||
import type { SerializeFrom } from "~/utils/remix";
|
||||
import type { TimeRange } from "../availability-types";
|
||||
import type { TimeRange, WindowAvailabilityEntry } from "../availability-types";
|
||||
import type { RegistrationAvailability } from "../core/RegistrationAvailability.server";
|
||||
import styles from "./RegistrationAvailabilityPanel.module.css";
|
||||
|
||||
@@ -27,9 +27,7 @@ export interface AvailabilityPanelUser {
|
||||
}
|
||||
|
||||
export type AvailabilityPanelData = SerializeFrom<RegistrationAvailability>;
|
||||
export type AvailabilityPanelEntry = NonNullable<
|
||||
AvailabilityPanelData["entries"]
|
||||
>[number];
|
||||
export type AvailabilityPanelEntry = WindowAvailabilityEntry;
|
||||
|
||||
export type AvailabilityRowStatus =
|
||||
| AvailabilityPanelEntry["availability"]["status"]
|
||||
@@ -187,7 +185,7 @@ export function AvailabilityMemberRow({
|
||||
</span>
|
||||
{showAvailability ? <AvailabilityRowDetail entry={entry} /> : null}
|
||||
{showAvailability
|
||||
? entry?.notes.map((note) => (
|
||||
? entry?.notes?.map((note) => (
|
||||
<span key={note} className={styles.note}>
|
||||
<Flag size={12} className={styles.noteFlag} /> {note}
|
||||
</span>
|
||||
|
||||
@@ -3,6 +3,7 @@ import * as AvailabilityWeekFactory from "~/db/seed/factories/AvailabilityWeekFa
|
||||
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";
|
||||
|
||||
@@ -91,3 +92,100 @@ describe("RosterSchedule.rosterScheduleData", () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
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([]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,10 +1,13 @@
|
||||
import { addWeeks } from "date-fns";
|
||||
import * as R from "remeda";
|
||||
import { dateToDatabaseTimestamp } from "~/utils/dates";
|
||||
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 } from "../availability-types";
|
||||
import type { TimeRange, WindowSchedule } from "../availability-types";
|
||||
import * as Availability from "./Availability";
|
||||
import * as Commitments from "./Commitments.server";
|
||||
|
||||
@@ -115,3 +118,74 @@ function weekView({ range, timezone }: { range: TimeRange; timezone: string }) {
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 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,
|
||||
};
|
||||
}),
|
||||
}));
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
141
app/features/scrims/components/ScrimAvailability.tsx
Normal file
141
app/features/scrims/components/ScrimAvailability.tsx
Normal file
@@ -0,0 +1,141 @@
|
||||
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={rosterStatuses(fit)} />
|
||||
</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({
|
||||
@@ -77,6 +82,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 +91,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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -20,7 +20,6 @@ import {
|
||||
import trackStyles from "~/features/availability/components/ScheduleTracks.module.css";
|
||||
import * as Availability from "~/features/availability/core/Availability";
|
||||
import type { RosterScheduleData } from "~/features/availability/core/RosterSchedule.server";
|
||||
import { getMemberRoleType } from "~/features/team/team-utils";
|
||||
import { useDateTimeFormat } from "~/hooks/intl/useDateTimeFormat";
|
||||
import {
|
||||
databaseTimestampToDate,
|
||||
@@ -382,13 +381,10 @@ function rosterUserIds({
|
||||
const team = teams.find((team) => team.id === from.teamId);
|
||||
if (!team) return [];
|
||||
|
||||
const players = team.members.filter(
|
||||
(member) => getMemberRoleType(member) !== "OTHER",
|
||||
);
|
||||
const members =
|
||||
players.length >= SCRIM.MIN_MEMBERS_PER_TEAM ? players : team.members;
|
||||
|
||||
return R.unique([viewerId, ...members.map((member) => member.id)]);
|
||||
return R.unique([
|
||||
viewerId,
|
||||
...Scrim.teamPlayers(team.members).map((member) => member.id),
|
||||
]);
|
||||
}
|
||||
|
||||
function slotsOfDay({
|
||||
|
||||
@@ -7,8 +7,10 @@ import {
|
||||
isTrackingLocked,
|
||||
participantIdsListFromAccepted,
|
||||
pickableSlots,
|
||||
rosterFit,
|
||||
sideDisplayName,
|
||||
sideOfUser,
|
||||
teamPlayers,
|
||||
} from "./Scrim";
|
||||
|
||||
const HOUR = 60 * 60;
|
||||
@@ -661,3 +663,110 @@ describe("pickableSlots", () => {
|
||||
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(22),
|
||||
});
|
||||
});
|
||||
|
||||
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(19)),
|
||||
];
|
||||
|
||||
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(18)], 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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -6,8 +6,15 @@ 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 {
|
||||
@@ -266,6 +273,86 @@ export function pickableSlots({
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 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
|
||||
|
||||
@@ -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,
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -48,7 +48,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(),
|
||||
|
||||
@@ -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:30") });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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>();
|
||||
|
||||
|
||||
@@ -40,6 +40,7 @@ export class ScrimsPage {
|
||||
limitedVisibilityPopover: page.getByTestId("limited-visibility-popover"),
|
||||
tournamentPopover: page.getByTestId("tournament-popover-trigger"),
|
||||
canceledLabel: page.getByText("Canceled"),
|
||||
fitIndicator: page.getByTestId("scrim-fit-indicator"),
|
||||
divsFilterPill: page.getByTestId("divs-filter"),
|
||||
addFilterButton: page.getByTestId("add-filter-button"),
|
||||
saveFiltersAsDefaultButton: page.getByTestId(
|
||||
@@ -94,6 +95,11 @@ export class ScrimsPage {
|
||||
await this.page.getByTestId("menu-item-divs-filter").click();
|
||||
}
|
||||
|
||||
/** One roster member's row of the fit indicator's popover, its status in `data-status`. */
|
||||
availabilityRow(userId: number) {
|
||||
return this.page.getByTestId(`availability-row-${userId}`);
|
||||
}
|
||||
|
||||
async openTab(tab: Tab) {
|
||||
await this.page.getByRole("tab", { name: TAB_NAMES[tab] }).click();
|
||||
}
|
||||
|
||||
@@ -477,6 +477,62 @@ test.describe("Scrim schedule picker", () => {
|
||||
});
|
||||
});
|
||||
|
||||
test.describe("Scrim fit indicator", () => {
|
||||
test("shows how much of the viewer's roster could play a post", async ({
|
||||
page,
|
||||
factories,
|
||||
}) => {
|
||||
const { memberUserIds } = await createTeamFor(factories, NZAP_TEST_ID);
|
||||
const evening = nextWeekSlot(WEDNESDAY, "18:00", "23:00");
|
||||
const withoutSchedule = memberUserIds[memberUserIds.length - 1];
|
||||
|
||||
for (const userId of memberUserIds.filter(
|
||||
(userId) => userId !== withoutSchedule,
|
||||
)) {
|
||||
await factories.AvailabilityWeekFactory.create({
|
||||
userId,
|
||||
weekStartsAt: nextWeek().startsAt,
|
||||
timezone: MACHINE_TIMEZONE,
|
||||
slots: [evening],
|
||||
});
|
||||
}
|
||||
|
||||
await factories.ScrimPostFactory.create({
|
||||
users: await createGroup(factories),
|
||||
startsAt: evening.startsAt,
|
||||
isScheduledForFuture: true,
|
||||
});
|
||||
|
||||
await impersonate(page, NZAP_TEST_ID);
|
||||
|
||||
const scrims = new ScrimsPage(page);
|
||||
await scrims.goto();
|
||||
await scrims.openTab("available");
|
||||
|
||||
await expect(scrims.locators.fitIndicator).toContainText("3/4 available");
|
||||
|
||||
await scrims.locators.fitIndicator.click();
|
||||
|
||||
await expect(scrims.availabilityRow(NZAP_TEST_ID)).toHaveAttribute(
|
||||
"data-status",
|
||||
"available",
|
||||
);
|
||||
await expect(scrims.availabilityRow(withoutSchedule)).toHaveAttribute(
|
||||
"data-status",
|
||||
"unknown",
|
||||
);
|
||||
|
||||
await page.keyboard.press("Escape");
|
||||
await scrims.requestFirst();
|
||||
|
||||
// the same breakdown, for the slot the request would be made for
|
||||
await expect(scrims.availabilityRow(NZAP_TEST_ID)).toHaveAttribute(
|
||||
"data-status",
|
||||
"available",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
async function createTeamFor(factories: Factories, userId: number) {
|
||||
const teammates = await factories.UserFactory.createMany(GROUP_SIZE - 1);
|
||||
|
||||
|
||||
@@ -42,5 +42,6 @@
|
||||
"picker.noSchedule": "",
|
||||
"picker.andOthers": "",
|
||||
"picker.legend.full": "",
|
||||
"picker.legend.oneShort": ""
|
||||
"picker.legend.oneShort": "",
|
||||
"scrims.availableOfRoster": ""
|
||||
}
|
||||
|
||||
@@ -42,5 +42,6 @@
|
||||
"picker.noSchedule": "",
|
||||
"picker.andOthers": "",
|
||||
"picker.legend.full": "",
|
||||
"picker.legend.oneShort": ""
|
||||
"picker.legend.oneShort": "",
|
||||
"scrims.availableOfRoster": ""
|
||||
}
|
||||
|
||||
@@ -42,5 +42,6 @@
|
||||
"picker.noSchedule": "No schedule this week: {{users}}",
|
||||
"picker.andOthers": "{{amount}} more",
|
||||
"picker.legend.full": "{{players}}+ free",
|
||||
"picker.legend.oneShort": "{{players}} free (sub?)"
|
||||
"picker.legend.oneShort": "{{players}} free (sub?)",
|
||||
"scrims.availableOfRoster": "{{amount}}/{{total}} available"
|
||||
}
|
||||
|
||||
@@ -42,5 +42,6 @@
|
||||
"picker.noSchedule": "",
|
||||
"picker.andOthers": "",
|
||||
"picker.legend.full": "",
|
||||
"picker.legend.oneShort": ""
|
||||
"picker.legend.oneShort": "",
|
||||
"scrims.availableOfRoster": ""
|
||||
}
|
||||
|
||||
@@ -42,5 +42,6 @@
|
||||
"picker.noSchedule": "",
|
||||
"picker.andOthers": "",
|
||||
"picker.legend.full": "",
|
||||
"picker.legend.oneShort": ""
|
||||
"picker.legend.oneShort": "",
|
||||
"scrims.availableOfRoster": ""
|
||||
}
|
||||
|
||||
@@ -42,5 +42,6 @@
|
||||
"picker.noSchedule": "",
|
||||
"picker.andOthers": "",
|
||||
"picker.legend.full": "",
|
||||
"picker.legend.oneShort": ""
|
||||
"picker.legend.oneShort": "",
|
||||
"scrims.availableOfRoster": ""
|
||||
}
|
||||
|
||||
@@ -42,5 +42,6 @@
|
||||
"picker.noSchedule": "",
|
||||
"picker.andOthers": "",
|
||||
"picker.legend.full": "",
|
||||
"picker.legend.oneShort": ""
|
||||
"picker.legend.oneShort": "",
|
||||
"scrims.availableOfRoster": ""
|
||||
}
|
||||
|
||||
@@ -42,5 +42,6 @@
|
||||
"picker.noSchedule": "",
|
||||
"picker.andOthers": "",
|
||||
"picker.legend.full": "",
|
||||
"picker.legend.oneShort": ""
|
||||
"picker.legend.oneShort": "",
|
||||
"scrims.availableOfRoster": ""
|
||||
}
|
||||
|
||||
@@ -42,5 +42,6 @@
|
||||
"picker.noSchedule": "",
|
||||
"picker.andOthers": "",
|
||||
"picker.legend.full": "",
|
||||
"picker.legend.oneShort": ""
|
||||
"picker.legend.oneShort": "",
|
||||
"scrims.availableOfRoster": ""
|
||||
}
|
||||
|
||||
@@ -42,5 +42,6 @@
|
||||
"picker.noSchedule": "",
|
||||
"picker.andOthers": "",
|
||||
"picker.legend.full": "",
|
||||
"picker.legend.oneShort": ""
|
||||
"picker.legend.oneShort": "",
|
||||
"scrims.availableOfRoster": ""
|
||||
}
|
||||
|
||||
@@ -42,5 +42,6 @@
|
||||
"picker.noSchedule": "",
|
||||
"picker.andOthers": "",
|
||||
"picker.legend.full": "",
|
||||
"picker.legend.oneShort": ""
|
||||
"picker.legend.oneShort": "",
|
||||
"scrims.availableOfRoster": ""
|
||||
}
|
||||
|
||||
@@ -42,5 +42,6 @@
|
||||
"picker.noSchedule": "",
|
||||
"picker.andOthers": "",
|
||||
"picker.legend.full": "",
|
||||
"picker.legend.oneShort": ""
|
||||
"picker.legend.oneShort": "",
|
||||
"scrims.availableOfRoster": ""
|
||||
}
|
||||
|
||||
@@ -42,5 +42,6 @@
|
||||
"picker.noSchedule": "",
|
||||
"picker.andOthers": "",
|
||||
"picker.legend.full": "",
|
||||
"picker.legend.oneShort": ""
|
||||
"picker.legend.oneShort": "",
|
||||
"scrims.availableOfRoster": ""
|
||||
}
|
||||
|
||||
@@ -42,5 +42,6 @@
|
||||
"picker.noSchedule": "",
|
||||
"picker.andOthers": "",
|
||||
"picker.legend.full": "",
|
||||
"picker.legend.oneShort": ""
|
||||
"picker.legend.oneShort": "",
|
||||
"scrims.availableOfRoster": ""
|
||||
}
|
||||
|
||||
@@ -42,5 +42,6 @@
|
||||
"picker.noSchedule": "",
|
||||
"picker.andOthers": "",
|
||||
"picker.legend.full": "",
|
||||
"picker.legend.oneShort": ""
|
||||
"picker.legend.oneShort": "",
|
||||
"scrims.availableOfRoster": ""
|
||||
}
|
||||
|
||||
@@ -42,5 +42,6 @@
|
||||
"picker.noSchedule": "",
|
||||
"picker.andOthers": "",
|
||||
"picker.legend.full": "",
|
||||
"picker.legend.oneShort": ""
|
||||
"picker.legend.oneShort": "",
|
||||
"scrims.availableOfRoster": ""
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user