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:
@@ -0,0 +1,52 @@
|
||||
/**
|
||||
* The calendar grid must never change cell size: every day cell in the
|
||||
* displayed month is the same width, and every week row is the same
|
||||
* height, regardless of what any single cell contains.
|
||||
*
|
||||
* October 2026 is the exact month from the reported bug: its last week row
|
||||
* (v.44) contains 31 October, "Alla helgons dag" — that long holiday name
|
||||
* used to inflate only THAT row's grid track minimums (each week is its own
|
||||
* separate CSS Grid — see `calendar.css`'s `.calendar-week` comment), so its
|
||||
* seven day cells rendered narrower and misaligned against every other row.
|
||||
* The fix (`minmax(0, 1fr)` tracks + `min-width: 0` on the cell/holiday-name
|
||||
* elements, plus lifting `--band-reserved-height` to the month's max lane
|
||||
* count) is unit-tested in `styles`/`CalendarWeek.test.tsx`/`Calendar.test.tsx`,
|
||||
* which can't assert real layout (jsdom does none) — this spec is the one
|
||||
* check that a real browser actually renders it uniformly.
|
||||
*/
|
||||
import { test, expect } from '@playwright/test'
|
||||
import { goToCalendarMonth } from '../support/calendarNav'
|
||||
|
||||
test('every day cell in October 2026 is the same width, and every week row the same height', async ({ page }) => {
|
||||
await goToCalendarMonth(page, new Date('2026-10-15T00:00:00Z'))
|
||||
|
||||
// Confirm the long-name row is actually present — otherwise this test
|
||||
// would pass for the wrong reason (no distorting content at all).
|
||||
await expect(
|
||||
page.getByTestId('calendar-day-holiday-name').filter({ hasText: 'Alla helgons dag' }),
|
||||
).toBeVisible()
|
||||
|
||||
const dayCells = page.locator('[data-testid="calendar-day"]')
|
||||
const dayCount = await dayCells.count()
|
||||
expect(dayCount).toBeGreaterThan(28) // at least 5 full week rows
|
||||
|
||||
const widths: number[] = []
|
||||
for (let i = 0; i < dayCount; i++) {
|
||||
const box = await dayCells.nth(i).boundingBox()
|
||||
if (!box) throw new Error(`calendar day cell ${i} has no bounding box — is it actually visible?`)
|
||||
widths.push(box.width)
|
||||
}
|
||||
expect(Math.max(...widths) - Math.min(...widths)).toBeLessThanOrEqual(1)
|
||||
|
||||
const weekRows = page.locator('.calendar-week')
|
||||
const rowCount = await weekRows.count()
|
||||
expect(rowCount).toBeGreaterThan(3) // October 2026 spans 5-6 Mon-Sun rows
|
||||
|
||||
const heights: number[] = []
|
||||
for (let i = 0; i < rowCount; i++) {
|
||||
const box = await weekRows.nth(i).boundingBox()
|
||||
if (!box) throw new Error(`week row ${i} has no bounding box — is it actually visible?`)
|
||||
heights.push(box.height)
|
||||
}
|
||||
expect(Math.max(...heights) - Math.min(...heights)).toBeLessThanOrEqual(1)
|
||||
})
|
||||
@@ -600,6 +600,71 @@ describe('CalendarWeek', () => {
|
||||
expect(week.style.getPropertyValue('--band-reserved-height')).toBe('0px')
|
||||
})
|
||||
|
||||
describe('monthMaxLaneCount: uniform row height across the month', () => {
|
||||
it('reserves height for a week with NO bands of its own when given a month-wide max from other weeks', () => {
|
||||
// Calendar.tsx computes the max lane count across every week in the
|
||||
// displayed month and passes it down uniformly — a quiet week must
|
||||
// still reserve as much bottom padding as the tallest week, so
|
||||
// every row in the month renders the same height.
|
||||
const { container } = render(<CalendarWeek {...defaultProps} plannedDays={[]} monthMaxLaneCount={2} />)
|
||||
const week = container.querySelector('.calendar-week') as HTMLElement
|
||||
// bandBottomInset(4) + 2 lanes * (barHeight(20) + laneGap(2)) = 48px
|
||||
expect(week.style.getPropertyValue('--band-reserved-height')).toBe('48px')
|
||||
})
|
||||
|
||||
it('does not shrink below what this week\'s own bands need, even if monthMaxLaneCount is smaller', () => {
|
||||
// Defensive floor: monthMaxLaneCount is always computed as the max
|
||||
// over all weeks including this one, so it should never actually be
|
||||
// smaller than this week's own lane count — but the component must
|
||||
// not let a band get squeezed if it somehow were.
|
||||
const plannedDays = [
|
||||
pd({ id: '1', date: '2026-01-05', parentPersonalDataUuid: 'parent-1' }),
|
||||
pd({ id: '2', date: '2026-01-05', parentPersonalDataUuid: 'other-parent' }),
|
||||
]
|
||||
const { container } = render(
|
||||
<CalendarWeek {...defaultProps} plannedDays={plannedDays} monthMaxLaneCount={0} />,
|
||||
)
|
||||
const week = container.querySelector('.calendar-week') as HTMLElement
|
||||
// This week's own 2 lanes win over the smaller monthMaxLaneCount.
|
||||
expect(week.style.getPropertyValue('--band-reserved-height')).toBe('48px')
|
||||
})
|
||||
|
||||
it('falls back to this week\'s own lane count when monthMaxLaneCount is omitted (single-week render)', () => {
|
||||
const plannedDays = [pd({ id: '1', date: '2026-01-05' })]
|
||||
const { container } = render(<CalendarWeek {...defaultProps} plannedDays={plannedDays} />)
|
||||
const week = container.querySelector('.calendar-week') as HTMLElement
|
||||
// bandBottomInset(4) + 1 lane * (barHeight(20) + laneGap(2)) = 26px
|
||||
expect(week.style.getPropertyValue('--band-reserved-height')).toBe('26px')
|
||||
})
|
||||
|
||||
it('gives every week row the same --band-reserved-height when rendered together with a shared monthMaxLaneCount, even though only one has bands', () => {
|
||||
// Mirrors how Calendar.tsx renders a month: several CalendarWeek
|
||||
// rows side by side, all fed the same month-wide max. A week with
|
||||
// no bands of its own must still match a week with two overlapping
|
||||
// bands, or that row would render visibly shorter.
|
||||
const busyWeekDays = [
|
||||
pd({ id: '1', date: '2026-01-05', parentPersonalDataUuid: 'parent-1' }),
|
||||
pd({ id: '2', date: '2026-01-05', parentPersonalDataUuid: 'other-parent' }),
|
||||
]
|
||||
const { container } = render(
|
||||
<>
|
||||
<CalendarWeek {...defaultProps} plannedDays={busyWeekDays} monthMaxLaneCount={2} />
|
||||
<CalendarWeek
|
||||
{...defaultProps}
|
||||
weekDates={['2026-01-12', '2026-01-13', '2026-01-14', '2026-01-15', '2026-01-16', '2026-01-17', '2026-01-18']}
|
||||
plannedDays={[]}
|
||||
monthMaxLaneCount={2}
|
||||
/>
|
||||
</>,
|
||||
)
|
||||
const weeks = Array.from(container.querySelectorAll('.calendar-week')) as HTMLElement[]
|
||||
expect(weeks).toHaveLength(2)
|
||||
const heights = weeks.map((week) => week.style.getPropertyValue('--band-reserved-height'))
|
||||
expect(new Set(heights).size).toBe(1)
|
||||
expect(heights[0]).toBe('48px')
|
||||
})
|
||||
})
|
||||
|
||||
it('still fires the day cell\'s onClick/onPointerDown/onPointerEnter for a date under a band', () => {
|
||||
// The band overlays the day cell (same grid row); the day cell must
|
||||
// stay reachable for its own click/drag-select handlers underneath.
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import fs from 'node:fs'
|
||||
import path from 'node:path'
|
||||
import { render, screen, fireEvent, waitFor } from '@testing-library/react'
|
||||
import { useSearchParams, type SetURLSearchParams } from 'react-router-dom'
|
||||
import { Calendar } from './Calendar'
|
||||
@@ -1471,4 +1473,85 @@ describe('Calendar', () => {
|
||||
await waitFor(() => expect(screen.queryByTestId('modal')).not.toBeInTheDocument())
|
||||
})
|
||||
})
|
||||
|
||||
describe('grid layout: uniform cell size and row height regardless of content', () => {
|
||||
it('keeps every week row on the same shared grid definition even when one week contains a long holiday name', () => {
|
||||
// Regression guard for the "31 October" bug: a long holiday name
|
||||
// ("Alla helgons dag") used to widen only the week row containing it,
|
||||
// because each week is its own CSS Grid container and a bare `1fr`
|
||||
// track's minimum is its content's own width. jsdom does no layout,
|
||||
// so this can't assert pixel widths directly — instead it asserts the
|
||||
// structural invariant (every week row uses the exact same
|
||||
// `.calendar-week` class, with no per-row width/grid override) and
|
||||
// that the shared stylesheet rule those rows resolve against is
|
||||
// actually content-safe (`minmax(0, 1fr)`, not a bare `1fr`).
|
||||
vi.mocked(useCalendarDays).mockReturnValue(
|
||||
makeCalendarDaysResult({
|
||||
data: makeCalendarWindow({
|
||||
days: [
|
||||
makeCalendarDayEntry({
|
||||
date: '2026-10-31',
|
||||
name: 'Alla helgons dag',
|
||||
dayType: 'red_day',
|
||||
isFree: true,
|
||||
}),
|
||||
],
|
||||
}),
|
||||
}),
|
||||
)
|
||||
vi.mocked(useSearchParams).mockReturnValue([new URLSearchParams('date=2026-10-15'), mockSetSearchParams])
|
||||
|
||||
const { container } = render(<Calendar />)
|
||||
|
||||
// The long holiday name actually rendered somewhere in the grid.
|
||||
expect(screen.getByText('Alla helgons dag')).toBeInTheDocument()
|
||||
|
||||
// Every week row shares the exact same class — none carries its own
|
||||
// width/grid-template-columns override that could diverge per row.
|
||||
const weekRows = Array.from(container.querySelectorAll('.calendar-week'))
|
||||
expect(weekRows.length).toBeGreaterThan(3)
|
||||
weekRows.forEach((row) => {
|
||||
expect(row.className).toBe('calendar-week')
|
||||
expect((row as HTMLElement).style.width).toBe('')
|
||||
expect((row as HTMLElement).style.gridTemplateColumns).toBe('')
|
||||
})
|
||||
|
||||
// The stylesheet rule those rows all resolve against must be immune
|
||||
// to content width: `minmax(0, 1fr)` tracks, and every element
|
||||
// inside a cell that could carry a long string sets `min-width: 0`.
|
||||
const css = fs.readFileSync(path.resolve(__dirname, 'calendar.css'), 'utf-8')
|
||||
expect(css).toMatch(/\.calendar-week\s*{[^}]*grid-template-columns:\s*repeat\(7,\s*minmax\(0,\s*1fr\)\)/)
|
||||
expect(css).toMatch(
|
||||
/\.calendar-week--with-week-numbers\s*{[^}]*grid-template-columns:\s*50px repeat\(7,\s*minmax\(0,\s*1fr\)\)/,
|
||||
)
|
||||
expect(css).toMatch(/\.calendar-day\s*{[^}]*min-width:\s*0/)
|
||||
expect(css).toMatch(/\.calendar-week__day-cell\s*{[^}]*min-width:\s*0/)
|
||||
expect(css).toMatch(/\.calendar-day__holiday-name\s*{[^}]*min-width:\s*0/)
|
||||
})
|
||||
|
||||
it('reserves the same --band-reserved-height on every week row, even though only one week has a band', () => {
|
||||
// Regression guard: `--band-reserved-height` used to be computed per
|
||||
// week from that week's OWN max concurrent lane count, so a week with
|
||||
// a band was taller than a quiet week in the same month. Every row of
|
||||
// the displayed month must reserve the same height now.
|
||||
vi.mocked(usePlannedDays).mockReturnValue(
|
||||
makePlannedDaysResult({
|
||||
data: [makePlannedLeaveDay({ id: 'pd-1', date: '2026-10-05', parentPersonalDataUuid: 'person-1' })],
|
||||
}),
|
||||
)
|
||||
vi.mocked(useSearchParams).mockReturnValue([new URLSearchParams('date=2026-10-15'), mockSetSearchParams])
|
||||
|
||||
const { container } = render(<Calendar />)
|
||||
|
||||
const weekRows = Array.from(container.querySelectorAll('.calendar-week')) as HTMLElement[]
|
||||
expect(weekRows.length).toBeGreaterThan(3)
|
||||
const reservedHeights = weekRows.map((row) => row.style.getPropertyValue('--band-reserved-height'))
|
||||
// Every row reserves the SAME height, and it's non-zero (the month
|
||||
// has at least one band) — a week with no band of its own must still
|
||||
// match the tallest week's reservation, or its row would render
|
||||
// shorter than the others.
|
||||
expect(new Set(reservedHeights).size).toBe(1)
|
||||
expect(reservedHeights[0]).not.toBe('0px')
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -24,7 +24,7 @@ import type {
|
||||
} from '@/types'
|
||||
import { Layout } from '@/components/layout/Layout'
|
||||
import { LoadingSpinner } from '@/components/common/LoadingSpinner'
|
||||
import { CalendarWeek, type BandClickInfo } from '@/components/common/CalendarWeek'
|
||||
import { CalendarWeek, computeWeekLaneCount, type BandClickInfo } from '@/components/common/CalendarWeek'
|
||||
import { PlannedDayModal } from '@/components/modals/PlannedDayModal'
|
||||
import { PeriodPopover, type PeriodPopoverSavePayload } from '@/components/calendar/PeriodPopover'
|
||||
import { PresetRail, type SeasonPreset } from '@/components/calendar/PresetRail'
|
||||
@@ -432,6 +432,16 @@ export function Calendar(): ReactNode {
|
||||
weeks.push(currentWeek)
|
||||
}
|
||||
|
||||
// Every week row must be the same height regardless of how many bands
|
||||
// overlap in it — see `CalendarWeek`'s `monthMaxLaneCount` doc comment.
|
||||
// Computed once here (the month's tallest week) and passed down
|
||||
// uniformly to every `CalendarWeek` below, instead of each week reserving
|
||||
// only its own bottom padding.
|
||||
const monthMaxLaneCount = weeks.reduce(
|
||||
(max, week) => Math.max(max, computeWeekLaneCount(week, plannedDaysHook.data ?? [], calendarDaysByDate)),
|
||||
0,
|
||||
)
|
||||
|
||||
// These four all derive the *other* axis (year when the month changes, and
|
||||
// vice versa) from live state rather than the `year`/`month` consts closed
|
||||
// over by this render. Two selects changed in quick succession (as
|
||||
@@ -894,6 +904,7 @@ export function Calendar(): ReactNode {
|
||||
calendarDaysByDate={calendarDaysByDate}
|
||||
onBandClick={handleBandClick}
|
||||
flashWishId={flashWishId}
|
||||
monthMaxLaneCount={monthMaxLaneCount}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@@ -77,7 +77,12 @@
|
||||
|
||||
.calendar__grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(7, 1fr);
|
||||
/* `minmax(0, 1fr)` not bare `1fr`: a bare `1fr` track's minimum is `auto`
|
||||
* (its content's own min-content width), so a long weekday label could
|
||||
* still widen a column. This header grid has no long content today, but
|
||||
* it must keep the same seven track widths as `.calendar-week` below —
|
||||
* see that rule's own comment for the bug this actually fixes. */
|
||||
grid-template-columns: repeat(7, minmax(0, 1fr));
|
||||
gap: var(--sp-2);
|
||||
margin-bottom: var(--sp-2);
|
||||
}
|
||||
@@ -107,6 +112,12 @@
|
||||
|
||||
.calendar-day {
|
||||
min-height: 84px;
|
||||
/* Without this, a cell's content (in practice `.calendar-day__holiday-name`
|
||||
* below) can impose an intrinsic min-width on the flex item that's wider
|
||||
* than the grid track gives it, which is exactly the "31 October" bug:
|
||||
* a long holiday name in one week's cell widened that row only, since
|
||||
* each week row is its own grid container (see `.calendar-week`). */
|
||||
min-width: 0;
|
||||
flex: 1;
|
||||
padding: var(--sp-2);
|
||||
/* Reserves room at the bottom of the cell for the band lanes overlaid on
|
||||
@@ -233,6 +244,12 @@
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
margin-top: 2px;
|
||||
/* `min-width: 0` + `width: 100%`: a long name (e.g. "Alla helgons dag")
|
||||
* must never grow past its cell — it clips via the ellipsis above instead
|
||||
* of pushing `.calendar-day`'s own intrinsic width out, which is what
|
||||
* distorted only the week row containing it. */
|
||||
min-width: 0;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
/* §3.6 — a workspace day override must look distinct from a plain statutory
|
||||
@@ -314,7 +331,18 @@
|
||||
.calendar-week {
|
||||
position: relative;
|
||||
display: grid;
|
||||
grid-template-columns: repeat(7, 1fr);
|
||||
/* `minmax(0, 1fr)`, not bare `1fr` — the calendar-grid-track-size bug: a
|
||||
* bare `1fr` track's minimum is `auto` (its content's own min-content
|
||||
* width), so a cell whose content is wide (a long holiday name like "Alla
|
||||
* helgons dag" via `.calendar-day__holiday-name`) inflates ONLY that
|
||||
* track. Because each week row is its own separate grid container, that
|
||||
* shows up as one week's seven day cells being narrower than every other
|
||||
* week's — the whole point of a calendar grid is that every cell is the
|
||||
* same size regardless of what's inside it. `minmax(0, 1fr)` clamps each
|
||||
* track's minimum to 0 so content width can never feed back into track
|
||||
* sizing; `.calendar-day`/`.calendar-day__holiday-name` also set their own
|
||||
* `min-width: 0` so nothing downstream re-imposes it. */
|
||||
grid-template-columns: repeat(7, minmax(0, 1fr));
|
||||
column-gap: var(--sp-2);
|
||||
row-gap: 3px;
|
||||
/* Belt-and-braces guard alongside `.calendar__main`'s `overflow-x` and the
|
||||
@@ -332,7 +360,7 @@
|
||||
}
|
||||
|
||||
.calendar-week--with-week-numbers {
|
||||
grid-template-columns: 50px repeat(7, 1fr);
|
||||
grid-template-columns: 50px repeat(7, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
.calendar-week__day-cell {
|
||||
@@ -343,6 +371,11 @@
|
||||
week with no bands collapses to `.calendar-day`'s own min-height and a
|
||||
short row reads as ragged against taller rows that reserve lane space. */
|
||||
display: flex;
|
||||
/* Same track-inflation guard as `.calendar-week`'s `minmax(0, 1fr)`: a flex
|
||||
* item's default `min-width: auto` is its content's min-content size, so
|
||||
* without this the grid item itself (not just the track) could still be
|
||||
* pushed wide by a long holiday name inside `.calendar-day`. */
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
/* One continuous band segment for this week row. Square by default —
|
||||
@@ -631,7 +664,7 @@
|
||||
}
|
||||
|
||||
.calendar__grid--with-week-numbers {
|
||||
grid-template-columns: 50px repeat(7, 1fr);
|
||||
grid-template-columns: 50px repeat(7, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
.calendar__week-number-header {
|
||||
|
||||
Reference in New Issue
Block a user