diff --git a/frontend/e2e/tests/calendar-grid-uniform.spec.ts b/frontend/e2e/tests/calendar-grid-uniform.spec.ts
new file mode 100644
index 0000000..6cde5af
--- /dev/null
+++ b/frontend/e2e/tests/calendar-grid-uniform.spec.ts
@@ -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)
+})
diff --git a/frontend/src/components/common/CalendarWeek.test.tsx b/frontend/src/components/common/CalendarWeek.test.tsx
index 1e52ffd..1844ae5 100644
--- a/frontend/src/components/common/CalendarWeek.test.tsx
+++ b/frontend/src/components/common/CalendarWeek.test.tsx
@@ -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()
+ 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(
+ ,
+ )
+ 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()
+ 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(
+ <>
+
+
+ >,
+ )
+ 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.
diff --git a/frontend/src/components/common/CalendarWeek.tsx b/frontend/src/components/common/CalendarWeek.tsx
index 57c2fd3..954bcfa 100644
--- a/frontend/src/components/common/CalendarWeek.tsx
+++ b/frontend/src/components/common/CalendarWeek.tsx
@@ -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,
+): number {
+ const segments = computeBandSegments(weekDates, plannedDays, calendarDaysByDate)
+ return segments.length > 0 ? Math.max(...segments.map((b) => b.lane)) + 1 : 0
+}
+
function assignBandLanes(segments: Array>): 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]
diff --git a/frontend/src/pages/Calendar.test.tsx b/frontend/src/pages/Calendar.test.tsx
index 8c72949..93def6e 100644
--- a/frontend/src/pages/Calendar.test.tsx
+++ b/frontend/src/pages/Calendar.test.tsx
@@ -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()
+
+ // 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()
+
+ 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')
+ })
+ })
})
diff --git a/frontend/src/pages/Calendar.tsx b/frontend/src/pages/Calendar.tsx
index c973002..b567794 100644
--- a/frontend/src/pages/Calendar.tsx
+++ b/frontend/src/pages/Calendar.tsx
@@ -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}
/>
))}
diff --git a/frontend/src/pages/calendar.css b/frontend/src/pages/calendar.css
index e916150..fb39fe6 100644
--- a/frontend/src/pages/calendar.css
+++ b/frontend/src/pages/calendar.css
@@ -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 {