From 5faf477266eeb1a5383f000ebae3ff2e0390a5bb Mon Sep 17 00:00:00 2001 From: Jacob Hagstedt Date: Wed, 23 Sep 2026 17:38:27 +0200 Subject: [PATCH] Show a parent's name once per calendar week row, not once per band MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ad-hoc days are banded per leave type and adjacent wishes are separate bands, so a type change inside one parent's row repeated "Name · Type". Only the parent's leftmost band in the row now carries the name; the band aria-label still includes it. Co-Authored-By: Claude Opus 5.5 --- .../components/common/CalendarWeek.test.tsx | 89 +++++++++++++++++++ .../src/components/common/CalendarWeek.tsx | 31 ++++++- 2 files changed, 119 insertions(+), 1 deletion(-) diff --git a/frontend/src/components/common/CalendarWeek.test.tsx b/frontend/src/components/common/CalendarWeek.test.tsx index f5224ab..874951f 100644 --- a/frontend/src/components/common/CalendarWeek.test.tsx +++ b/frontend/src/components/common/CalendarWeek.test.tsx @@ -1155,6 +1155,95 @@ describe('CalendarWeek', () => { }) }) + describe('band names: once per parent per week row, not once per segment', () => { + it('shows the name once when ad-hoc days for one parent split into a vacation run then a sjukpenning run', () => { + // Mon/Tue vacation and Wed-Fri sjukpenning are two separate ad-hoc + // bands (bandKeyFor keys on parent+dayType), not one band with a + // mid-period type change — each has its own single-run label. The + // parent's name belongs only on the leftmost (vacation) one. + const plannedDays = [ + pd({ id: '1', date: '2026-01-05', parentPersonalDataUuid: 'parent-1', dayType: 'vacation' }), + pd({ id: '2', date: '2026-01-06', parentPersonalDataUuid: 'parent-1', dayType: 'vacation' }), + pd({ id: '3', date: '2026-01-07', parentPersonalDataUuid: 'parent-1', dayType: 'sjukpenning' }), + pd({ id: '4', date: '2026-01-08', parentPersonalDataUuid: 'parent-1', dayType: 'sjukpenning' }), + pd({ id: '5', date: '2026-01-09', parentPersonalDataUuid: 'parent-1', dayType: 'sjukpenning' }), + ] + const { container } = render() + expect(screen.getAllByTestId(/^calendar-band-/)).toHaveLength(2) + const labels = Array.from(container.querySelectorAll('.calendar-band__label')) + expect(labels).toHaveLength(2) + expect(labels[0]?.textContent).toContain('Test Parent') + expect(labels[0]?.textContent).toContain('Semester') + expect(labels[1]?.textContent).not.toContain('Test Parent') + expect(labels[1]?.textContent).toContain('Sjukpenning') + }) + + it('shows the name once across two adjacent wishes for the same parent', () => { + const plannedDays = [ + pd({ id: '1', date: '2026-01-05', wishId: 'wish-1', parentPersonalDataUuid: 'parent-1' }), + pd({ id: '2', date: '2026-01-06', wishId: 'wish-1', parentPersonalDataUuid: 'parent-1' }), + pd({ id: '3', date: '2026-01-07', wishId: 'wish-2', parentPersonalDataUuid: 'parent-1' }), + pd({ id: '4', date: '2026-01-08', wishId: 'wish-2', parentPersonalDataUuid: 'parent-1' }), + ] + const { container } = render() + expect(screen.getAllByTestId(/^calendar-band-/)).toHaveLength(2) + const labels = Array.from(container.querySelectorAll('.calendar-band__label')) + expect(labels).toHaveLength(2) + expect(labels[0]?.textContent).toContain('Test Parent') + expect(labels[1]?.textContent).not.toContain('Test Parent') + }) + + it('shows each parent\'s name once, independently, when both parents have a mid-week type change', () => { + const plannedDays = [ + pd({ id: '1', date: '2026-01-05', parentPersonalDataUuid: 'parent-1', dayType: 'vacation' }), + pd({ id: '2', date: '2026-01-06', parentPersonalDataUuid: 'parent-1', dayType: 'sjukpenning' }), + pd({ id: '3', date: '2026-01-07', parentPersonalDataUuid: 'other-parent', dayType: 'vacation' }), + pd({ id: '4', date: '2026-01-08', parentPersonalDataUuid: 'other-parent', dayType: 'sjukpenning' }), + ] + const { container } = render() + const labels = Array.from(container.querySelectorAll('.calendar-band__label')) + expect(labels).toHaveLength(4) + expect(labels[0]?.textContent).toContain('Test Parent') + expect(labels[1]?.textContent).not.toContain('Test Parent') + expect(labels[1]?.textContent).not.toContain('Partner') + expect(labels[2]?.textContent).toContain('Partner') + expect(labels[3]?.textContent).not.toContain('Partner') + expect(labels[3]?.textContent).not.toContain('Test Parent') + }) + + it('still shows the name on a band continuing into the next week row (each row recomputes its own leftmost segment)', () => { + const plannedDays = [ + pd({ id: '1', date: '2026-01-10', wishId: 'wish-1', parentPersonalDataUuid: 'parent-1' }), + pd({ id: '2', date: '2026-01-13', wishId: 'wish-1', parentPersonalDataUuid: 'parent-1' }), + ] + const calendarDaysByDate = new Map([ + ['2026-01-11', calDay({ date: '2026-01-11', dayType: 'weekend' })], + ['2026-01-12', calDay({ date: '2026-01-12', dayType: 'weekend' })], + ]) + const { container } = render( + <> + + + , + ) + const labels = Array.from(container.querySelectorAll('.calendar-band__label')) + expect(labels).toHaveLength(2) + expect(labels[0]?.textContent).toContain('Test Parent') + expect(labels[1]?.textContent).toContain('Test Parent') + }) + }) + describe('per-day hover/focus card', () => { beforeEach(() => { vi.useFakeTimers() diff --git a/frontend/src/components/common/CalendarWeek.tsx b/frontend/src/components/common/CalendarWeek.tsx index 1828dfc..7f33640 100644 --- a/frontend/src/components/common/CalendarWeek.tsx +++ b/frontend/src/components/common/CalendarWeek.tsx @@ -452,6 +452,34 @@ export function CalendarWeek({ [weekDates, plannedDays, calendarDaysByDate], ) + // A parent's leave in one week row can be split across several band + // segments — ad-hoc days are keyed by parent+dayType (`bandKeyFor`), so a + // Mon-Tue vacation stretch and a Wed-Fri sjukpenning stretch for the same + // parent are two segments, not one — and separate wishes are always + // separate segments regardless of adjacency. Showing the parent name on + // every segment's first run would repeat "Name · Type" once per segment; + // the spec is ONE name per parent per week row, on that parent's + // leftmost segment (lowest `startCol`, tie-broken by lowest `lane` for + // determinism when two segments happen to start in the same column). + // Every other segment/run for that parent in this row shows the type + // only. This is computed per week (each row calls `computeBandSegments` + // independently), so a band continuing into the next week row still gets + // its own leftmost-segment name there. + const nameSegmentIdByParent = useMemo(() => { + const bestByParent = new Map() + for (const seg of bandSegments) { + const current = bestByParent.get(seg.parentUuid) + if (!current || seg.startCol < current.startCol || (seg.startCol === current.startCol && seg.lane < current.lane)) { + bestByParent.set(seg.parentUuid, seg) + } + } + const map = new Map() + for (const [parentUuid, seg] of bestByParent) { + map.set(parentUuid, seg.id) + } + return map + }, [bandSegments]) + // Bands overlay the day-cell row itself (grid-row: 1, position: absolute) // instead of occupying separate rows below it — see the comment on // `.calendar-week` in calendar.css. `barHeight`/`laneGap`/`bandBottomInset` @@ -679,6 +707,7 @@ export function CalendarWeek({ const runDubbeldag = segment.dayInfos .slice(run.startIdx, run.endIdx + 1) .some((d) => !d.isGap && d.isDubbeldag) + const showName = runIdx === 0 && nameSegmentIdByParent.get(segment.parentUuid) === segment.id return (