+
+ {orderedRows.map((row) => (
+
+ setSelectedIds((ids) =>
+ ids.includes(row.userId)
+ ? ids.filter((id) => id !== row.userId)
+ : [...ids, row.userId],
+ )
+ }
+ />
+ ))}
+
+
+
+
+ {dayViews.map(({ day, dayIndex, cells }) => (
+
+ {dayLabel(dayIndex, { withTestId: true })}
+ {/** biome-ignore lint/a11y/noStaticElementInteractions: hover-only detail, the list view and summary carry the same info */}
+ setHoveredCell(null)}
+ >
+ {cells.map((cell) => (
+ // biome-ignore lint/a11y/noStaticElementInteractions: hover-only detail, the list view and summary carry the same info
+
0
+ ? shadeClass(cell.userIds.length)
+ : undefined,
+ )}
+ onMouseEnter={(event) =>
+ setHoveredCell({
+ cell,
+ anchor: tooltipAnchor(event.currentTarget),
+ })
+ }
+ data-testid="schedule-heatmap-cell"
+ data-count={cell.userIds.length}
+ />
+ ))}
+
+ {/* keeps the day rows in step with the axis row's "later" expander */}
+
+
+ ))}
+
+
+ {dayViews.map(({ day, dayIndex, segments }) => (
+
+
+ {dayLabel(dayIndex)}
+
+ {segments.length === 0 ? (
+
—
+ ) : (
+
+ {segments.map((segment) => (
+
+
+ {rangeText(segment)} ·{" "}
+ {t("schedule:picker.free", {
+ amount: segment.userIds.length,
+ })}
+
+ ))}
+
+ )}
+
+ ))}
+
+
+ {hoveredCell ? (
+
+
+ {rangeText(hoveredCell.cell)}
+
+
+ {t("schedule:scrims.availableOfRoster", {
+ amount: hoveredCell.cell.userIds.length,
+ total: selectedRows.length,
+ })}
+
+
+ {selectedRows.map((row) => (
+
+
+ {row.member.username}
+ {!row.reported ? ` · ${t("schedule:team.noSchedule")}` : null}
+
+ ))}
+
+
+ ) : null}
+ {selectedRows.length > 0 ? (
+
+ {R.range(1, Math.min(selectedRows.length, MAX_SHADE_COUNT) + 1).map(
+ (count) => (
+
+
+ {count === MAX_SHADE_COUNT &&
+ selectedRows.length > MAX_SHADE_COUNT
+ ? `${count}+`
+ : count}
+
+ ),
+ )}
+ {t("schedule:team.legendFreeAtOnce")}
+
+ ) : null}
+ {unreportedNames.length > 0 ? (
+
+ {t("schedule:picker.noSchedule", {
+ users: unreportedNames.join(", "),
+ })}
+
+ ) : null}
+
+
+ );
+}
+
+function MemberChip({
+ row,
+ selected,
+ onToggle,
+}: {
+ row: MemberRow;
+ selected: boolean;
+ onToggle: () => void;
+}) {
+ const { t } = useTranslation(["schedule"]);
+
+ return (
+
+
+ {row.member.username}
+ {!row.reported ? (
+
+ ?
+
+ ) : null}
+
+ );
+}
+
+interface HoveredCell {
+ cell: { startsAt: number; endsAt: number; userIds: Array
};
+ 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);
+}
diff --git a/app/features/availability/core/Availability.test.ts b/app/features/availability/core/Availability.test.ts
index b44495e36..6004e0aca 100644
--- a/app/features/availability/core/Availability.test.ts
+++ b/app/features/availability/core/Availability.test.ts
@@ -638,6 +638,64 @@ describe("Availability.playableWindows", () => {
});
});
+describe("Availability.availabilitySegments", () => {
+ const members = (
+ ranges: Array>,
+ ) =>
+ 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],
diff --git a/app/features/availability/core/Availability.ts b/app/features/availability/core/Availability.ts
index 88fcd5029..ef5f2c61c 100644
--- a/app/features/availability/core/Availability.ts
+++ b/app/features/availability/core/Availability.ts
@@ -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) {
+/**
+ * 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) {
const normalized = members.map((member) => ({
userId: member.userId,
ranges: normalize(member.ranges),
diff --git a/app/features/availability/loaders/t.$customUrl.schedule.server.ts b/app/features/availability/loaders/t.$customUrl.schedule.server.ts
index 31b37f4db..240c94f09 100644
--- a/app/features/availability/loaders/t.$customUrl.schedule.server.ts
+++ b/app/features/availability/loaders/t.$customUrl.schedule.server.ts
@@ -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 }),
}));
diff --git a/app/features/availability/routes/t.$customUrl.schedule.module.css b/app/features/availability/routes/t.$customUrl.schedule.module.css
index 671ae3a38..c4809e01e 100644
--- a/app/features/availability/routes/t.$customUrl.schedule.module.css
+++ b/app/features/availability/routes/t.$customUrl.schedule.module.css
@@ -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;
diff --git a/app/features/availability/routes/t.$customUrl.schedule.tsx b/app/features/availability/routes/t.$customUrl.schedule.tsx
index 4354a70d8..3829be6d0 100644
--- a/app/features/availability/routes/t.$customUrl.schedule.tsx
+++ b/app/features/availability/routes/t.$customUrl.schedule.tsx
@@ -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 }) {
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 }) {