From 1ed7e41aecd3d181fcb6096bd5fb917163caee67 Mon Sep 17 00:00:00 2001 From: Kalle <38327916+Sendouc@users.noreply.github.com> Date: Sat, 5 Sep 2026 13:45:26 +0300 Subject: [PATCH] Team schedule heatmap --- .../availability-search-params.test.ts | 1 + .../availability-search-params.ts | 4 + .../PlayableWindowsSummary.module.css | 43 ++ .../components/PlayableWindowsSummary.tsx | 91 ++++ .../components/ScheduleHeatmap.module.css | 208 +++++++++ .../components/ScheduleHeatmap.tsx | 408 ++++++++++++++++++ .../availability/core/Availability.test.ts | 58 +++ .../availability/core/Availability.ts | 7 +- .../loaders/t.$customUrl.schedule.server.ts | 6 + .../routes/t.$customUrl.schedule.module.css | 44 -- .../routes/t.$customUrl.schedule.tsx | 121 +++--- ...-availability-team-schedule-cross-links.md | 5 - changelog/2026-09-05-team-schedule-heatmap.md | 11 + e2e/pages/team/team-schedule-page.ts | 20 + e2e/team.spec.ts | 34 +- locales/da/schedule.json | 3 + locales/de/schedule.json | 3 + locales/en/schedule.json | 3 + locales/es-ES/schedule.json | 3 + locales/es-US/schedule.json | 3 + locales/fr-CA/schedule.json | 3 + locales/fr-EU/schedule.json | 3 + locales/he/schedule.json | 3 + locales/it/schedule.json | 3 + locales/ja/schedule.json | 3 + locales/ko/schedule.json | 3 + locales/nl/schedule.json | 3 + locales/pl/schedule.json | 3 + locales/pt-BR/schedule.json | 3 + locales/ru/schedule.json | 3 + locales/zh/schedule.json | 3 + 31 files changed, 990 insertions(+), 119 deletions(-) create mode 100644 app/features/availability/components/PlayableWindowsSummary.module.css create mode 100644 app/features/availability/components/PlayableWindowsSummary.tsx create mode 100644 app/features/availability/components/ScheduleHeatmap.module.css create mode 100644 app/features/availability/components/ScheduleHeatmap.tsx delete mode 100644 changelog/2026-09-05-availability-team-schedule-cross-links.md create mode 100644 changelog/2026-09-05-team-schedule-heatmap.md diff --git a/app/features/availability/availability-search-params.test.ts b/app/features/availability/availability-search-params.test.ts index 9a74b5ef8..36616517a 100644 --- a/app/features/availability/availability-search-params.test.ts +++ b/app/features/availability/availability-search-params.test.ts @@ -6,6 +6,7 @@ describe("scheduleWeekSearchParams", () => { test("round-trips", () => { assertRoundTrips(scheduleWeekSearchParams, { week: ["current", "next"], + view: ["heatmap", "grid"], }); }); }); diff --git a/app/features/availability/availability-search-params.ts b/app/features/availability/availability-search-params.ts index ef1c7dad1..cede7e823 100644 --- a/app/features/availability/availability-search-params.ts +++ b/app/features/availability/availability-search-params.ts @@ -7,4 +7,8 @@ export const scheduleWeekSearchParams = SearchParams.define({ default: "current", loader: false, }), + view: SP.param(v.picklist(["heatmap", "grid"]), { + default: "heatmap", + loader: false, + }), }); diff --git a/app/features/availability/components/PlayableWindowsSummary.module.css b/app/features/availability/components/PlayableWindowsSummary.module.css new file mode 100644 index 000000000..e538f42d2 --- /dev/null +++ b/app/features/availability/components/PlayableWindowsSummary.module.css @@ -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; +} diff --git a/app/features/availability/components/PlayableWindowsSummary.tsx b/app/features/availability/components/PlayableWindowsSummary.tsx new file mode 100644 index 000000000..4253c80cf --- /dev/null +++ b/app/features/availability/components/PlayableWindowsSummary.tsx @@ -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 ( + + ); +} + +/** The week's playable windows as one line per tier, shared by the schedule views. */ +export function PlayableWindowsSummary({ + windows, + minPlayers, +}: { + windows: Array; + minPlayers: number; +}) { + const { t } = useTranslation(["schedule"]); + + const fullWindows = windows.filter((window) => window.tier === "FULL"); + const oneShortWindows = windows.filter( + (window) => window.tier === "ONE_SHORT", + ); + + return ( +
+
+ + + {t("schedule:team.canPlay", { players: minPlayers })} + + +
+ {minPlayers > 1 && oneShortWindows.length > 0 ? ( +
+ + + {t("schedule:team.withSub", { players: minPlayers - 1 })} + + +
+ ) : null} +
+ ); +} + +function WindowList({ windows }: { windows: Array }) { + 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)} + + ))} + + ); +} diff --git a/app/features/availability/components/ScheduleHeatmap.module.css b/app/features/availability/components/ScheduleHeatmap.module.css new file mode 100644 index 000000000..cffe88bb5 --- /dev/null +++ b/app/features/availability/components/ScheduleHeatmap.module.css @@ -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); +} diff --git a/app/features/availability/components/ScheduleHeatmap.tsx b/app/features/availability/components/ScheduleHeatmap.tsx new file mode 100644 index 000000000..a0729a562 --- /dev/null +++ b/app/features/availability/components/ScheduleHeatmap.tsx @@ -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[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; +}) { + const { t } = useTranslation(["schedule"]); + const rangeText = useRangeText(); + const { formatter: dayFormatter } = useDateTimeFormat({ + weekday: "short", + day: "numeric", + }); + + const rows: Array = 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>(() => + rows + .filter(({ member }) => getMemberRoleType(member) !== "OTHER") + .map((row) => row.userId), + ); + const selectedRows = orderedRows.filter((row) => + selectedIds.includes(row.userId), + ); + const [hoveredCell, setHoveredCell] = React.useState( + 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 ( +
+ + {tier ? ( + + ) : null} + + {dayFormatter.format(week.days[dayIndex].noonAt)} +
+ ); + }; + + return ( +
+
+ {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 ( + + ); +} + +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 }) {
- - + + setParams({ view: key === "grid" ? "grid" : "heatmap" }) + } + > + + }> + {t("schedule:team.viewHeatmap")} + + }> + {t("schedule:team.viewGrid")} + + + + + + +
+ + +
+
+
); @@ -162,11 +209,10 @@ function ScheduleGrid({ week }: { week: WeekData }) { {week.days.map((day, dayIndex) => ( {day.windowTier ? ( - ) : 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 ( -
-
- - - {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" }); diff --git a/changelog/2026-09-05-availability-team-schedule-cross-links.md b/changelog/2026-09-05-availability-team-schedule-cross-links.md deleted file mode 100644 index 94de649db..000000000 --- a/changelog/2026-09-05-availability-team-schedule-cross-links.md +++ /dev/null @@ -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 diff --git a/changelog/2026-09-05-team-schedule-heatmap.md b/changelog/2026-09-05-team-schedule-heatmap.md new file mode 100644 index 000000000..e0dfacc52 --- /dev/null +++ b/changelog/2026-09-05-team-schedule-heatmap.md @@ -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 diff --git a/e2e/pages/team/team-schedule-page.ts b/e2e/pages/team/team-schedule-page.ts index 36c0161ac..b33d51dbd 100644 --- a/e2e/pages/team/team-schedule-page.ts +++ b/e2e/pages/team/team-schedule-page.ts @@ -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); + } } diff --git a/e2e/team.spec.ts b/e2e/team.spec.ts index 51457b399..1c9e6cea3 100644 --- a/e2e/team.spec.ts +++ b/e2e/team.spec.ts @@ -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(); diff --git a/locales/da/schedule.json b/locales/da/schedule.json index 101742cb5..a3ebd18a1 100644 --- a/locales/da/schedule.json +++ b/locales/da/schedule.json @@ -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": "", diff --git a/locales/de/schedule.json b/locales/de/schedule.json index 101742cb5..a3ebd18a1 100644 --- a/locales/de/schedule.json +++ b/locales/de/schedule.json @@ -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": "", diff --git a/locales/en/schedule.json b/locales/en/schedule.json index 240d195a4..90bd8e6f5 100644 --- a/locales/en/schedule.json +++ b/locales/en/schedule.json @@ -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", diff --git a/locales/es-ES/schedule.json b/locales/es-ES/schedule.json index 101742cb5..a3ebd18a1 100644 --- a/locales/es-ES/schedule.json +++ b/locales/es-ES/schedule.json @@ -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": "", diff --git a/locales/es-US/schedule.json b/locales/es-US/schedule.json index 101742cb5..a3ebd18a1 100644 --- a/locales/es-US/schedule.json +++ b/locales/es-US/schedule.json @@ -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": "", diff --git a/locales/fr-CA/schedule.json b/locales/fr-CA/schedule.json index 101742cb5..a3ebd18a1 100644 --- a/locales/fr-CA/schedule.json +++ b/locales/fr-CA/schedule.json @@ -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": "", diff --git a/locales/fr-EU/schedule.json b/locales/fr-EU/schedule.json index 101742cb5..a3ebd18a1 100644 --- a/locales/fr-EU/schedule.json +++ b/locales/fr-EU/schedule.json @@ -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": "", diff --git a/locales/he/schedule.json b/locales/he/schedule.json index 101742cb5..a3ebd18a1 100644 --- a/locales/he/schedule.json +++ b/locales/he/schedule.json @@ -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": "", diff --git a/locales/it/schedule.json b/locales/it/schedule.json index 101742cb5..a3ebd18a1 100644 --- a/locales/it/schedule.json +++ b/locales/it/schedule.json @@ -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": "", diff --git a/locales/ja/schedule.json b/locales/ja/schedule.json index 101742cb5..a3ebd18a1 100644 --- a/locales/ja/schedule.json +++ b/locales/ja/schedule.json @@ -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": "", diff --git a/locales/ko/schedule.json b/locales/ko/schedule.json index 101742cb5..a3ebd18a1 100644 --- a/locales/ko/schedule.json +++ b/locales/ko/schedule.json @@ -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": "", diff --git a/locales/nl/schedule.json b/locales/nl/schedule.json index 101742cb5..a3ebd18a1 100644 --- a/locales/nl/schedule.json +++ b/locales/nl/schedule.json @@ -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": "", diff --git a/locales/pl/schedule.json b/locales/pl/schedule.json index 101742cb5..a3ebd18a1 100644 --- a/locales/pl/schedule.json +++ b/locales/pl/schedule.json @@ -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": "", diff --git a/locales/pt-BR/schedule.json b/locales/pt-BR/schedule.json index 101742cb5..a3ebd18a1 100644 --- a/locales/pt-BR/schedule.json +++ b/locales/pt-BR/schedule.json @@ -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": "", diff --git a/locales/ru/schedule.json b/locales/ru/schedule.json index 101742cb5..a3ebd18a1 100644 --- a/locales/ru/schedule.json +++ b/locales/ru/schedule.json @@ -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": "", diff --git a/locales/zh/schedule.json b/locales/zh/schedule.json index 101742cb5..a3ebd18a1 100644 --- a/locales/zh/schedule.json +++ b/locales/zh/schedule.json @@ -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": "",