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 {