mirror of
https://github.com/Sendouc/sendou.ink.git
synced 2026-08-28 14:18:04 -05:00
Team schedule calendar on team page
This commit is contained in:
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 { teamScheduleSearchParams } from "./availability-search-params";
|
||||
|
||||
describe("teamScheduleSearchParams", () => {
|
||||
test("round-trips", () => {
|
||||
assertRoundTrips(teamScheduleSearchParams, {
|
||||
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 teamScheduleSearchParams = SearchParams.define({
|
||||
week: SP.param(v.picklist(["current", "next"]), {
|
||||
default: "current",
|
||||
loader: false,
|
||||
}),
|
||||
});
|
||||
@@ -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]>>,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 }];
|
||||
}),
|
||||
),
|
||||
};
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
340
app/features/availability/routes/t.$customUrl.schedule.tsx
Normal file
340
app/features/availability/routes/t.$customUrl.schedule.tsx
Normal 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;
|
||||
}
|
||||
@@ -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"
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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", [
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
38
e2e/pages/team/team-schedule-page.ts
Normal file
38
e2e/pages/team/team-schedule-page.ts
Normal 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}`);
|
||||
}
|
||||
}
|
||||
140
e2e/team.spec.ts
140
e2e/team.spec.ts
@@ -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: "/",
|
||||
},
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -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": ""
|
||||
}
|
||||
|
||||
@@ -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": "",
|
||||
|
||||
@@ -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": ""
|
||||
}
|
||||
|
||||
@@ -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": "",
|
||||
|
||||
@@ -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}})"
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": ""
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": ""
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": ""
|
||||
}
|
||||
|
||||
@@ -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": "",
|
||||
|
||||
@@ -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": ""
|
||||
}
|
||||
|
||||
@@ -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": "",
|
||||
|
||||
@@ -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": ""
|
||||
}
|
||||
|
||||
@@ -14,6 +14,7 @@
|
||||
"actionButtons.leaveTeam.confirm": "לעזוב",
|
||||
"actionButtons.editTeam": "עריכת צוות",
|
||||
"actionButtons.manageRoster": "",
|
||||
"actionButtons.schedule": "",
|
||||
"actionButtons.deleteTeam": "מחיקת צוות",
|
||||
"actionButtons.deleteTeam.profilePicture": "",
|
||||
"actionButtons.deleteTeam.banner": "",
|
||||
|
||||
@@ -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": ""
|
||||
}
|
||||
|
||||
@@ -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": "",
|
||||
|
||||
@@ -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": ""
|
||||
}
|
||||
|
||||
@@ -14,6 +14,7 @@
|
||||
"actionButtons.leaveTeam.confirm": "チームを抜ける",
|
||||
"actionButtons.editTeam": "チームを編集",
|
||||
"actionButtons.manageRoster": "",
|
||||
"actionButtons.schedule": "",
|
||||
"actionButtons.deleteTeam": "チームを削除",
|
||||
"actionButtons.deleteTeam.profilePicture": "",
|
||||
"actionButtons.deleteTeam.banner": "",
|
||||
|
||||
@@ -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": ""
|
||||
}
|
||||
|
||||
@@ -14,6 +14,7 @@
|
||||
"actionButtons.leaveTeam.confirm": "",
|
||||
"actionButtons.editTeam": "",
|
||||
"actionButtons.manageRoster": "",
|
||||
"actionButtons.schedule": "",
|
||||
"actionButtons.deleteTeam": "",
|
||||
"actionButtons.deleteTeam.profilePicture": "",
|
||||
"actionButtons.deleteTeam.banner": "",
|
||||
|
||||
@@ -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": ""
|
||||
}
|
||||
|
||||
@@ -14,6 +14,7 @@
|
||||
"actionButtons.leaveTeam.confirm": "",
|
||||
"actionButtons.editTeam": "",
|
||||
"actionButtons.manageRoster": "",
|
||||
"actionButtons.schedule": "",
|
||||
"actionButtons.deleteTeam": "",
|
||||
"actionButtons.deleteTeam.profilePicture": "",
|
||||
"actionButtons.deleteTeam.banner": "",
|
||||
|
||||
@@ -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": ""
|
||||
}
|
||||
|
||||
@@ -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": "",
|
||||
|
||||
@@ -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": ""
|
||||
}
|
||||
|
||||
@@ -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": "",
|
||||
|
||||
@@ -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": ""
|
||||
}
|
||||
|
||||
@@ -14,6 +14,7 @@
|
||||
"actionButtons.leaveTeam.confirm": "Покинуть",
|
||||
"actionButtons.editTeam": "Редактировать команду",
|
||||
"actionButtons.manageRoster": "",
|
||||
"actionButtons.schedule": "",
|
||||
"actionButtons.deleteTeam": "Удалить команду",
|
||||
"actionButtons.deleteTeam.profilePicture": "",
|
||||
"actionButtons.deleteTeam.banner": "",
|
||||
|
||||
@@ -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": ""
|
||||
}
|
||||
|
||||
@@ -14,6 +14,7 @@
|
||||
"actionButtons.leaveTeam.confirm": "退出",
|
||||
"actionButtons.editTeam": "编辑队伍",
|
||||
"actionButtons.manageRoster": "管理队员",
|
||||
"actionButtons.schedule": "",
|
||||
"actionButtons.deleteTeam": "删除队伍",
|
||||
"actionButtons.deleteTeam.profilePicture": "移除队徽",
|
||||
"actionButtons.deleteTeam.banner": "移除横幅",
|
||||
|
||||
Reference in New Issue
Block a user