mirror of
https://github.com/Sendouc/sendou.ink.git
synced 2026-09-09 04:36:02 -05:00
Team schedule heatmap
This commit is contained in:
@@ -6,6 +6,7 @@ describe("scheduleWeekSearchParams", () => {
|
||||
test("round-trips", () => {
|
||||
assertRoundTrips(scheduleWeekSearchParams, {
|
||||
week: ["current", "next"],
|
||||
view: ["heatmap", "grid"],
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -7,4 +7,8 @@ export const scheduleWeekSearchParams = SearchParams.define({
|
||||
default: "current",
|
||||
loader: false,
|
||||
}),
|
||||
view: SP.param(v.picklist(["heatmap", "grid"]), {
|
||||
default: "heatmap",
|
||||
loader: false,
|
||||
}),
|
||||
});
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
.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);
|
||||
}
|
||||
}
|
||||
|
||||
.windowList {
|
||||
display: inline-flex;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--s-1) var(--s-2);
|
||||
}
|
||||
|
||||
.window {
|
||||
white-space: nowrap;
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
import clsx from "clsx";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useDateTimeFormat } from "~/hooks/intl/useDateTimeFormat";
|
||||
import type { PlayableWindowTier, TimeRange } from "../availability-types";
|
||||
import styles from "./PlayableWindowsSummary.module.css";
|
||||
|
||||
type SummaryWindow = TimeRange & { tier: PlayableWindowTier };
|
||||
|
||||
/** The playable-window tier as a dot: filled for `FULL`, outlined for one short. */
|
||||
export function TierDot({
|
||||
full,
|
||||
className,
|
||||
testId,
|
||||
}: {
|
||||
full: boolean;
|
||||
className?: string;
|
||||
testId?: string;
|
||||
}) {
|
||||
return (
|
||||
<span
|
||||
className={clsx(styles.tierDot, className, {
|
||||
[styles.tierDotFull]: full,
|
||||
})}
|
||||
data-testid={testId}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
/** The week's playable windows as one line per tier, shared by the schedule views. */
|
||||
export function PlayableWindowsSummary({
|
||||
windows,
|
||||
minPlayers,
|
||||
}: {
|
||||
windows: Array<SummaryWindow>;
|
||||
minPlayers: number;
|
||||
}) {
|
||||
const { t } = useTranslation(["schedule"]);
|
||||
|
||||
const fullWindows = windows.filter((window) => window.tier === "FULL");
|
||||
const oneShortWindows = windows.filter(
|
||||
(window) => window.tier === "ONE_SHORT",
|
||||
);
|
||||
|
||||
return (
|
||||
<div className={styles.summary} data-testid="schedule-summary">
|
||||
<div className={styles.summaryRow}>
|
||||
<TierDot full />
|
||||
<span className={styles.summaryLabel}>
|
||||
{t("schedule:team.canPlay", { players: minPlayers })}
|
||||
</span>
|
||||
<WindowList windows={fullWindows} />
|
||||
</div>
|
||||
{minPlayers > 1 && oneShortWindows.length > 0 ? (
|
||||
<div className={styles.summaryRow}>
|
||||
<TierDot full={false} />
|
||||
<span className={styles.summaryLabel}>
|
||||
{t("schedule:team.withSub", { players: minPlayers - 1 })}
|
||||
</span>
|
||||
<WindowList windows={oneShortWindows} />
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function WindowList({ windows }: { windows: Array<SummaryWindow> }) {
|
||||
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>
|
||||
);
|
||||
}
|
||||
208
app/features/availability/components/ScheduleHeatmap.module.css
Normal file
208
app/features/availability/components/ScheduleHeatmap.module.css
Normal file
@@ -0,0 +1,208 @@
|
||||
.heatmap {
|
||||
/* how many are free at once, as steps between the track and the strongest "can play" green */
|
||||
--heat-1: color-mix(
|
||||
in oklab,
|
||||
var(--color-success-high) 20%,
|
||||
var(--color-bg-high)
|
||||
);
|
||||
--heat-2: color-mix(
|
||||
in oklab,
|
||||
var(--color-success-high) 40%,
|
||||
var(--color-bg-high)
|
||||
);
|
||||
--heat-3: color-mix(
|
||||
in oklab,
|
||||
var(--color-success-high) 60%,
|
||||
var(--color-bg-high)
|
||||
);
|
||||
--heat-4: color-mix(
|
||||
in oklab,
|
||||
var(--color-success-high) 80%,
|
||||
var(--color-bg-high)
|
||||
);
|
||||
--heat-5: var(--color-success-high);
|
||||
}
|
||||
|
||||
.chips {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--s-1-5);
|
||||
}
|
||||
|
||||
/* the chip-radio look, as a multi-select: solid fills, no borders */
|
||||
.chip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: var(--s-1-5);
|
||||
padding: 0 var(--s-2) 0 var(--s-1);
|
||||
height: var(--selector-size);
|
||||
background-color: var(--color-bg-higher);
|
||||
border: none;
|
||||
border-radius: var(--radius-selector);
|
||||
font-size: var(--font-xs);
|
||||
font-weight: var(--weight-semi);
|
||||
color: var(--color-text);
|
||||
cursor: pointer;
|
||||
transition: background-color 0.15s;
|
||||
|
||||
&[aria-pressed="true"] {
|
||||
background-color: var(--color-text-accent);
|
||||
color: var(--color-text-inverse);
|
||||
}
|
||||
|
||||
&:focus-visible {
|
||||
outline: var(--focus-ring);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
}
|
||||
|
||||
.chipUnknown {
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
.dotSlot {
|
||||
display: inline-flex;
|
||||
width: 0.6rem;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.cellRow {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(var(--cells), 1fr);
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.cell {
|
||||
height: 1.5rem;
|
||||
border-radius: 3px;
|
||||
background-color: var(--color-bg-high);
|
||||
|
||||
&:hover {
|
||||
outline: 2px solid var(--color-border-high);
|
||||
outline-offset: 1px;
|
||||
}
|
||||
}
|
||||
|
||||
.count1 {
|
||||
background-color: var(--heat-1);
|
||||
}
|
||||
|
||||
.count2 {
|
||||
background-color: var(--heat-2);
|
||||
}
|
||||
|
||||
.count3 {
|
||||
background-color: var(--heat-3);
|
||||
}
|
||||
|
||||
.count4 {
|
||||
background-color: var(--heat-4);
|
||||
}
|
||||
|
||||
.count5 {
|
||||
background-color: var(--heat-5);
|
||||
}
|
||||
|
||||
/* framed like the popover element: page background inside the standard border */
|
||||
.tooltip {
|
||||
position: fixed;
|
||||
z-index: 10;
|
||||
transform: translate(-50%, calc(-100% - 6px));
|
||||
max-width: 16rem;
|
||||
padding: var(--s-2);
|
||||
background-color: var(--color-bg);
|
||||
border: var(--border-style);
|
||||
border-radius: var(--radius-box);
|
||||
font-size: var(--font-xs);
|
||||
font-weight: var(--weight-semi);
|
||||
pointer-events: none;
|
||||
|
||||
&.tooltipBelow {
|
||||
transform: translate(-50%, 6px);
|
||||
}
|
||||
}
|
||||
|
||||
.tooltipTime {
|
||||
font-weight: var(--weight-semi);
|
||||
}
|
||||
|
||||
.tooltipCount {
|
||||
color: var(--color-text-high);
|
||||
margin-block-end: var(--s-1);
|
||||
}
|
||||
|
||||
.tooltipMembers {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--s-0-5);
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
.tooltipDot {
|
||||
/* whole pixels: a fractional size rounds width and height differently and the circle turns oval */
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
background-color: var(--color-success);
|
||||
flex-shrink: 0;
|
||||
|
||||
&.tooltipDotUnknown {
|
||||
background-color: transparent;
|
||||
border: 1px dashed currentColor;
|
||||
}
|
||||
}
|
||||
|
||||
.tooltipMember {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--s-1-5);
|
||||
white-space: nowrap;
|
||||
|
||||
&.tooltipMemberOff {
|
||||
color: var(--color-text-high);
|
||||
|
||||
& .tooltipDot {
|
||||
background-color: transparent;
|
||||
border: 1px solid currentColor;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.listChip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: var(--s-1);
|
||||
padding: var(--s-0-5) var(--s-2);
|
||||
background-color: var(--color-bg-high);
|
||||
border-radius: var(--radius-full);
|
||||
font-size: var(--font-xs);
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
.legend {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: var(--s-2);
|
||||
font-size: var(--font-3xs);
|
||||
color: var(--color-text-high);
|
||||
}
|
||||
|
||||
.legendItem {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--s-1);
|
||||
}
|
||||
|
||||
.legendSwatch {
|
||||
width: 14px;
|
||||
height: 10px;
|
||||
border-radius: 2px;
|
||||
}
|
||||
|
||||
.unreported {
|
||||
font-size: var(--font-2xs);
|
||||
color: var(--color-text-high);
|
||||
}
|
||||
408
app/features/availability/components/ScheduleHeatmap.tsx
Normal file
408
app/features/availability/components/ScheduleHeatmap.tsx
Normal file
@@ -0,0 +1,408 @@
|
||||
import clsx from "clsx";
|
||||
import * as React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import * as R from "remeda";
|
||||
import { Avatar } from "~/components/Avatar";
|
||||
import type { TeamLoaderData } from "~/features/team/loaders/t.$customUrl.server";
|
||||
import { getMemberRoleType } from "~/features/team/team-utils";
|
||||
import { useDateTimeFormat } from "~/hooks/intl/useDateTimeFormat";
|
||||
import { databaseTimestampToDate } from "~/utils/dates";
|
||||
import { AVAILABILITY } from "../availability-constants";
|
||||
import type { DayTimeRange, TimeRange } from "../availability-types";
|
||||
import * as Availability from "../core/Availability";
|
||||
import type { TeamScheduleLoaderData } from "../loaders/t.$customUrl.schedule.server";
|
||||
import { PlayableWindowsSummary, TierDot } from "./PlayableWindowsSummary";
|
||||
import { useRangeText } from "./ScheduleDayCell";
|
||||
import styles from "./ScheduleHeatmap.module.css";
|
||||
import { ClockAxis, useClockWindow } from "./ScheduleTracks";
|
||||
import trackStyles from "./ScheduleTracks.module.css";
|
||||
|
||||
const MINUTE_IN_SECONDS = 60;
|
||||
/** Counts past this all read as the strongest shade. */
|
||||
const MAX_SHADE_COUNT = 5;
|
||||
/** One block per hour: a member counts in it when free for the whole hour. */
|
||||
const CELL_MINUTES = 60;
|
||||
|
||||
type WeekData = NonNullable<TeamScheduleLoaderData["weeks"]>[number];
|
||||
type TeamMember = TeamLoaderData["team"]["members"][number];
|
||||
type MemberRow = WeekData["members"][number] & { member: TeamMember };
|
||||
|
||||
/**
|
||||
* The week as day tracks shaded by how many of the counted members are free at once. Chips
|
||||
* pick who counts, so pairing off for a duo or pulling a sub in is the same view with fewer
|
||||
* or different members; the playable windows below follow the pick.
|
||||
*/
|
||||
export function ScheduleHeatmap({
|
||||
week,
|
||||
members,
|
||||
}: {
|
||||
week: WeekData;
|
||||
members: Array<TeamMember>;
|
||||
}) {
|
||||
const { t } = useTranslation(["schedule"]);
|
||||
const rangeText = useRangeText();
|
||||
const { formatter: dayFormatter } = useDateTimeFormat({
|
||||
weekday: "short",
|
||||
day: "numeric",
|
||||
});
|
||||
|
||||
const rows: Array<MemberRow> = week.members.flatMap((row) => {
|
||||
const member = members.find((candidate) => candidate.id === row.userId);
|
||||
|
||||
return member ? [{ ...row, member }] : [];
|
||||
});
|
||||
const orderedRows = [
|
||||
...rows.filter(({ member }) => getMemberRoleType(member) !== "OTHER"),
|
||||
...rows.filter(({ member }) => getMemberRoleType(member) === "OTHER"),
|
||||
];
|
||||
|
||||
const [selectedIds, setSelectedIds] = React.useState<Array<number>>(() =>
|
||||
rows
|
||||
.filter(({ member }) => getMemberRoleType(member) !== "OTHER")
|
||||
.map((row) => row.userId),
|
||||
);
|
||||
const selectedRows = orderedRows.filter((row) =>
|
||||
selectedIds.includes(row.userId),
|
||||
);
|
||||
const [hoveredCell, setHoveredCell] = React.useState<HoveredCell | null>(
|
||||
null,
|
||||
);
|
||||
const minPlayers = Math.min(
|
||||
AVAILABILITY.DEFAULT_MIN_PLAYERS,
|
||||
selectedRows.length,
|
||||
);
|
||||
|
||||
const toDayRange = (
|
||||
range: TimeRange,
|
||||
day: WeekData["days"][number],
|
||||
): DayTimeRange => ({
|
||||
start: (range.startsAt - day.startsAt) / MINUTE_IN_SECONDS,
|
||||
end: (range.endsAt - day.startsAt) / MINUTE_IN_SECONDS,
|
||||
});
|
||||
|
||||
const clockWindow = useClockWindow({
|
||||
fitTo: rows.flatMap((row) =>
|
||||
row.days.flatMap((day, dayIndex) =>
|
||||
day.ranges.map((range) => toDayRange(range, week.days[dayIndex])),
|
||||
),
|
||||
),
|
||||
});
|
||||
|
||||
const cellStarts = R.range(
|
||||
0,
|
||||
(clockWindow.trackEnd - clockWindow.trackStart) / CELL_MINUTES,
|
||||
).map((index) => clockWindow.trackStart + index * CELL_MINUTES);
|
||||
|
||||
const dayViews = week.days.map((day, dayIndex) => ({
|
||||
day,
|
||||
dayIndex,
|
||||
cells: cellStarts.map((startMinutes) => {
|
||||
const startsAt = day.startsAt + startMinutes * MINUTE_IN_SECONDS;
|
||||
const endsAt = startsAt + CELL_MINUTES * MINUTE_IN_SECONDS;
|
||||
|
||||
return {
|
||||
startsAt,
|
||||
endsAt,
|
||||
userIds: selectedRows
|
||||
.filter((row) =>
|
||||
row.days[dayIndex].ranges.some(
|
||||
(range) => range.startsAt <= startsAt && range.endsAt >= endsAt,
|
||||
),
|
||||
)
|
||||
.map((row) => row.userId),
|
||||
};
|
||||
}),
|
||||
segments: Availability.availabilitySegments(
|
||||
selectedRows.map((row) => ({
|
||||
userId: row.userId,
|
||||
ranges: row.days[dayIndex].ranges,
|
||||
})),
|
||||
).filter((segment) => segment.userIds.length > 0),
|
||||
}));
|
||||
|
||||
const windows = Availability.playableWindows({
|
||||
members: selectedRows.map((row) => ({
|
||||
userId: row.userId,
|
||||
ranges: row.days.flatMap((day) => day.ranges),
|
||||
})),
|
||||
minPlayers,
|
||||
});
|
||||
const dayTier = (dayIndex: number) => {
|
||||
const tiers = windows
|
||||
.filter((window) => dayIndexOf(window.startsAt, week.days) === dayIndex)
|
||||
.map((window) => window.tier);
|
||||
|
||||
if (tiers.includes("FULL")) return "FULL";
|
||||
if (tiers.includes("ONE_SHORT")) return "ONE_SHORT";
|
||||
return null;
|
||||
};
|
||||
|
||||
const segmentTitle = (
|
||||
segment: (typeof dayViews)[number]["segments"][number],
|
||||
) =>
|
||||
`${rangeText(segment)} · ${t("schedule:picker.free", {
|
||||
amount: segment.userIds.length,
|
||||
})} · ${segment.userIds
|
||||
.flatMap((userId) => {
|
||||
const username = rows.find((row) => row.userId === userId)?.member
|
||||
.username;
|
||||
|
||||
return username ? [username] : [];
|
||||
})
|
||||
.join(", ")}`;
|
||||
|
||||
const shadeClass = (count: number) =>
|
||||
styles[`count${Math.min(count, MAX_SHADE_COUNT)}`];
|
||||
|
||||
const unreportedNames = selectedRows
|
||||
.filter((row) => !row.reported)
|
||||
.map((row) => row.member.username);
|
||||
|
||||
// the list variant skips the test id so the hidden copy of a day never doubles it
|
||||
const dayLabel = (dayIndex: number, { withTestId = false } = {}) => {
|
||||
const tier = dayTier(dayIndex);
|
||||
|
||||
return (
|
||||
<div className={trackStyles.dayLabel}>
|
||||
<span className={styles.dotSlot}>
|
||||
{tier ? (
|
||||
<TierDot
|
||||
full={tier === "FULL"}
|
||||
testId={withTestId ? `schedule-day-dot-${dayIndex}` : undefined}
|
||||
/>
|
||||
) : null}
|
||||
</span>
|
||||
{dayFormatter.format(week.days[dayIndex].noonAt)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className={clsx("stack md", styles.heatmap)}
|
||||
data-testid="schedule-heatmap"
|
||||
>
|
||||
<div className={styles.chips}>
|
||||
{orderedRows.map((row) => (
|
||||
<MemberChip
|
||||
key={row.userId}
|
||||
row={row}
|
||||
selected={selectedIds.includes(row.userId)}
|
||||
onToggle={() =>
|
||||
setSelectedIds((ids) =>
|
||||
ids.includes(row.userId)
|
||||
? ids.filter((id) => id !== row.userId)
|
||||
: [...ids, row.userId],
|
||||
)
|
||||
}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
<div className={trackStyles.container}>
|
||||
<div className={trackStyles.tracks}>
|
||||
<ClockAxis
|
||||
clockWindow={clockWindow}
|
||||
dayStartsAt={databaseTimestampToDate(week.days[0].startsAt)}
|
||||
/>
|
||||
{dayViews.map(({ day, dayIndex, cells }) => (
|
||||
<React.Fragment key={day.date}>
|
||||
{dayLabel(dayIndex, { withTestId: true })}
|
||||
{/** biome-ignore lint/a11y/noStaticElementInteractions: hover-only detail, the list view and summary carry the same info */}
|
||||
<div
|
||||
className={styles.cellRow}
|
||||
style={{ "--cells": cells.length } as React.CSSProperties}
|
||||
onMouseLeave={() => setHoveredCell(null)}
|
||||
>
|
||||
{cells.map((cell) => (
|
||||
// biome-ignore lint/a11y/noStaticElementInteractions: hover-only detail, the list view and summary carry the same info
|
||||
<div
|
||||
key={cell.startsAt}
|
||||
className={clsx(
|
||||
styles.cell,
|
||||
cell.userIds.length > 0
|
||||
? shadeClass(cell.userIds.length)
|
||||
: undefined,
|
||||
)}
|
||||
onMouseEnter={(event) =>
|
||||
setHoveredCell({
|
||||
cell,
|
||||
anchor: tooltipAnchor(event.currentTarget),
|
||||
})
|
||||
}
|
||||
data-testid="schedule-heatmap-cell"
|
||||
data-count={cell.userIds.length}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
{/* keeps the day rows in step with the axis row's "later" expander */}
|
||||
<div />
|
||||
</React.Fragment>
|
||||
))}
|
||||
</div>
|
||||
<div className={trackStyles.list}>
|
||||
{dayViews.map(({ day, dayIndex, segments }) => (
|
||||
<div key={day.date} className={trackStyles.listDay}>
|
||||
<div className={trackStyles.listDayHeader}>
|
||||
{dayLabel(dayIndex)}
|
||||
</div>
|
||||
{segments.length === 0 ? (
|
||||
<span className="text-lighter text-xs">—</span>
|
||||
) : (
|
||||
<div className={trackStyles.listDayBody}>
|
||||
{segments.map((segment) => (
|
||||
<span
|
||||
key={segment.startsAt}
|
||||
className={styles.listChip}
|
||||
title={segmentTitle(segment)}
|
||||
>
|
||||
<span
|
||||
className={clsx(
|
||||
styles.legendSwatch,
|
||||
shadeClass(segment.userIds.length),
|
||||
)}
|
||||
/>
|
||||
{rangeText(segment)} ·{" "}
|
||||
{t("schedule:picker.free", {
|
||||
amount: segment.userIds.length,
|
||||
})}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
{hoveredCell ? (
|
||||
<div
|
||||
className={clsx(styles.tooltip, {
|
||||
[styles.tooltipBelow]: hoveredCell.anchor.below,
|
||||
})}
|
||||
style={{ left: hoveredCell.anchor.x, top: hoveredCell.anchor.y }}
|
||||
data-testid="schedule-heatmap-tooltip"
|
||||
>
|
||||
<div className={styles.tooltipTime}>
|
||||
{rangeText(hoveredCell.cell)}
|
||||
</div>
|
||||
<div className={styles.tooltipCount}>
|
||||
{t("schedule:scrims.availableOfRoster", {
|
||||
amount: hoveredCell.cell.userIds.length,
|
||||
total: selectedRows.length,
|
||||
})}
|
||||
</div>
|
||||
<ul className={styles.tooltipMembers}>
|
||||
{selectedRows.map((row) => (
|
||||
<li
|
||||
key={row.userId}
|
||||
className={clsx(styles.tooltipMember, {
|
||||
[styles.tooltipMemberOff]: !hoveredCell.cell.userIds.includes(
|
||||
row.userId,
|
||||
),
|
||||
})}
|
||||
>
|
||||
<span
|
||||
className={clsx(styles.tooltipDot, {
|
||||
[styles.tooltipDotUnknown]: !row.reported,
|
||||
})}
|
||||
/>
|
||||
{row.member.username}
|
||||
{!row.reported ? ` · ${t("schedule:team.noSchedule")}` : null}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
) : null}
|
||||
{selectedRows.length > 0 ? (
|
||||
<div className={styles.legend}>
|
||||
{R.range(1, Math.min(selectedRows.length, MAX_SHADE_COUNT) + 1).map(
|
||||
(count) => (
|
||||
<span key={count} className={styles.legendItem}>
|
||||
<span
|
||||
className={clsx(styles.legendSwatch, shadeClass(count))}
|
||||
/>
|
||||
{count === MAX_SHADE_COUNT &&
|
||||
selectedRows.length > MAX_SHADE_COUNT
|
||||
? `${count}+`
|
||||
: count}
|
||||
</span>
|
||||
),
|
||||
)}
|
||||
<span>{t("schedule:team.legendFreeAtOnce")}</span>
|
||||
</div>
|
||||
) : null}
|
||||
{unreportedNames.length > 0 ? (
|
||||
<div
|
||||
className={styles.unreported}
|
||||
data-testid="schedule-heatmap-unreported"
|
||||
>
|
||||
{t("schedule:picker.noSchedule", {
|
||||
users: unreportedNames.join(", "),
|
||||
})}
|
||||
</div>
|
||||
) : null}
|
||||
<PlayableWindowsSummary windows={windows} minPlayers={minPlayers} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function MemberChip({
|
||||
row,
|
||||
selected,
|
||||
onToggle,
|
||||
}: {
|
||||
row: MemberRow;
|
||||
selected: boolean;
|
||||
onToggle: () => void;
|
||||
}) {
|
||||
const { t } = useTranslation(["schedule"]);
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
className={styles.chip}
|
||||
aria-pressed={selected}
|
||||
onClick={onToggle}
|
||||
data-testid={`heatmap-member-${row.userId}`}
|
||||
>
|
||||
<Avatar user={row.member} size="xxxs" />
|
||||
{row.member.username}
|
||||
{!row.reported ? (
|
||||
<span
|
||||
className={styles.chipUnknown}
|
||||
title={t("schedule:team.noSchedule")}
|
||||
>
|
||||
?
|
||||
</span>
|
||||
) : null}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
interface HoveredCell {
|
||||
cell: { startsAt: number; endsAt: number; userIds: Array<number> };
|
||||
anchor: { x: number; y: number; below: boolean };
|
||||
}
|
||||
|
||||
/** Under this far from the viewport top the tooltip opens below the cell instead of above. */
|
||||
const TOOLTIP_FLIP_THRESHOLD_PX = 160;
|
||||
/** Half the tooltip's max width, so a clamped anchor keeps it on screen. */
|
||||
const TOOLTIP_EDGE_PX = 130;
|
||||
|
||||
function tooltipAnchor(element: HTMLElement): HoveredCell["anchor"] {
|
||||
const rect = element.getBoundingClientRect();
|
||||
const below = rect.top < TOOLTIP_FLIP_THRESHOLD_PX;
|
||||
|
||||
return {
|
||||
x: R.clamp(rect.left + rect.width / 2, {
|
||||
min: TOOLTIP_EDGE_PX,
|
||||
max: window.innerWidth - TOOLTIP_EDGE_PX,
|
||||
}),
|
||||
y: below ? rect.bottom : rect.top,
|
||||
below,
|
||||
};
|
||||
}
|
||||
|
||||
/** Index of the day a timestamp falls in, the last day whose midnight is not past it. */
|
||||
function dayIndexOf(timestamp: number, days: Array<{ startsAt: number }>) {
|
||||
return R.findLastIndex(days, (day) => day.startsAt <= timestamp);
|
||||
}
|
||||
@@ -638,6 +638,64 @@ describe("Availability.playableWindows", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("Availability.availabilitySegments", () => {
|
||||
const members = (
|
||||
ranges: Array<Array<[start: string, end: string, endDate?: string]>>,
|
||||
) =>
|
||||
ranges.map((memberRanges, index) => ({
|
||||
userId: index + 1,
|
||||
ranges: memberRanges.map(([start, end, endDate]) =>
|
||||
range("2026-08-24", start, end, endDate),
|
||||
),
|
||||
}));
|
||||
|
||||
test("returns nothing for no members", () => {
|
||||
expect(Availability.availabilitySegments([])).toEqual([]);
|
||||
});
|
||||
|
||||
test("splits overlapping members at every start and end with who is free throughout", () => {
|
||||
const segments = Availability.availabilitySegments(
|
||||
members([[["18:00", "22:00"]], [["19:00", "23:00"]]]),
|
||||
);
|
||||
|
||||
expect(segments).toEqual([
|
||||
{ ...range("2026-08-24", "18:00", "19:00"), userIds: [1] },
|
||||
{ ...range("2026-08-24", "19:00", "22:00"), userIds: [1, 2] },
|
||||
{ ...range("2026-08-24", "22:00", "23:00"), userIds: [2] },
|
||||
]);
|
||||
});
|
||||
|
||||
test("a gap between members comes out as a span with nobody free", () => {
|
||||
const segments = Availability.availabilitySegments(
|
||||
members([[["18:00", "19:00"]], [["20:00", "21:00"]]]),
|
||||
);
|
||||
|
||||
expect(segments).toEqual([
|
||||
{ ...range("2026-08-24", "18:00", "19:00"), userIds: [1] },
|
||||
{ ...range("2026-08-24", "19:00", "20:00"), userIds: [] },
|
||||
{ ...range("2026-08-24", "20:00", "21:00"), userIds: [2] },
|
||||
]);
|
||||
});
|
||||
|
||||
test("merges one member's touching ranges before splitting", () => {
|
||||
const segments = Availability.availabilitySegments(
|
||||
members([
|
||||
[
|
||||
["18:00", "20:00"],
|
||||
["20:00", "22:00"],
|
||||
],
|
||||
[["19:00", "21:00"]],
|
||||
]),
|
||||
);
|
||||
|
||||
expect(segments).toEqual([
|
||||
{ ...range("2026-08-24", "18:00", "19:00"), userIds: [1] },
|
||||
{ ...range("2026-08-24", "19:00", "21:00"), userIds: [1, 2] },
|
||||
{ ...range("2026-08-24", "21:00", "22:00"), userIds: [1] },
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Availability.snapMinutes", () => {
|
||||
test.each([
|
||||
[0, 0],
|
||||
|
||||
@@ -358,8 +358,11 @@ export function snapMinutes(
|
||||
return Math.round(minutes / step) * step;
|
||||
}
|
||||
|
||||
/** Splits the members' availability at every start/end into spans, each with the members free throughout. */
|
||||
function availabilitySegments(members: Array<MemberAvailability>) {
|
||||
/**
|
||||
* Splits the members' availability at every start/end into spans, each with the members free
|
||||
* throughout. Spans nobody is free in come out with an empty `userIds`.
|
||||
*/
|
||||
export function availabilitySegments(members: Array<MemberAvailability>) {
|
||||
const normalized = members.map((member) => ({
|
||||
userId: member.userId,
|
||||
ranges: normalize(member.ranges),
|
||||
|
||||
@@ -130,6 +130,12 @@ function weekView({
|
||||
|
||||
const days = ScheduleWeek.days(range, timezone).map((day) => ({
|
||||
...day,
|
||||
/** Local midnight starting the day, the zero the heatmap reads track minutes from. */
|
||||
startsAt: Availability.localToTimestamp({
|
||||
date: day.date,
|
||||
time: "00:00",
|
||||
timezone,
|
||||
}),
|
||||
windowTier: bestWindowTierOfDay({ date: day.date, windows, timezone }),
|
||||
}));
|
||||
|
||||
|
||||
@@ -84,54 +84,10 @@
|
||||
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;
|
||||
|
||||
@@ -1,6 +1,14 @@
|
||||
import clsx from "clsx";
|
||||
import { isSameDay } from "date-fns";
|
||||
import { CalendarClock, Flag, Pencil, Plus, Trash } from "lucide-react";
|
||||
import {
|
||||
CalendarClock,
|
||||
Flag,
|
||||
Flame,
|
||||
Pencil,
|
||||
Plus,
|
||||
Table,
|
||||
Trash,
|
||||
} from "lucide-react";
|
||||
import * as React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useLoaderData, useMatches } from "react-router";
|
||||
@@ -10,6 +18,12 @@ import { ActionButton } from "~/components/ActionButton";
|
||||
import { Alert } from "~/components/Alert";
|
||||
import { LinkButton, SendouButton } from "~/components/elements/Button";
|
||||
import { SendouDialog } from "~/components/elements/Dialog";
|
||||
import {
|
||||
SendouTab,
|
||||
SendouTabList,
|
||||
SendouTabPanel,
|
||||
SendouTabs,
|
||||
} from "~/components/elements/Tabs";
|
||||
import { FormMessage } from "~/components/FormMessage";
|
||||
import { UserLink } from "~/components/UserLink";
|
||||
import { TeamGoBackButton } from "~/features/team/components/TeamGoBackButton";
|
||||
@@ -32,7 +46,12 @@ import {
|
||||
teamScheduleActionSchema,
|
||||
} from "../availability-schemas";
|
||||
import { scheduleWeekSearchParams } from "../availability-search-params";
|
||||
import {
|
||||
PlayableWindowsSummary,
|
||||
TierDot,
|
||||
} from "../components/PlayableWindowsSummary";
|
||||
import { ScheduleDayCell } from "../components/ScheduleDayCell";
|
||||
import { ScheduleHeatmap } from "../components/ScheduleHeatmap";
|
||||
import { WeekToggle } from "../components/WeekToggle";
|
||||
import type { TeamScheduleLoaderData } from "../loaders/t.$customUrl.schedule.server";
|
||||
import { loader } from "../loaders/t.$customUrl.schedule.server";
|
||||
@@ -74,7 +93,10 @@ export default function TeamSchedulePage() {
|
||||
|
||||
function ScheduleWeeks({ weeks }: { weeks: Array<WeekData> }) {
|
||||
const { t } = useTranslation(["schedule"]);
|
||||
const [{ week }, setParams] = useSearchParamsTyped(scheduleWeekSearchParams);
|
||||
const members = useTeamMembers();
|
||||
const [{ week, view }, setParams] = useSearchParamsTyped(
|
||||
scheduleWeekSearchParams,
|
||||
);
|
||||
const { formatter: headingFormatter } = useDateTimeFormat({
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
@@ -110,8 +132,33 @@ function ScheduleWeeks({ weeks }: { weeks: Array<WeekData> }) {
|
||||
</div>
|
||||
</div>
|
||||
<TeamEvents week={shownWeek} />
|
||||
<ScheduleGrid week={shownWeek} />
|
||||
<PlayableWindowsSummary week={shownWeek} />
|
||||
<SendouTabs
|
||||
selectedKey={view}
|
||||
onSelectionChange={(key) =>
|
||||
setParams({ view: key === "grid" ? "grid" : "heatmap" })
|
||||
}
|
||||
>
|
||||
<SendouTabList>
|
||||
<SendouTab id="heatmap" icon={<Flame />}>
|
||||
{t("schedule:team.viewHeatmap")}
|
||||
</SendouTab>
|
||||
<SendouTab id="grid" icon={<Table />}>
|
||||
{t("schedule:team.viewGrid")}
|
||||
</SendouTab>
|
||||
</SendouTabList>
|
||||
<SendouTabPanel id="heatmap">
|
||||
<ScheduleHeatmap week={shownWeek} members={members} />
|
||||
</SendouTabPanel>
|
||||
<SendouTabPanel id="grid">
|
||||
<div className="stack md">
|
||||
<ScheduleGrid week={shownWeek} />
|
||||
<PlayableWindowsSummary
|
||||
windows={shownWeek.windows}
|
||||
minPlayers={shownWeek.minPlayers}
|
||||
/>
|
||||
</div>
|
||||
</SendouTabPanel>
|
||||
</SendouTabs>
|
||||
<WeekNotes week={shownWeek} />
|
||||
</div>
|
||||
);
|
||||
@@ -162,11 +209,10 @@ function ScheduleGrid({ week }: { week: WeekData }) {
|
||||
{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}`}
|
||||
<TierDot
|
||||
full={day.windowTier === "FULL"}
|
||||
className={styles.dayDot}
|
||||
testId={`schedule-day-dot-${dayIndex}`}
|
||||
/>
|
||||
) : null}
|
||||
{dayFormatter.format(day.noonAt)}
|
||||
@@ -213,63 +259,6 @@ function ScheduleCell({
|
||||
);
|
||||
}
|
||||
|
||||
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" });
|
||||
|
||||
@@ -1,5 +0,0 @@
|
||||
---
|
||||
navItem: calendar
|
||||
type: feature
|
||||
---
|
||||
The availability editor links to your team's schedule and the team schedule links back, both keeping the week you are looking at
|
||||
11
changelog/2026-09-05-team-schedule-heatmap.md
Normal file
11
changelog/2026-09-05-team-schedule-heatmap.md
Normal file
@@ -0,0 +1,11 @@
|
||||
---
|
||||
navItem: calendar
|
||||
type: feature
|
||||
---
|
||||
Team schedule heatmap view (new deault)
|
||||
|
||||
- Each day is a row of hour blocks shaded by how many members are free for that hour, so overlaps of any size stand out at a glance (not just the full-team ones)
|
||||
- Hover a block to see exactly who is free then
|
||||
- Pick who counts with the member chips: drop someone or pair down to two to find duo windows, or pull a coach or sub into the count
|
||||
- The old grid is still there behind the Grid tab
|
||||
- The availability editor links to your team's schedule and the team schedule links back, both keeping the week you are looking at
|
||||
@@ -10,6 +10,9 @@ export class TeamSchedulePage {
|
||||
this.page = page;
|
||||
this.locators = {
|
||||
grid: page.getByTestId("schedule-grid"),
|
||||
heatmap: page.getByTestId("schedule-heatmap"),
|
||||
heatmapTooltip: page.getByTestId("schedule-heatmap-tooltip"),
|
||||
heatmapUnreported: page.getByTestId("schedule-heatmap-unreported"),
|
||||
summary: page.getByTestId("schedule-summary"),
|
||||
hiddenMessage: page.getByTestId("schedule-hidden"),
|
||||
windows: page.getByTestId("schedule-window"),
|
||||
@@ -21,6 +24,7 @@ export class TeamSchedulePage {
|
||||
nextWeekToggle: page.locator(
|
||||
'label[for="chip-radio-schedule-week-next"]',
|
||||
),
|
||||
gridViewTab: page.getByRole("tab", { name: "Grid" }),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -46,4 +50,20 @@ export class TeamSchedulePage {
|
||||
dayDot(dayIndex: number) {
|
||||
return this.page.getByTestId(`schedule-day-dot-${dayIndex}`);
|
||||
}
|
||||
|
||||
memberChip(userId: number) {
|
||||
return this.page.getByTestId(`heatmap-member-${userId}`);
|
||||
}
|
||||
|
||||
heatmapCells(count: number) {
|
||||
return this.page.locator(
|
||||
`[data-testid="schedule-heatmap-cell"][data-count="${count}"]`,
|
||||
);
|
||||
}
|
||||
|
||||
heatmapCellBackground(count: number) {
|
||||
return this.heatmapCells(count)
|
||||
.first()
|
||||
.evaluate((cell) => getComputedStyle(cell).backgroundColor);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -419,11 +419,13 @@ async function createFullTeam(factories: Factories) {
|
||||
}
|
||||
|
||||
test.describe("Team schedule", () => {
|
||||
test("member sees the grid states and playable windows", async ({
|
||||
test("member sees the heatmap, the grid states and playable windows", async ({
|
||||
page,
|
||||
factories,
|
||||
}) => {
|
||||
const noScheduleMember = await factories.UserFactory.create();
|
||||
const noScheduleMember = await factories.UserFactory.create({
|
||||
discordName: "Schedules-Later",
|
||||
});
|
||||
const { id: teamId, customUrl } = await factories.TeamFactory.create({
|
||||
name: TEAM_NAME,
|
||||
memberUserIds: [ADMIN_ID, NZAP_TEST_ID, noScheduleMember.id],
|
||||
@@ -465,6 +467,34 @@ test.describe("Team schedule", () => {
|
||||
await team.goto(customUrl);
|
||||
|
||||
const schedule = await team.openSchedule();
|
||||
|
||||
// heatmap is the default view: two share Wed 19-22, one is also free Wed 18-19 and Thu 1-2
|
||||
await expect(schedule.locators.heatmap).toBeVisible();
|
||||
await expect(schedule.heatmapCells(2)).toHaveCount(3);
|
||||
await expect(schedule.heatmapCells(1)).toHaveCount(2);
|
||||
// the count shade must actually paint: an equal-specificity base background once blanked the whole grid
|
||||
expect(await schedule.heatmapCellBackground(2)).not.toBe(
|
||||
await schedule.heatmapCellBackground(0),
|
||||
);
|
||||
await expect(schedule.locators.heatmapUnreported).toContainText(
|
||||
"Schedules-Later",
|
||||
);
|
||||
await expect(schedule.dayDot(WEDNESDAY)).toBeVisible();
|
||||
|
||||
// hovering a block names who is free then and who has no schedule
|
||||
await schedule.heatmapCells(2).first().hover();
|
||||
await expect(schedule.locators.heatmapTooltip).toContainText("2/3");
|
||||
await expect(schedule.locators.heatmapTooltip).toContainText("N-ZAP");
|
||||
await expect(schedule.locators.heatmapTooltip).toContainText(
|
||||
"Schedules-Later",
|
||||
);
|
||||
|
||||
// dropping the member without a schedule from the count clears the nudge about them
|
||||
await schedule.memberChip(noScheduleMember.id).click();
|
||||
await isNotVisible(schedule.locators.heatmapUnreported);
|
||||
await schedule.memberChip(noScheduleMember.id).click();
|
||||
|
||||
await schedule.locators.gridViewTab.click();
|
||||
await expect(schedule.locators.grid).toBeVisible();
|
||||
|
||||
await expect(schedule.cellRange(ADMIN_ID, WEDNESDAY)).toBeVisible();
|
||||
|
||||
@@ -32,6 +32,7 @@
|
||||
"registration.summary.out": "",
|
||||
"registration.summary.unknown": "",
|
||||
"team.canPlay": "",
|
||||
"team.legendFreeAtOnce": "",
|
||||
"team.currentWeek": "",
|
||||
"team.editAvailability": "",
|
||||
"team.hidden": "",
|
||||
@@ -39,6 +40,8 @@
|
||||
"team.noSchedule": "",
|
||||
"team.notAvailable": "",
|
||||
"team.noWindows": "",
|
||||
"team.viewGrid": "",
|
||||
"team.viewHeatmap": "",
|
||||
"team.weekHeading": "",
|
||||
"team.withSub": "",
|
||||
"picker.title": "",
|
||||
|
||||
@@ -32,6 +32,7 @@
|
||||
"registration.summary.out": "",
|
||||
"registration.summary.unknown": "",
|
||||
"team.canPlay": "",
|
||||
"team.legendFreeAtOnce": "",
|
||||
"team.currentWeek": "",
|
||||
"team.editAvailability": "",
|
||||
"team.hidden": "",
|
||||
@@ -39,6 +40,8 @@
|
||||
"team.noSchedule": "",
|
||||
"team.notAvailable": "",
|
||||
"team.noWindows": "",
|
||||
"team.viewGrid": "",
|
||||
"team.viewHeatmap": "",
|
||||
"team.weekHeading": "",
|
||||
"team.withSub": "",
|
||||
"picker.title": "",
|
||||
|
||||
@@ -32,6 +32,7 @@
|
||||
"registration.summary.out": "{{amount}} out",
|
||||
"registration.summary.unknown": "{{amount}} unknown",
|
||||
"team.canPlay": "Team can play ({{players}}+)",
|
||||
"team.legendFreeAtOnce": "free at the same time",
|
||||
"team.currentWeek": "This week",
|
||||
"team.editAvailability": "Edit my availability",
|
||||
"team.hidden": "Only team members can see the team schedule",
|
||||
@@ -39,6 +40,8 @@
|
||||
"team.noSchedule": "No schedule",
|
||||
"team.notAvailable": "Not available",
|
||||
"team.noWindows": "No shared free time",
|
||||
"team.viewGrid": "Grid",
|
||||
"team.viewHeatmap": "Heatmap",
|
||||
"team.weekHeading": "Week {{week}}",
|
||||
"team.withSub": "With a sub ({{players}})",
|
||||
"picker.title": "Pick a start time from your team's schedule",
|
||||
|
||||
@@ -32,6 +32,7 @@
|
||||
"registration.summary.out": "",
|
||||
"registration.summary.unknown": "",
|
||||
"team.canPlay": "",
|
||||
"team.legendFreeAtOnce": "",
|
||||
"team.currentWeek": "",
|
||||
"team.editAvailability": "",
|
||||
"team.hidden": "",
|
||||
@@ -39,6 +40,8 @@
|
||||
"team.noSchedule": "",
|
||||
"team.notAvailable": "",
|
||||
"team.noWindows": "",
|
||||
"team.viewGrid": "",
|
||||
"team.viewHeatmap": "",
|
||||
"team.weekHeading": "",
|
||||
"team.withSub": "",
|
||||
"picker.title": "",
|
||||
|
||||
@@ -32,6 +32,7 @@
|
||||
"registration.summary.out": "",
|
||||
"registration.summary.unknown": "",
|
||||
"team.canPlay": "",
|
||||
"team.legendFreeAtOnce": "",
|
||||
"team.currentWeek": "",
|
||||
"team.editAvailability": "",
|
||||
"team.hidden": "",
|
||||
@@ -39,6 +40,8 @@
|
||||
"team.noSchedule": "",
|
||||
"team.notAvailable": "",
|
||||
"team.noWindows": "",
|
||||
"team.viewGrid": "",
|
||||
"team.viewHeatmap": "",
|
||||
"team.weekHeading": "",
|
||||
"team.withSub": "",
|
||||
"picker.title": "",
|
||||
|
||||
@@ -32,6 +32,7 @@
|
||||
"registration.summary.out": "",
|
||||
"registration.summary.unknown": "",
|
||||
"team.canPlay": "",
|
||||
"team.legendFreeAtOnce": "",
|
||||
"team.currentWeek": "",
|
||||
"team.editAvailability": "",
|
||||
"team.hidden": "",
|
||||
@@ -39,6 +40,8 @@
|
||||
"team.noSchedule": "",
|
||||
"team.notAvailable": "",
|
||||
"team.noWindows": "",
|
||||
"team.viewGrid": "",
|
||||
"team.viewHeatmap": "",
|
||||
"team.weekHeading": "",
|
||||
"team.withSub": "",
|
||||
"picker.title": "",
|
||||
|
||||
@@ -32,6 +32,7 @@
|
||||
"registration.summary.out": "",
|
||||
"registration.summary.unknown": "",
|
||||
"team.canPlay": "",
|
||||
"team.legendFreeAtOnce": "",
|
||||
"team.currentWeek": "",
|
||||
"team.editAvailability": "",
|
||||
"team.hidden": "",
|
||||
@@ -39,6 +40,8 @@
|
||||
"team.noSchedule": "",
|
||||
"team.notAvailable": "",
|
||||
"team.noWindows": "",
|
||||
"team.viewGrid": "",
|
||||
"team.viewHeatmap": "",
|
||||
"team.weekHeading": "",
|
||||
"team.withSub": "",
|
||||
"picker.title": "",
|
||||
|
||||
@@ -32,6 +32,7 @@
|
||||
"registration.summary.out": "",
|
||||
"registration.summary.unknown": "",
|
||||
"team.canPlay": "",
|
||||
"team.legendFreeAtOnce": "",
|
||||
"team.currentWeek": "",
|
||||
"team.editAvailability": "",
|
||||
"team.hidden": "",
|
||||
@@ -39,6 +40,8 @@
|
||||
"team.noSchedule": "",
|
||||
"team.notAvailable": "",
|
||||
"team.noWindows": "",
|
||||
"team.viewGrid": "",
|
||||
"team.viewHeatmap": "",
|
||||
"team.weekHeading": "",
|
||||
"team.withSub": "",
|
||||
"picker.title": "",
|
||||
|
||||
@@ -32,6 +32,7 @@
|
||||
"registration.summary.out": "",
|
||||
"registration.summary.unknown": "",
|
||||
"team.canPlay": "",
|
||||
"team.legendFreeAtOnce": "",
|
||||
"team.currentWeek": "",
|
||||
"team.editAvailability": "",
|
||||
"team.hidden": "",
|
||||
@@ -39,6 +40,8 @@
|
||||
"team.noSchedule": "",
|
||||
"team.notAvailable": "",
|
||||
"team.noWindows": "",
|
||||
"team.viewGrid": "",
|
||||
"team.viewHeatmap": "",
|
||||
"team.weekHeading": "",
|
||||
"team.withSub": "",
|
||||
"picker.title": "",
|
||||
|
||||
@@ -32,6 +32,7 @@
|
||||
"registration.summary.out": "",
|
||||
"registration.summary.unknown": "",
|
||||
"team.canPlay": "",
|
||||
"team.legendFreeAtOnce": "",
|
||||
"team.currentWeek": "",
|
||||
"team.editAvailability": "",
|
||||
"team.hidden": "",
|
||||
@@ -39,6 +40,8 @@
|
||||
"team.noSchedule": "",
|
||||
"team.notAvailable": "",
|
||||
"team.noWindows": "",
|
||||
"team.viewGrid": "",
|
||||
"team.viewHeatmap": "",
|
||||
"team.weekHeading": "",
|
||||
"team.withSub": "",
|
||||
"picker.title": "",
|
||||
|
||||
@@ -32,6 +32,7 @@
|
||||
"registration.summary.out": "",
|
||||
"registration.summary.unknown": "",
|
||||
"team.canPlay": "",
|
||||
"team.legendFreeAtOnce": "",
|
||||
"team.currentWeek": "",
|
||||
"team.editAvailability": "",
|
||||
"team.hidden": "",
|
||||
@@ -39,6 +40,8 @@
|
||||
"team.noSchedule": "",
|
||||
"team.notAvailable": "",
|
||||
"team.noWindows": "",
|
||||
"team.viewGrid": "",
|
||||
"team.viewHeatmap": "",
|
||||
"team.weekHeading": "",
|
||||
"team.withSub": "",
|
||||
"picker.title": "",
|
||||
|
||||
@@ -32,6 +32,7 @@
|
||||
"registration.summary.out": "",
|
||||
"registration.summary.unknown": "",
|
||||
"team.canPlay": "",
|
||||
"team.legendFreeAtOnce": "",
|
||||
"team.currentWeek": "",
|
||||
"team.editAvailability": "",
|
||||
"team.hidden": "",
|
||||
@@ -39,6 +40,8 @@
|
||||
"team.noSchedule": "",
|
||||
"team.notAvailable": "",
|
||||
"team.noWindows": "",
|
||||
"team.viewGrid": "",
|
||||
"team.viewHeatmap": "",
|
||||
"team.weekHeading": "",
|
||||
"team.withSub": "",
|
||||
"picker.title": "",
|
||||
|
||||
@@ -32,6 +32,7 @@
|
||||
"registration.summary.out": "",
|
||||
"registration.summary.unknown": "",
|
||||
"team.canPlay": "",
|
||||
"team.legendFreeAtOnce": "",
|
||||
"team.currentWeek": "",
|
||||
"team.editAvailability": "",
|
||||
"team.hidden": "",
|
||||
@@ -39,6 +40,8 @@
|
||||
"team.noSchedule": "",
|
||||
"team.notAvailable": "",
|
||||
"team.noWindows": "",
|
||||
"team.viewGrid": "",
|
||||
"team.viewHeatmap": "",
|
||||
"team.weekHeading": "",
|
||||
"team.withSub": "",
|
||||
"picker.title": "",
|
||||
|
||||
@@ -32,6 +32,7 @@
|
||||
"registration.summary.out": "",
|
||||
"registration.summary.unknown": "",
|
||||
"team.canPlay": "",
|
||||
"team.legendFreeAtOnce": "",
|
||||
"team.currentWeek": "",
|
||||
"team.editAvailability": "",
|
||||
"team.hidden": "",
|
||||
@@ -39,6 +40,8 @@
|
||||
"team.noSchedule": "",
|
||||
"team.notAvailable": "",
|
||||
"team.noWindows": "",
|
||||
"team.viewGrid": "",
|
||||
"team.viewHeatmap": "",
|
||||
"team.weekHeading": "",
|
||||
"team.withSub": "",
|
||||
"picker.title": "",
|
||||
|
||||
@@ -32,6 +32,7 @@
|
||||
"registration.summary.out": "",
|
||||
"registration.summary.unknown": "",
|
||||
"team.canPlay": "",
|
||||
"team.legendFreeAtOnce": "",
|
||||
"team.currentWeek": "",
|
||||
"team.editAvailability": "",
|
||||
"team.hidden": "",
|
||||
@@ -39,6 +40,8 @@
|
||||
"team.noSchedule": "",
|
||||
"team.notAvailable": "",
|
||||
"team.noWindows": "",
|
||||
"team.viewGrid": "",
|
||||
"team.viewHeatmap": "",
|
||||
"team.weekHeading": "",
|
||||
"team.withSub": "",
|
||||
"picker.title": "",
|
||||
|
||||
@@ -32,6 +32,7 @@
|
||||
"registration.summary.out": "",
|
||||
"registration.summary.unknown": "",
|
||||
"team.canPlay": "",
|
||||
"team.legendFreeAtOnce": "",
|
||||
"team.currentWeek": "",
|
||||
"team.editAvailability": "",
|
||||
"team.hidden": "",
|
||||
@@ -39,6 +40,8 @@
|
||||
"team.noSchedule": "",
|
||||
"team.notAvailable": "",
|
||||
"team.noWindows": "",
|
||||
"team.viewGrid": "",
|
||||
"team.viewHeatmap": "",
|
||||
"team.weekHeading": "",
|
||||
"team.withSub": "",
|
||||
"picker.title": "",
|
||||
|
||||
Reference in New Issue
Block a user