Files
sendou.ink/app/features/availability/components/ScheduleHeatmap.tsx
2026-10-03 19:58:35 +03:00

419 lines
12 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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;
/** 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 colored by whether enough of the counted members are free at once to play. 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 tierClass = (count: number) => {
if (count === 0) return undefined;
if (count >= minPlayers) return styles.tierFull;
if (count === minPlayers - 1) return styles.tierOneShort;
return styles.tierFew;
};
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,
tierClass(cell.userIds.length),
)}
onMouseEnter={(event) =>
setHoveredCell({
cell,
anchor: tooltipAnchor(event.currentTarget),
})
}
data-testid="schedule-heatmap-cell"
data-count={cell.userIds.length}
>
{cell.userIds.length > 0 ? cell.userIds.length : null}
</div>
))}
</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,
tierClass(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}>
<span className={styles.legendItem}>
<span className={clsx(styles.legendSwatch, styles.tierFull)} />
{t("schedule:picker.legend.full", { players: minPlayers })}
</span>
{minPlayers > 1 ? (
<span className={styles.legendItem}>
<span
className={clsx(styles.legendSwatch, styles.tierOneShort)}
/>
{t("schedule:picker.legend.oneShort", {
players: minPlayers - 1,
})}
</span>
) : null}
{minPlayers > 2 ? (
<span className={styles.legendItem}>
<span className={clsx(styles.legendSwatch, styles.tierFew)} />
{t("schedule:picker.free", {
amount: minPlayers === 3 ? "1" : `1–${minPlayers - 2}`,
})}
</span>
) : null}
</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);
}