Hold every calendar cell to the same size

A calendar whose boxes resize is not a calendar. Each week row is its own
grid, and a bare 1fr track takes its minimum from its content, so the one
week containing "Alla helgons dag" had its seven cells squeezed while
every other week's stayed wide. The tracks are now minmax(0, 1fr) and the
cell, the day and the holiday name each refuse to contribute an intrinsic
width; the name clips to an ellipsis and keeps its full text in the title.

Row height follows the same rule: the band lanes are reserved from the
month's tallest week rather than each row's own, so a week with no leave
in it stands as tall as the week beside it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ei7gCZzdd751uZtPzb1Pep
This commit is contained in:
2026-09-19 14:08:04 +02:00
parent 224fe7ae3e
commit f0f537c95f
6 changed files with 281 additions and 6 deletions
@@ -48,6 +48,16 @@ export interface CalendarWeekProps {
* highlight (see Calendar.tsx's preset rail: "scrolls to the resolved
* placement and flashes the band"). `null`/omitted highlights nothing. */
flashWishId?: string | null | undefined
/** The highest concurrent-lane count across every week in the displayed
* month (computed once in Calendar.tsx via `computeWeekLaneCount` over
* each week, then passed down uniformly to all of them). Without this,
* `--band-reserved-height` was computed purely from THIS week's own bands,
* so a week with more overlapping bands (or any bands at all) reserved
* more bottom padding than a quiet week and rendered taller — every row in
* a calendar grid must be the same height regardless of what it contains.
* Omitted/undefined falls back to this week's own lane count (used by
* existing unit tests that render a single week in isolation). */
monthMaxLaneCount?: number | undefined
}
/** One day's slot inside a band segment: either a real planned_leave_day
@@ -212,6 +222,20 @@ function computeBandSegments(
return assignBandLanes(raw)
}
/** The concurrent-lane count a single week's own bands would need — the same
* computation `CalendarWeek` uses internally for `bandsReservedHeight`,
* exposed so Calendar.tsx can compute it for every week up front and take
* the month-wide max (see `monthMaxLaneCount` above). Kept as a thin wrapper
* over `computeBandSegments` rather than duplicating its logic. */
export function computeWeekLaneCount(
weekDates: string[],
plannedDays: PlannedLeaveDay[],
calendarDaysByDate?: Map<string, CalendarDayEntry>,
): number {
const segments = computeBandSegments(weekDates, plannedDays, calendarDaysByDate)
return segments.length > 0 ? Math.max(...segments.map((b) => b.lane)) + 1 : 0
}
function assignBandLanes(segments: Array<Omit<BandSegment, 'lane'>>): BandSegment[] {
const sorted = [...segments].sort((a, b) => {
if (a.startCol !== b.startCol) return a.startCol - b.startCol
@@ -261,6 +285,7 @@ export function CalendarWeek({
calendarDaysByDate,
onBandClick,
flashWishId,
monthMaxLaneCount,
}: CalendarWeekProps): ReactNode {
const { t } = useTranslation()
const bandSegments = useMemo(
@@ -279,7 +304,13 @@ export function CalendarWeek({
const laneGap = 2
const bandBottomInset = 4
const laneCount = bandSegments.length > 0 ? Math.max(...bandSegments.map((b) => b.lane)) + 1 : 0
const bandsReservedHeight = laneCount > 0 ? bandBottomInset + laneCount * (barHeight + laneGap) : 0
// Reserve space for the month's tallest week, not just this one — see
// `monthMaxLaneCount`'s doc comment. `Math.max` with this week's own
// `laneCount` is a safety floor (never reserve LESS than this row's own
// bands actually need), never used to reserve more than the month's real
// max since the caller always passes that max down to every week.
const effectiveLaneCount = monthMaxLaneCount !== undefined ? Math.max(laneCount, monthMaxLaneCount) : laneCount
const bandsReservedHeight = effectiveLaneCount > 0 ? bandBottomInset + effectiveLaneCount * (barHeight + laneGap) : 0
const colOffset = showWeekNumbers ? 1 : 0
const firstDate = weekDates[0]