From be973fa8c17475cdf9bfcc12029b1dfe7ece814e Mon Sep 17 00:00:00 2001 From: Kalle <38327916+Sendouc@users.noreply.github.com> Date: Sat, 22 Aug 2026 15:13:40 +0300 Subject: [PATCH] Team schedule calendar on team page --- .../availability-search-params.test.ts | 11 + .../availability-search-params.ts | 10 + .../availability/core/Availability.test.ts | 54 +++ .../availability/core/Availability.ts | 23 +- .../loaders/t.$customUrl.schedule.server.ts | 212 +++++++++++ .../routes/t.$customUrl.schedule.module.css | 169 +++++++++ .../routes/t.$customUrl.schedule.tsx | 340 ++++++++++++++++++ .../team/routes/t.$customUrl.index.tsx | 16 +- app/features/team/routes/t.$customUrl.tsx | 16 +- app/routes.ts | 1 + e2e/helpers/factories.ts | 3 + e2e/pages/team/team-page.ts | 7 + e2e/pages/team/team-schedule-page.ts | 38 ++ e2e/team.spec.ts | 140 ++++++++ locales/da/schedule.json | 12 +- locales/da/team.json | 1 + locales/de/schedule.json | 12 +- locales/de/team.json | 1 + locales/en/schedule.json | 12 +- locales/en/team.json | 1 + locales/es-ES/schedule.json | 12 +- locales/es-ES/team.json | 1 + locales/es-US/schedule.json | 12 +- locales/es-US/team.json | 1 + locales/fr-CA/schedule.json | 12 +- locales/fr-CA/team.json | 1 + locales/fr-EU/schedule.json | 12 +- locales/fr-EU/team.json | 1 + locales/he/schedule.json | 12 +- locales/he/team.json | 1 + locales/it/schedule.json | 12 +- locales/it/team.json | 1 + locales/ja/schedule.json | 12 +- locales/ja/team.json | 1 + locales/ko/schedule.json | 12 +- locales/ko/team.json | 1 + locales/nl/schedule.json | 12 +- locales/nl/team.json | 1 + locales/pl/schedule.json | 12 +- locales/pl/team.json | 1 + locales/pt-BR/schedule.json | 12 +- locales/pt-BR/team.json | 1 + locales/ru/schedule.json | 12 +- locales/ru/team.json | 1 + locales/zh/schedule.json | 12 +- locales/zh/team.json | 1 + 46 files changed, 1220 insertions(+), 28 deletions(-) create mode 100644 app/features/availability/availability-search-params.test.ts create mode 100644 app/features/availability/availability-search-params.ts create mode 100644 app/features/availability/loaders/t.$customUrl.schedule.server.ts create mode 100644 app/features/availability/routes/t.$customUrl.schedule.module.css create mode 100644 app/features/availability/routes/t.$customUrl.schedule.tsx create mode 100644 e2e/pages/team/team-schedule-page.ts diff --git a/app/features/availability/availability-search-params.test.ts b/app/features/availability/availability-search-params.test.ts new file mode 100644 index 000000000..910ebeab8 --- /dev/null +++ b/app/features/availability/availability-search-params.test.ts @@ -0,0 +1,11 @@ +import { describe, test } from "vitest"; +import { assertRoundTrips } from "~/modules/search-params/search-params-test-utils"; +import { teamScheduleSearchParams } from "./availability-search-params"; + +describe("teamScheduleSearchParams", () => { + test("round-trips", () => { + assertRoundTrips(teamScheduleSearchParams, { + week: ["current", "next"], + }); + }); +}); diff --git a/app/features/availability/availability-search-params.ts b/app/features/availability/availability-search-params.ts new file mode 100644 index 000000000..c9b3cdac3 --- /dev/null +++ b/app/features/availability/availability-search-params.ts @@ -0,0 +1,10 @@ +import * as v from "valibot"; +import * as SearchParams from "~/modules/search-params/search-params"; +import { SP } from "~/modules/search-params/search-params"; + +export const teamScheduleSearchParams = SearchParams.define({ + week: SP.param(v.picklist(["current", "next"]), { + default: "current", + loader: false, + }), +}); diff --git a/app/features/availability/core/Availability.test.ts b/app/features/availability/core/Availability.test.ts index 81124e871..fc5a27d31 100644 --- a/app/features/availability/core/Availability.test.ts +++ b/app/features/availability/core/Availability.test.ts @@ -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>, diff --git a/app/features/availability/core/Availability.ts b/app/features/availability/core/Availability.ts index 5cdfe399e..4d7f93756 100644 --- a/app/features/availability/core/Availability.ts +++ b/app/features/availability/core/Availability.ts @@ -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, + window: TimeRange, +): Array { + return normalize(ranges).flatMap((range) => { + const startsAt = Math.max(range.startsAt, window.startsAt); + const endsAt = Math.min(range.endsAt, window.endsAt); + + return endsAt > startsAt ? [{ startsAt, endsAt }] : []; + }); +} + /** * The windows the team could play in: spans * where `minPlayers` of the members (`FULL`) or one fewer (`ONE_SHORT`) are all diff --git a/app/features/availability/loaders/t.$customUrl.schedule.server.ts b/app/features/availability/loaders/t.$customUrl.schedule.server.ts new file mode 100644 index 000000000..70fdc5e2d --- /dev/null +++ b/app/features/availability/loaders/t.$customUrl.schedule.server.ts @@ -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; + +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 +>[number]; + +function weekView({ + range, + timezone, + memberIds, + playerIds, + reportedWeeks, +}: { + range: TimeRange; + timezone: string; + memberIds: Array; + playerIds: Array; + reportedWeeks: Array; +}) { + 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; + 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; + 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>, + 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 }]; + }), + ), + }; +} diff --git a/app/features/availability/routes/t.$customUrl.schedule.module.css b/app/features/availability/routes/t.$customUrl.schedule.module.css new file mode 100644 index 000000000..70bedbc6c --- /dev/null +++ b/app/features/availability/routes/t.$customUrl.schedule.module.css @@ -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
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); +} diff --git a/app/features/availability/routes/t.$customUrl.schedule.tsx b/app/features/availability/routes/t.$customUrl.schedule.tsx new file mode 100644 index 000000000..8bd88989c --- /dev/null +++ b/app/features/availability/routes/t.$customUrl.schedule.tsx @@ -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[number]; +type MemberWeekRow = WeekData["members"][number]; +type TeamMember = TeamLoaderData["team"]["members"][number]; + +export default function TeamSchedulePage() { + const { t } = useTranslation(["schedule"]); + const data = useLoaderData(); + + return ( +
+ + {data.weeks ? ( + + ) : ( +
+ {t("schedule:team.hidden")} +
+ )} +
+ ); +} + +function ScheduleWeeks({ weeks }: { weeks: Array }) { + const { t } = useTranslation(["schedule"]); + const [{ week }, setParams] = useSearchParamsTyped(teamScheduleSearchParams); + const { formatter: headingFormatter } = useDateTimeFormat({ + month: "short", + day: "numeric", + }); + + const shownWeek = week === "next" ? weeks[1] : weeks[0]; + + return ( +
+
+

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

+ + setParams({ week: "current" })} + > + {t("schedule:team.currentWeek")} + + setParams({ week: "next" })} + > + {t("schedule:team.nextWeek")} + + +
+ + + +

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

+
+ ); +} + +function ScheduleGrid({ week }: { week: WeekData }) { + const { t } = useTranslation(["team"]); + const members = useTeamMembers(); + const { formatter: dayFormatter } = useDateTimeFormat({ + weekday: "short", + day: "numeric", + }); + + const rows = week.members.flatMap((row) => { + const member = members.find((member) => member.id === row.userId); + + return member ? [{ ...row, member }] : []; + }); + const playerRows = rows.filter( + ({ member }) => getMemberRoleType(member) !== "OTHER", + ); + const otherRows = rows.filter( + ({ member }) => getMemberRoleType(member) === "OTHER", + ); + + const renderRow = (row: MemberWeekRow & { member: TeamMember }) => ( + + + + + {row.days.map((ranges, dayIndex) => ( + + ))} + + ); + + return ( +
+ + + + + ))} + + + + {playerRows.map(renderRow)} + {otherRows.length > 0 ? ( + + + + ) : null} + {otherRows.map(renderRow)} + +
+ {week.days.map((day, dayIndex) => ( + + {day.windowTier ? ( + + ) : null} + {dayFormatter.format(day.noonAt)} +
+ {t("team:roster.sections.other")} +
+
+ ); +} + +function ScheduleCell({ + row, + 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 ( + +
+ {!row.reported ? ( + + ? + + ) : ranges.length === 0 ? ( + + — + + ) : ( + ranges.map((range) => ( +
+ {timeFormatter.formatRange(range.startsAt, range.endsAt)} +
+ )) + )} + {note ? ( + + + + ) : null} +
+ + ); +} + +function PlayableWindowsSummary({ week }: { week: WeekData }) { + const { t } = useTranslation(["schedule"]); + + const fullWindows = week.windows.filter((window) => window.tier === "FULL"); + const oneShortWindows = week.windows.filter( + (window) => window.tier === "ONE_SHORT", + ); + + return ( +
+
+ + + {t("schedule:team.canPlay", { players: week.minPlayers })} + + +
+ {week.minPlayers > 1 && oneShortWindows.length > 0 ? ( +
+ + + {t("schedule:team.withSub", { players: week.minPlayers - 1 })} + + +
+ ) : null} +
+ ); +} + +function WindowList({ windows }: { windows: WeekData["windows"] }) { + const { t } = useTranslation(["schedule"]); + const { formatter: windowFormatter } = useDateTimeFormat({ + weekday: "short", + hour: "numeric", + minute: "2-digit", + }); + + if (windows.length === 0) { + return {t("schedule:team.noWindows")}; + } + + return ( + + {windows.map((window) => ( + + {windowFormatter.formatRange(window.startsAt, window.endsAt)} + + ))} + + ); +} + +function WeekNotes({ week }: { week: WeekData }) { + const members = useTeamMembers(); + const { formatter: dayFormatter } = useDateTimeFormat({ weekday: "short" }); + + const notes = R.sortBy( + week.members.flatMap((row) => + row.notes.map((note) => ({ ...note, userId: row.userId })), + ), + (note) => note.dayIndex, + ); + + if (notes.length === 0) return null; + + return ( +
    + {notes.map((note) => ( +
  • + + + {dayFormatter.format(week.days[note.dayIndex].noonAt)} + + + {members.find((member) => member.id === note.userId)?.username} + + {note.text} +
  • + ))} +
+ ); +} + +function useTeamMembers() { + const [, parentRoute] = useMatches(); + invariant(parentRoute); + const layoutData = parentRoute.loaderData as TeamLoaderData; + + return layoutData.team.members; +} diff --git a/app/features/team/routes/t.$customUrl.index.tsx b/app/features/team/routes/t.$customUrl.index.tsx index ccd5c92de..918851ebb 100644 --- a/app/features/team/routes/t.$customUrl.index.tsx +++ b/app/features/team/routes/t.$customUrl.index.tsx @@ -1,4 +1,5 @@ import { + CalendarDays, LogOut, Menu, SquarePen, @@ -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 (
+ } + testId="team-schedule-button" + > + {t("team:actionButtons.schedule")} + {canManageRoster ? ( -
- +
+
+
+ +
+ +
- -
); } diff --git a/app/routes.ts b/app/routes.ts index c46d9a1a9..29bb624a6 100644 --- a/app/routes.ts +++ b/app/routes.ts @@ -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", [ diff --git a/e2e/helpers/factories.ts b/e2e/helpers/factories.ts index f5358b52e..1c940ca21 100644 --- a/e2e/helpers/factories.ts +++ b/e2e/helpers/factories.ts @@ -35,6 +35,9 @@ export async function loadFactories(parallelIndex: number) { ApiTokenFactory: await import("~/db/seed/factories/ApiTokenFactory"), ArtFactory: await import("~/db/seed/factories/ArtFactory"), AssociationFactory: await import("~/db/seed/factories/AssociationFactory"), + AvailabilityWeekFactory: await import( + "~/db/seed/factories/AvailabilityWeekFactory" + ), BadgeFactory: await import("~/db/seed/factories/BadgeFactory"), BuildFactory: await import("~/db/seed/factories/BuildFactory"), CalendarEventFactory: await import( diff --git a/e2e/pages/team/team-page.ts b/e2e/pages/team/team-page.ts index a18200856..f312da7dc 100644 --- a/e2e/pages/team/team-page.ts +++ b/e2e/pages/team/team-page.ts @@ -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(); } diff --git a/e2e/pages/team/team-schedule-page.ts b/e2e/pages/team/team-schedule-page.ts new file mode 100644 index 000000000..bfa9c2dd5 --- /dev/null +++ b/e2e/pages/team/team-schedule-page.ts @@ -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}`); + } +} diff --git a/e2e/team.spec.ts b/e2e/team.spec.ts index 71cb87674..8011048d8 100644 --- a/e2e/team.spec.ts +++ b/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: "/", + }, + ]); +} diff --git a/locales/da/schedule.json b/locales/da/schedule.json index bbb140d5c..2ae2b9566 100644 --- a/locales/da/schedule.json +++ b/locales/da/schedule.json @@ -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": "" } diff --git a/locales/da/team.json b/locales/da/team.json index 809871de6..b7b7de31e 100644 --- a/locales/da/team.json +++ b/locales/da/team.json @@ -14,6 +14,7 @@ "actionButtons.leaveTeam.confirm": "Forlad hold", "actionButtons.editTeam": "Rediger hold", "actionButtons.manageRoster": "", + "actionButtons.schedule": "", "actionButtons.deleteTeam": "Slet hold", "actionButtons.deleteTeam.profilePicture": "", "actionButtons.deleteTeam.banner": "", diff --git a/locales/de/schedule.json b/locales/de/schedule.json index bbb140d5c..2ae2b9566 100644 --- a/locales/de/schedule.json +++ b/locales/de/schedule.json @@ -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": "" } diff --git a/locales/de/team.json b/locales/de/team.json index eb9f304a8..888a848ab 100644 --- a/locales/de/team.json +++ b/locales/de/team.json @@ -14,6 +14,7 @@ "actionButtons.leaveTeam.confirm": "Verlassen", "actionButtons.editTeam": "Team bearbeiten", "actionButtons.manageRoster": "", + "actionButtons.schedule": "", "actionButtons.deleteTeam": "Team löschen", "actionButtons.deleteTeam.profilePicture": "", "actionButtons.deleteTeam.banner": "", diff --git a/locales/en/schedule.json b/locales/en/schedule.json index f930b1bff..3bef58743 100644 --- a/locales/en/schedule.json +++ b/locales/en/schedule.json @@ -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}})" } diff --git a/locales/en/team.json b/locales/en/team.json index 05904fc34..f3e3673d3 100644 --- a/locales/en/team.json +++ b/locales/en/team.json @@ -14,6 +14,7 @@ "actionButtons.leaveTeam.confirm": "Leave", "actionButtons.editTeam": "Edit Team", "actionButtons.manageRoster": "Manage Members", + "actionButtons.schedule": "Schedule", "actionButtons.deleteTeam": "Delete Team", "actionButtons.deleteTeam.profilePicture": "Remove Profile Picture", "actionButtons.deleteTeam.banner": "Remove Banner", diff --git a/locales/es-ES/schedule.json b/locales/es-ES/schedule.json index bbb140d5c..2ae2b9566 100644 --- a/locales/es-ES/schedule.json +++ b/locales/es-ES/schedule.json @@ -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": "" } diff --git a/locales/es-ES/team.json b/locales/es-ES/team.json index 4ccfbf1da..84c50023e 100644 --- a/locales/es-ES/team.json +++ b/locales/es-ES/team.json @@ -14,6 +14,7 @@ "actionButtons.leaveTeam.confirm": "Abandonar", "actionButtons.editTeam": "Editar equipo", "actionButtons.manageRoster": "Gestionar miembros", + "actionButtons.schedule": "", "actionButtons.deleteTeam": "Eliminar equipo", "actionButtons.deleteTeam.profilePicture": "Eliminar foto de perfil", "actionButtons.deleteTeam.banner": "Eliminar banner", diff --git a/locales/es-US/schedule.json b/locales/es-US/schedule.json index bbb140d5c..2ae2b9566 100644 --- a/locales/es-US/schedule.json +++ b/locales/es-US/schedule.json @@ -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": "" } diff --git a/locales/es-US/team.json b/locales/es-US/team.json index f03a74656..4528de9d9 100644 --- a/locales/es-US/team.json +++ b/locales/es-US/team.json @@ -14,6 +14,7 @@ "actionButtons.leaveTeam.confirm": "Abandonar", "actionButtons.editTeam": "Editar Equipo", "actionButtons.manageRoster": "Gestionar miembros", + "actionButtons.schedule": "", "actionButtons.deleteTeam": "Eliminar Equipo", "actionButtons.deleteTeam.profilePicture": "Eliminar foto de perfil", "actionButtons.deleteTeam.banner": "Eliminar banner", diff --git a/locales/fr-CA/schedule.json b/locales/fr-CA/schedule.json index bbb140d5c..2ae2b9566 100644 --- a/locales/fr-CA/schedule.json +++ b/locales/fr-CA/schedule.json @@ -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": "" } diff --git a/locales/fr-CA/team.json b/locales/fr-CA/team.json index 2e6bdab4d..41530bc02 100644 --- a/locales/fr-CA/team.json +++ b/locales/fr-CA/team.json @@ -14,6 +14,7 @@ "actionButtons.leaveTeam.confirm": "Quitter", "actionButtons.editTeam": "Modifier l'équipe", "actionButtons.manageRoster": "", + "actionButtons.schedule": "", "actionButtons.deleteTeam": "Supprimer l'équipe", "actionButtons.deleteTeam.profilePicture": "", "actionButtons.deleteTeam.banner": "", diff --git a/locales/fr-EU/schedule.json b/locales/fr-EU/schedule.json index bbb140d5c..2ae2b9566 100644 --- a/locales/fr-EU/schedule.json +++ b/locales/fr-EU/schedule.json @@ -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": "" } diff --git a/locales/fr-EU/team.json b/locales/fr-EU/team.json index 0e64ac75a..a240a8335 100644 --- a/locales/fr-EU/team.json +++ b/locales/fr-EU/team.json @@ -14,6 +14,7 @@ "actionButtons.leaveTeam.confirm": "Quitter", "actionButtons.editTeam": "Modifier l'équipe", "actionButtons.manageRoster": "", + "actionButtons.schedule": "", "actionButtons.deleteTeam": "Supprimer l'équipe", "actionButtons.deleteTeam.profilePicture": "", "actionButtons.deleteTeam.banner": "", diff --git a/locales/he/schedule.json b/locales/he/schedule.json index bbb140d5c..2ae2b9566 100644 --- a/locales/he/schedule.json +++ b/locales/he/schedule.json @@ -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": "" } diff --git a/locales/he/team.json b/locales/he/team.json index be7f271ae..327171e6e 100644 --- a/locales/he/team.json +++ b/locales/he/team.json @@ -14,6 +14,7 @@ "actionButtons.leaveTeam.confirm": "לעזוב", "actionButtons.editTeam": "עריכת צוות", "actionButtons.manageRoster": "", + "actionButtons.schedule": "", "actionButtons.deleteTeam": "מחיקת צוות", "actionButtons.deleteTeam.profilePicture": "", "actionButtons.deleteTeam.banner": "", diff --git a/locales/it/schedule.json b/locales/it/schedule.json index bbb140d5c..2ae2b9566 100644 --- a/locales/it/schedule.json +++ b/locales/it/schedule.json @@ -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": "" } diff --git a/locales/it/team.json b/locales/it/team.json index bdebb8191..a8d6605a7 100644 --- a/locales/it/team.json +++ b/locales/it/team.json @@ -14,6 +14,7 @@ "actionButtons.leaveTeam.confirm": "Lascia", "actionButtons.editTeam": "Modifica team", "actionButtons.manageRoster": "", + "actionButtons.schedule": "", "actionButtons.deleteTeam": "Delete team", "actionButtons.deleteTeam.profilePicture": "", "actionButtons.deleteTeam.banner": "", diff --git a/locales/ja/schedule.json b/locales/ja/schedule.json index bbb140d5c..2ae2b9566 100644 --- a/locales/ja/schedule.json +++ b/locales/ja/schedule.json @@ -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": "" } diff --git a/locales/ja/team.json b/locales/ja/team.json index 967acf2cb..15a08796d 100644 --- a/locales/ja/team.json +++ b/locales/ja/team.json @@ -14,6 +14,7 @@ "actionButtons.leaveTeam.confirm": "チームを抜ける", "actionButtons.editTeam": "チームを編集", "actionButtons.manageRoster": "", + "actionButtons.schedule": "", "actionButtons.deleteTeam": "チームを削除", "actionButtons.deleteTeam.profilePicture": "", "actionButtons.deleteTeam.banner": "", diff --git a/locales/ko/schedule.json b/locales/ko/schedule.json index bbb140d5c..2ae2b9566 100644 --- a/locales/ko/schedule.json +++ b/locales/ko/schedule.json @@ -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": "" } diff --git a/locales/ko/team.json b/locales/ko/team.json index 7e13669ec..59642bfe4 100644 --- a/locales/ko/team.json +++ b/locales/ko/team.json @@ -14,6 +14,7 @@ "actionButtons.leaveTeam.confirm": "", "actionButtons.editTeam": "", "actionButtons.manageRoster": "", + "actionButtons.schedule": "", "actionButtons.deleteTeam": "", "actionButtons.deleteTeam.profilePicture": "", "actionButtons.deleteTeam.banner": "", diff --git a/locales/nl/schedule.json b/locales/nl/schedule.json index bbb140d5c..2ae2b9566 100644 --- a/locales/nl/schedule.json +++ b/locales/nl/schedule.json @@ -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": "" } diff --git a/locales/nl/team.json b/locales/nl/team.json index 7e13669ec..59642bfe4 100644 --- a/locales/nl/team.json +++ b/locales/nl/team.json @@ -14,6 +14,7 @@ "actionButtons.leaveTeam.confirm": "", "actionButtons.editTeam": "", "actionButtons.manageRoster": "", + "actionButtons.schedule": "", "actionButtons.deleteTeam": "", "actionButtons.deleteTeam.profilePicture": "", "actionButtons.deleteTeam.banner": "", diff --git a/locales/pl/schedule.json b/locales/pl/schedule.json index bbb140d5c..2ae2b9566 100644 --- a/locales/pl/schedule.json +++ b/locales/pl/schedule.json @@ -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": "" } diff --git a/locales/pl/team.json b/locales/pl/team.json index 4fb714e1d..b1a20f22c 100644 --- a/locales/pl/team.json +++ b/locales/pl/team.json @@ -14,6 +14,7 @@ "actionButtons.leaveTeam.confirm": "Opuść", "actionButtons.editTeam": "Edytuj Drużynę", "actionButtons.manageRoster": "", + "actionButtons.schedule": "", "actionButtons.deleteTeam": "Usuń drużynę", "actionButtons.deleteTeam.profilePicture": "", "actionButtons.deleteTeam.banner": "", diff --git a/locales/pt-BR/schedule.json b/locales/pt-BR/schedule.json index bbb140d5c..2ae2b9566 100644 --- a/locales/pt-BR/schedule.json +++ b/locales/pt-BR/schedule.json @@ -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": "" } diff --git a/locales/pt-BR/team.json b/locales/pt-BR/team.json index e33749aec..09c1f38be 100644 --- a/locales/pt-BR/team.json +++ b/locales/pt-BR/team.json @@ -14,6 +14,7 @@ "actionButtons.leaveTeam.confirm": "Sair", "actionButtons.editTeam": "Editar Time", "actionButtons.manageRoster": "", + "actionButtons.schedule": "", "actionButtons.deleteTeam": "Apagar Time", "actionButtons.deleteTeam.profilePicture": "", "actionButtons.deleteTeam.banner": "", diff --git a/locales/ru/schedule.json b/locales/ru/schedule.json index bbb140d5c..2ae2b9566 100644 --- a/locales/ru/schedule.json +++ b/locales/ru/schedule.json @@ -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": "" } diff --git a/locales/ru/team.json b/locales/ru/team.json index b3e3f031a..d8a6cddf4 100644 --- a/locales/ru/team.json +++ b/locales/ru/team.json @@ -14,6 +14,7 @@ "actionButtons.leaveTeam.confirm": "Покинуть", "actionButtons.editTeam": "Редактировать команду", "actionButtons.manageRoster": "", + "actionButtons.schedule": "", "actionButtons.deleteTeam": "Удалить команду", "actionButtons.deleteTeam.profilePicture": "", "actionButtons.deleteTeam.banner": "", diff --git a/locales/zh/schedule.json b/locales/zh/schedule.json index bbb140d5c..2ae2b9566 100644 --- a/locales/zh/schedule.json +++ b/locales/zh/schedule.json @@ -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": "" } diff --git a/locales/zh/team.json b/locales/zh/team.json index 995cb0e57..29dc92f92 100644 --- a/locales/zh/team.json +++ b/locales/zh/team.json @@ -14,6 +14,7 @@ "actionButtons.leaveTeam.confirm": "退出", "actionButtons.editTeam": "编辑队伍", "actionButtons.manageRoster": "管理队员", + "actionButtons.schedule": "", "actionButtons.deleteTeam": "删除队伍", "actionButtons.deleteTeam.profilePicture": "移除队徽", "actionButtons.deleteTeam.banner": "移除横幅",