Label each leave-type change on calendar bands and add a per-day hover card

A band showed one label for its first day's type, so a mid-band type change
had no text and the label ran over dark fills in the wrong ink. Each same-type
run now gets its own label and ink (parent name only on the first), the
lägsta outline covers only lägsta days, and hovering/focusing a day shows a
card with parent, date, type, dubbeldag/pinned flags and the period range.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-09-23 09:20:01 +02:00
parent 885108f3e0
commit a14655a831
10 changed files with 868 additions and 88 deletions
@@ -0,0 +1,79 @@
import { describe, it, expect } from 'vitest'
import { render, screen } from '@testing-library/react'
import { BandDayHoverCard } from './BandDayHoverCard'
// No i18n mock here (unlike CalendarWeek.test.tsx) — this exercises the real
// catalogue/interpolation, since the card's whole job is composing several
// translated fragments (type, flags, period range) into one small tooltip.
// tests/setup.ts pins jsdom's navigator.language to sv-SE, so this renders
// the real Swedish copy.
describe('BandDayHoverCard', () => {
const baseProps = {
id: 'hover-card-1',
parentName: 'Alice',
date: '2026-06-05',
isGap: false,
isDubbeldag: false,
isPinned: false,
periodStart: '2026-06-01',
periodEnd: '2026-06-07',
// A detached element is enough for content assertions — jsdom has no
// real layout engine, so `getBoundingClientRect()` on it (or on any
// attached element) returns an all-zero rect either way. Positioning
// itself is this component's own concern and isn't asserted here.
anchorEl: document.createElement('div'),
}
it('renders as a tooltip with the given id', () => {
render(<BandDayHoverCard {...baseProps} dayType="vacation" />)
const tooltip = screen.getByRole('tooltip')
expect(tooltip).toHaveAttribute('id', 'hover-card-1')
})
it('shows the parent name and the day type label', () => {
render(<BandDayHoverCard {...baseProps} dayType="sjukpenning" />)
const tooltip = screen.getByRole('tooltip')
expect(tooltip.textContent).toContain('Alice')
expect(tooltip.textContent).toContain('Sjukpenningnivå')
})
it('shows the weekday and date', () => {
render(<BandDayHoverCard {...baseProps} dayType="vacation" date="2026-06-05" />)
// 2026-06-05 is a Friday.
expect(screen.getByRole('tooltip').textContent).toContain('fredag')
})
it('shows a gap-day explanation instead of a funding type for a bridged gap day', () => {
render(<BandDayHoverCard {...baseProps} dayType={null} isGap={true} />)
const tooltip = screen.getByRole('tooltip')
expect(tooltip.textContent).not.toContain('Semester')
expect(tooltip.textContent).not.toContain('Sjukpenningnivå')
expect(tooltip.textContent).toContain('Helg')
})
it('shows the dubbeldag flag only when the day is a dubbeldag', () => {
const { rerender } = render(<BandDayHoverCard {...baseProps} dayType="vacation" isDubbeldag={false} />)
expect(screen.queryByText('Dubbeldag')).not.toBeInTheDocument()
rerender(<BandDayHoverCard {...baseProps} dayType="vacation" isDubbeldag={true} />)
expect(screen.getByText('Dubbeldag')).toBeInTheDocument()
})
it('shows the pinned flag (reusing calendarDay.pinnedAria wording) only when pinned', () => {
const { rerender } = render(<BandDayHoverCard {...baseProps} dayType="vacation" isPinned={false} />)
expect(screen.queryByText(/låst av dig/)).not.toBeInTheDocument()
rerender(<BandDayHoverCard {...baseProps} dayType="vacation" isPinned={true} />)
expect(screen.getByText(/låst av dig/)).toBeInTheDocument()
})
it('shows the period range and day count', () => {
render(<BandDayHoverCard {...baseProps} dayType="vacation" periodStart="2026-06-01" periodEnd="2026-06-07" />)
const tooltip = screen.getByRole('tooltip')
expect(tooltip.textContent).toContain('2026-06-01')
expect(tooltip.textContent).toContain('2026-06-07')
// Inclusive range: 7 days.
expect(tooltip.textContent).toContain('7')
})
})
@@ -0,0 +1,123 @@
import { ReactNode, useLayoutEffect, useRef, useState } from 'react'
import type { PlannedLeaveDay } from '@/types'
import { useTranslation } from '@/i18n'
import { getLabelForType } from '@/utils/leaveTypes'
import { datesInRange, formatWeekdayAndDate } from '@/utils/dates'
import './band-day-hover-card.css'
/** Vertical gap between the card and the slot it describes, and the margin
* kept from the viewport edge when clamping horizontally — both in px. */
const ANCHOR_GAP_PX = 6
const VIEWPORT_MARGIN_PX = 8
/** Fallback size used before the card has painted once (`offsetWidth`/
* `offsetHeight` are 0 pre-layout) — matches `band-day-hover-card.css`'s
* `max-width`, so the very first frame's guess is already close. */
const FALLBACK_WIDTH_PX = 220
const FALLBACK_HEIGHT_PX = 60
export interface BandDayHoverCardProps {
/** DOM id this card is described by (`aria-describedby` on the hovered
* slot points at it) — see `CalendarWeek`'s `useId()`-generated id. */
id: string
parentName: string
date: string
/** `null` for a bridged gap day (weekend/red day the band passes through
* without a funded row) — see `BandDayInfo.isGap` in CalendarWeek. */
dayType: PlannedLeaveDay['dayType'] | null
isGap: boolean
isDubbeldag: boolean
isPinned: boolean
/** The whole period's date range (not just this row's slice of it), so
* the card always reports the period's true length. */
periodStart: string
periodEnd: string
/** The hovered/focused `.calendar-band__day` slot the card describes and
* positions itself against. `CalendarWeek` portals this component to
* `document.body` (see its render) specifically so the card can escape
* `.calendar-week`'s `overflow: hidden` — that clip is deliberate there
* (it stops a band bleeding into the next row) but would otherwise also
* clip this card, which needs to poke out above the row. Positioning is
* computed from `anchorEl`'s real screen position instead of the band's
* own grid/percentage math, so it works the same regardless of which
* ancestor would otherwise have clipped it, and flips below when hovering
* the calendar's very first row leaves no room above the viewport.
*/
anchorEl: HTMLElement
}
export function BandDayHoverCard({
id,
parentName,
date,
dayType,
isGap,
isDubbeldag,
isPinned,
periodStart,
periodEnd,
anchorEl,
}: BandDayHoverCardProps): ReactNode {
const { t, locale } = useTranslation()
const cardRef = useRef<HTMLDivElement>(null)
const [position, setPosition] = useState<{ top: number; left: number } | null>(null)
useLayoutEffect(() => {
const updatePosition = (): void => {
const anchorRect = anchorEl.getBoundingClientRect()
const cardWidth = cardRef.current?.offsetWidth || FALLBACK_WIDTH_PX
const cardHeight = cardRef.current?.offsetHeight || FALLBACK_HEIGHT_PX
// Prefer above the slot; flip below when there isn't room (the
// calendar's top week row has nothing above it to clip into anyway).
let top = anchorRect.top - cardHeight - ANCHOR_GAP_PX
if (top < VIEWPORT_MARGIN_PX) {
top = anchorRect.bottom + ANCHOR_GAP_PX
}
// Center on the slot, then clamp so a day near either edge (the
// Sunday column, or the leading Monday when week numbers are off)
// never pushes the card off-screen.
let left = anchorRect.left + anchorRect.width / 2 - cardWidth / 2
const maxLeft = window.innerWidth - VIEWPORT_MARGIN_PX - cardWidth
left = Math.min(Math.max(left, VIEWPORT_MARGIN_PX), Math.max(maxLeft, VIEWPORT_MARGIN_PX))
setPosition({ top, left })
}
updatePosition()
// A drag-scroll or a window resize while the card is showing would
// otherwise leave it pointing at empty space instead of the slot.
window.addEventListener('scroll', updatePosition, true)
window.addEventListener('resize', updatePosition)
return () => {
window.removeEventListener('scroll', updatePosition, true)
window.removeEventListener('resize', updatePosition)
}
}, [anchorEl])
const typeLabel = isGap ? t('calendarBand.hover.gapDay') : getLabelForType(dayType ?? 'vacation', t)
const dayCount = datesInRange(periodStart, periodEnd).length
const periodLabel = t('calendarBand.hover.period', { start: periodStart, end: periodEnd, count: dayCount })
return (
<div
ref={cardRef}
id={id}
role="tooltip"
className="band-day-hover-card"
style={{
top: `${position?.top ?? -9999}px`,
left: `${position?.left ?? -9999}px`,
}}
>
<div className="band-day-hover-card__header">
<span className="band-day-hover-card__parent">{parentName}</span>
<span className="band-day-hover-card__date">{formatWeekdayAndDate(date, locale)}</span>
</div>
<div className="band-day-hover-card__type">{typeLabel}</div>
{isDubbeldag && <div className="band-day-hover-card__flag">{t('calendarBand.hover.dubbeldag')}</div>}
{isPinned && <div className="band-day-hover-card__flag">{t('calendarDay.pinnedAria')}</div>}
<div className="band-day-hover-card__period">{periodLabel}</div>
</div>
)
}
@@ -0,0 +1,63 @@
/* Calendar band day hover/focus card — a small floating tooltip describing
* the hovered/focused day slot inside a band (see CalendarWeek's `hoveredDay`
* state and BandDayHoverCard). `position: fixed` with `top`/`left` computed
* in BandDayHoverCard.tsx from the slot's real screen position, and the
* component is portalled to `document.body` by CalendarWeek — NOT rendered
* inside `.calendar-week`/`.calendar-band`, both of which clip via
* `overflow: hidden` (deliberately, so a band never bleeds into the next
* row — see calendar.css). A percentage/grid position relative to either
* ancestor would be clipped the moment the card pokes above the row, which
* is most of the time (a band sits near the bottom of its cell); the fixed
* position plus portal is what actually escapes that, in every row
* including the calendar's first one. Surface/shadow mirror the modal shell
* (`--c-surface`/`--shadow-float`, see primitives.css) since this is the
* app's other floating layer, just far smaller and non-interactive. Sized
* to content (bilingual rule — Swedish strings run longer than English). */
.band-day-hover-card {
position: fixed;
z-index: var(--z-popover);
display: flex;
flex-direction: column;
gap: 2px;
width: max-content;
max-width: 220px;
padding: var(--sp-2) var(--sp-3);
background-color: var(--c-surface);
border-radius: var(--radius-sm);
box-shadow: var(--shadow-float);
color: var(--c-text);
font-size: var(--fs-xs);
line-height: 1.35;
pointer-events: none;
white-space: normal;
}
.band-day-hover-card__header {
display: flex;
flex-direction: column;
font-weight: 600;
}
.band-day-hover-card__parent {
color: var(--c-text);
}
.band-day-hover-card__date {
font-weight: 400;
color: var(--c-text-muted);
}
.band-day-hover-card__type {
color: var(--c-text);
}
.band-day-hover-card__flag {
color: var(--c-text-muted);
}
.band-day-hover-card__period {
margin-top: 2px;
padding-top: 2px;
border-top: 1px solid var(--c-border);
color: var(--c-text-muted);
}
@@ -1,7 +1,7 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { render, screen, fireEvent } from '@testing-library/react'
import { CalendarWeek } from './CalendarWeek'
import type { BandClickInfo } from './CalendarWeek'
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
import { render, screen, fireEvent, act } from '@testing-library/react'
import { CalendarWeek, computeTypeRuns } from './CalendarWeek'
import type { BandClickInfo, BandDayInfo } from './CalendarWeek'
import type { PlannedLeaveDay, CalendarDayEntry } from '@/types'
import type { CalendarDayProps } from './CalendarDay'
import type { CalendarEventProps } from './CalendarEvent'
@@ -15,9 +15,13 @@ vi.mock('@/i18n', () => ({
'leaveType.sjukpenning': 'Sjukpenning',
'leaveType.lagsta': 'Lägsta nivå',
'calendarDay.pinnedAria': 'pinned',
'calendarBand.hover.gapDay': 'Weekend or public holiday',
'calendarBand.hover.dubbeldag': 'Double day',
'calendarBand.hover.period': 'period range',
}
return translations[key] ?? key
},
locale: 'sv',
}),
}))
@@ -87,6 +91,16 @@ function calDay(overrides: Partial<CalendarDayEntry> & Pick<CalendarDayEntry, 'd
}
}
function bandDayInfo(overrides: Partial<BandDayInfo> & Pick<BandDayInfo, 'date'>): BandDayInfo {
return {
dayType: 'vacation',
isDubbeldag: false,
isPinned: false,
isGap: false,
...overrides,
}
}
describe('CalendarWeek', () => {
const defaultProps = {
weekDates: ['2026-01-05', '2026-01-06', '2026-01-07', '2026-01-08', '2026-01-09', '2026-01-10', '2026-01-11'],
@@ -1013,4 +1027,225 @@ describe('CalendarWeek', () => {
expect(cell).toHaveAttribute('data-is-override', 'false')
})
})
describe('computeTypeRuns', () => {
it('returns one run spanning the single day', () => {
const runs = computeTypeRuns([bandDayInfo({ date: '2026-01-05', dayType: 'vacation' })])
expect(runs).toEqual([{ typeKey: 'vacation', dayType: 'vacation', startIdx: 0, endIdx: 0 }])
})
it('splits into one run per day when every day has a different type (alternating types)', () => {
const runs = computeTypeRuns([
bandDayInfo({ date: '2026-01-05', dayType: 'vacation' }),
bandDayInfo({ date: '2026-01-06', dayType: 'sjukpenning' }),
bandDayInfo({ date: '2026-01-07', dayType: 'lagsta' }),
])
expect(runs).toEqual([
{ typeKey: 'vacation', dayType: 'vacation', startIdx: 0, endIdx: 0 },
{ typeKey: 'sjukpenning', dayType: 'sjukpenning', startIdx: 1, endIdx: 1 },
{ typeKey: 'lagsta', dayType: 'lagsta', startIdx: 2, endIdx: 2 },
])
})
it('absorbs leading gaps into the first run', () => {
const runs = computeTypeRuns([
bandDayInfo({ date: '2026-01-03', isGap: true, dayType: null }),
bandDayInfo({ date: '2026-01-04', isGap: true, dayType: null }),
bandDayInfo({ date: '2026-01-05', dayType: 'vacation' }),
])
expect(runs).toEqual([{ typeKey: 'vacation', dayType: 'vacation', startIdx: 0, endIdx: 2 }])
})
it('keeps a gap between two same-type days inside one run (Friday-to-Monday)', () => {
const runs = computeTypeRuns([
bandDayInfo({ date: '2026-01-09', dayType: 'vacation' }),
bandDayInfo({ date: '2026-01-10', isGap: true, dayType: null }),
bandDayInfo({ date: '2026-01-11', isGap: true, dayType: null }),
bandDayInfo({ date: '2026-01-12', dayType: 'vacation' }),
])
expect(runs).toEqual([{ typeKey: 'vacation', dayType: 'vacation', startIdx: 0, endIdx: 3 }])
})
it('ends a run at a gap that is followed by a different type, joining the gap to the earlier run', () => {
const runs = computeTypeRuns([
bandDayInfo({ date: '2026-01-09', dayType: 'vacation' }),
bandDayInfo({ date: '2026-01-10', isGap: true, dayType: null }),
bandDayInfo({ date: '2026-01-11', isGap: true, dayType: null }),
bandDayInfo({ date: '2026-01-12', dayType: 'sjukpenning' }),
])
expect(runs).toEqual([
{ typeKey: 'vacation', dayType: 'vacation', startIdx: 0, endIdx: 2 },
{ typeKey: 'sjukpenning', dayType: 'sjukpenning', startIdx: 3, endIdx: 3 },
])
})
})
describe('mixed-type bands: one label per run, not one band-wide label', () => {
it('renders one label per same-type run in a band whose funding type changes mid-period', () => {
const plannedDays = [
pd({ id: '1', date: '2026-01-05', wishId: 'wish-1', dayType: 'vacation' }),
pd({ id: '2', date: '2026-01-06', wishId: 'wish-1', dayType: 'sjukpenning' }),
pd({ id: '3', date: '2026-01-07', wishId: 'wish-1', dayType: 'lagsta' }),
]
const { container } = render(<CalendarWeek {...defaultProps} plannedDays={plannedDays} parentName="Alice" />)
const labels = container.querySelectorAll('.calendar-band__label')
expect(labels).toHaveLength(3)
})
it('gives only the first run the parent name; later runs show the type alone', () => {
const plannedDays = [
pd({ id: '1', date: '2026-01-05', wishId: 'wish-1', dayType: 'vacation' }),
pd({ id: '2', date: '2026-01-06', wishId: 'wish-1', dayType: 'sjukpenning' }),
]
const { container } = render(<CalendarWeek {...defaultProps} plannedDays={plannedDays} parentName="Alice" />)
const labels = Array.from(container.querySelectorAll('.calendar-band__label'))
expect(labels[0]?.textContent).toContain('Alice')
expect(labels[1]?.textContent).not.toContain('Alice')
expect(labels[1]?.textContent).toContain('Sjukpenning')
})
it('inks each run label from its own type, not the band\'s first day', () => {
const plannedDays = [
pd({ id: '1', date: '2026-01-05', wishId: 'wish-1', dayType: 'vacation' }),
pd({ id: '2', date: '2026-01-06', wishId: 'wish-1', dayType: 'sjukpenning' }),
]
const { container } = render(<CalendarWeek {...defaultProps} plannedDays={plannedDays} />)
const labels = Array.from(container.querySelectorAll<HTMLElement>('.calendar-band__label'))
expect(labels[0]?.style.color).toBe('var(--c-leave-vacation-ink)')
expect(labels[1]?.style.color).toBe('var(--c-leave-sjukpenning-ink)')
})
it('keeps a same-type run spanning a weekend as one label, not split by the bridged gap', () => {
const plannedDays = [
pd({ id: '1', date: '2026-01-09', wishId: 'wish-1', dayType: 'vacation' }),
pd({ id: '2', date: '2026-01-12', wishId: 'wish-1', dayType: 'vacation' }),
]
const calendarDaysByDate = new Map<string, CalendarDayEntry>([
['2026-01-10', calDay({ date: '2026-01-10', dayType: 'weekend' })],
['2026-01-11', calDay({ date: '2026-01-11', dayType: 'weekend' })],
])
const { container } = render(
<CalendarWeek {...defaultProps} plannedDays={plannedDays} calendarDaysByDate={calendarDaysByDate} />,
)
expect(container.querySelectorAll('.calendar-band__label')).toHaveLength(1)
})
it('marks only the lägsta run with the outline overlay, not the whole mixed band', () => {
const plannedDays = [
pd({ id: '1', date: '2026-01-05', wishId: 'wish-1', dayType: 'vacation' }),
pd({ id: '2', date: '2026-01-06', wishId: 'wish-1', dayType: 'lagsta' }),
pd({ id: '3', date: '2026-01-07', wishId: 'wish-1', dayType: 'sjukpenning' }),
]
const { container } = render(<CalendarWeek {...defaultProps} plannedDays={plannedDays} />)
const outlines = container.querySelectorAll<HTMLElement>('.calendar-band__run--lagsta')
expect(outlines).toHaveLength(1)
// 3 equal-width slots: the lägsta day is the middle third.
expect(outlines[0]?.style.left).toBe(`${(1 / 3) * 100}%`)
})
it('marks each run\'s own dubbeldag flag rather than flagging the whole band', () => {
const plannedDays = [
pd({ id: '1', date: '2026-01-05', wishId: 'wish-1', dayType: 'vacation', isDubbeldag: false }),
pd({ id: '2', date: '2026-01-06', wishId: 'wish-1', dayType: 'sjukpenning', isDubbeldag: true }),
]
const { container } = render(<CalendarWeek {...defaultProps} plannedDays={plannedDays} />)
const labels = Array.from(container.querySelectorAll('.calendar-band__label'))
expect(labels[0]?.querySelector('[data-testid="calendar-event"]')).not.toHaveClass('dubbeldag')
expect(labels[1]?.querySelector('[data-testid="calendar-event"]')).toHaveClass('dubbeldag')
})
})
describe('per-day hover/focus card', () => {
beforeEach(() => {
vi.useFakeTimers()
})
afterEach(() => {
vi.useRealTimers()
})
it('shows nothing until the hover delay elapses, then shows the card for the hovered day', () => {
const plannedDays = [pd({ id: '1', date: '2026-01-05', dayType: 'sjukpenning' })]
const { container } = render(<CalendarWeek {...defaultProps} plannedDays={plannedDays} parentName="Alice" />)
const slot = container.querySelector('.calendar-band__day')!
fireEvent.pointerEnter(slot)
expect(screen.queryByRole('tooltip')).not.toBeInTheDocument()
act(() => {
vi.advanceTimersByTime(150)
})
const tooltip = screen.getByRole('tooltip')
expect(tooltip.textContent).toContain('Alice')
expect(tooltip.textContent).toContain('Sjukpenning')
expect(slot).toHaveAttribute('aria-describedby', tooltip.id)
})
it('does not show the card while a pointer button is held anywhere on the page (dragging)', () => {
const plannedDays = [pd({ id: '1', date: '2026-01-05' })]
const { container } = render(<CalendarWeek {...defaultProps} plannedDays={plannedDays} />)
const slot = container.querySelector('.calendar-band__day')!
// A drag-select starts with a pointerdown that bubbles to the window —
// CalendarWeek tracks that globally rather than reading the
// pointerenter event's own `buttons` bitmask (jsdom doesn't populate
// it), same as a real drag started elsewhere in the grid and dragged
// over this slot.
fireEvent.pointerDown(slot)
fireEvent.pointerEnter(slot)
act(() => {
vi.advanceTimersByTime(150)
})
expect(screen.queryByRole('tooltip')).not.toBeInTheDocument()
fireEvent.pointerUp(slot)
})
it('hides the card on pointer leave', () => {
const plannedDays = [pd({ id: '1', date: '2026-01-05' })]
const { container } = render(<CalendarWeek {...defaultProps} plannedDays={plannedDays} />)
const slot = container.querySelector('.calendar-band__day')!
fireEvent.pointerEnter(slot)
act(() => {
vi.advanceTimersByTime(150)
})
expect(screen.getByRole('tooltip')).toBeInTheDocument()
fireEvent.pointerLeave(slot)
expect(screen.queryByRole('tooltip')).not.toBeInTheDocument()
})
it('shows the gap-day text, not a funding type, for a bridged gap slot', () => {
const plannedDays = [
pd({ id: '1', date: '2026-01-09', wishId: 'wish-1' }),
pd({ id: '2', date: '2026-01-12', wishId: 'wish-1' }),
]
const calendarDaysByDate = new Map<string, CalendarDayEntry>([
['2026-01-10', calDay({ date: '2026-01-10', dayType: 'weekend' })],
['2026-01-11', calDay({ date: '2026-01-11', dayType: 'weekend' })],
])
const { container } = render(
<CalendarWeek {...defaultProps} plannedDays={plannedDays} calendarDaysByDate={calendarDaysByDate} />,
)
const gapSlot = container.querySelector('.calendar-band__day--gap')!
fireEvent.pointerEnter(gapSlot)
act(() => {
vi.advanceTimersByTime(150)
})
expect(screen.getByRole('tooltip').textContent).toContain('Weekend or public holiday')
})
it('shows the card on focus too, without requiring a pointer', () => {
const plannedDays = [pd({ id: '1', date: '2026-01-05' })]
const { container } = render(<CalendarWeek {...defaultProps} plannedDays={plannedDays} />)
const slot = container.querySelector('.calendar-band__day')!
fireEvent.focus(slot)
act(() => {
vi.advanceTimersByTime(150)
})
expect(screen.getByRole('tooltip')).toBeInTheDocument()
fireEvent.blur(slot)
expect(screen.queryByRole('tooltip')).not.toBeInTheDocument()
})
})
})
+276 -47
View File
@@ -1,10 +1,17 @@
import { ReactNode, useMemo } from 'react'
import { ReactNode, useEffect, useId, useMemo, useRef, useState } from 'react'
import { createPortal } from 'react-dom'
import type { PlannedLeaveDay, CalendarDayEntry } from '@/types'
import { getColorForType, getInkForType, getLabelForType, getTypeKey } from '@/utils/leaveTypes'
import { getColorForType, getInkForType, getLabelForType, getTypeKey, type LeaveTypeKey } from '@/utils/leaveTypes'
import { getISOWeekNumber, formatDateLocalISO } from '@/utils/dates'
import { useTranslation } from '@/i18n'
import { CalendarDay } from './CalendarDay'
import { CalendarEvent } from './CalendarEvent'
import { BandDayHoverCard } from '@/components/calendar/BandDayHoverCard'
/** Delay before the per-day hover/focus card appears — long enough that a
* pointer merely passing through several slots (dragging across the grid,
* or just moving the mouse toward something else) never flashes it. */
const HOVER_SHOW_DELAY_MS = 150
/** Fired when a band (a period of planned days, possibly spanning several
* weeks) is clicked. `wishId` is null for an ad-hoc period not tied to a
@@ -74,7 +81,7 @@ export interface CalendarWeekProps {
* (`isGap: false`, carrying its own funding type/dubbeldag/pinned flags) or
* a bridged free-day gap (a weekend/red day the band passes through without
* needing a funded row — see `isBridgeableGap`). */
interface BandDayInfo {
export interface BandDayInfo {
date: string
dayType: PlannedLeaveDay['dayType'] | null
isDubbeldag: boolean
@@ -82,6 +89,91 @@ interface BandDayInfo {
isGap: boolean
}
/** A run of consecutive same-type days inside one band segment's
* `dayInfos` — see `computeTypeRuns`. `startIdx`/`endIdx` are inclusive
* indices into that segment's `dayInfos` array. */
export interface TypeRun {
typeKey: LeaveTypeKey
dayType: PlannedLeaveDay['dayType'] | null
startIdx: number
endIdx: number
}
/**
* Splits a band segment's per-day info into runs of consecutive same-type
* days, so each run can get its own readable label instead of one label
* (inked for the first day's type) covering the whole segment.
*
* A bridged gap day (weekend/red day, `isGap: true`) has no type of its
* own, so it joins whichever run it sits next to:
* - a gap between two real days of the SAME type stays inside that one run
* (a Friday→Monday vacation stretch across a weekend is one run, not
* three);
* - a gap immediately followed by a real day of a DIFFERENT type joins the
* run BEFORE it — the new type starts its own run at the next real day;
* - gap(s) with no real day before them yet (this segment is a week row
* that opens mid-gap, continuing a band that started earlier in the
* previous week) join the first run once it's found, rather than being
* orphaned before it.
*/
export function computeTypeRuns(dayInfos: BandDayInfo[]): TypeRun[] {
const runs: TypeRun[] = []
let i = 0
while (i < dayInfos.length && dayInfos[i]?.isGap) i++
const hasLeadingGaps = i > 0
while (i < dayInfos.length) {
const info = dayInfos[i]
if (!info) {
i++
continue
}
const dayType = info.dayType
const typeKey = getTypeKey(dayType ?? 'vacation')
const startIdx = i
let endIdx = i
i++
while (i < dayInfos.length) {
const next = dayInfos[i]
if (!next) break
if (next.isGap) {
endIdx = i
i++
continue
}
if (next.dayType === dayType) {
endIdx = i
i++
continue
}
break
}
runs.push({ typeKey, dayType, startIdx, endIdx })
}
// A leading gap (no real day before it in this segment) belongs to the
// first run once one exists — this segment is a continuation row, not a
// segment with no type at all (a band's dayInfos always contains at
// least one real day; only fully-bridged weeks are pure gaps).
const firstRun = runs[0]
if (hasLeadingGaps && firstRun) {
firstRun.startIdx = 0
}
// Defensive fallback for a segment that is entirely bridged gaps (a whole
// week bridged by a wish that starts/ends outside it) — not expected in
// practice, but rendering nothing would silently drop the row.
if (runs.length === 0 && dayInfos.length > 0) {
runs.push({ typeKey: 'vacation', dayType: null, startIdx: 0, endIdx: dayInfos.length - 1 })
}
return runs
}
interface BandSegment {
id: string
wishId: string | null
@@ -299,6 +391,62 @@ export function CalendarWeek({
monthMaxLaneCount,
}: CalendarWeekProps): ReactNode {
const { t } = useTranslation()
const hoverCardId = useId()
const [hoveredDay, setHoveredDay] = useState<{ segmentId: string; date: string; anchorEl: HTMLElement } | null>(
null,
)
const hoverTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null)
// Tracks whether a pointer button is currently held anywhere on the page —
// read (not stored in state) so a drag-select in progress never surfaces
// the card, without re-rendering the whole week on every pointerdown/up.
const pointerDownRef = useRef(false)
useEffect(() => {
const handlePointerDown = (): void => {
pointerDownRef.current = true
}
const handlePointerUp = (): void => {
pointerDownRef.current = false
}
window.addEventListener('pointerdown', handlePointerDown)
window.addEventListener('pointerup', handlePointerUp)
return () => {
window.removeEventListener('pointerdown', handlePointerDown)
window.removeEventListener('pointerup', handlePointerUp)
}
}, [])
useEffect(
() => () => {
if (hoverTimeoutRef.current !== null) clearTimeout(hoverTimeoutRef.current)
},
[],
)
const clearHoverTimeout = (): void => {
if (hoverTimeoutRef.current !== null) {
clearTimeout(hoverTimeoutRef.current)
hoverTimeoutRef.current = null
}
}
// Delayed show avoids flicker while a pointer drags across many day
// slots; an active drag-select (a pointer button held anywhere) must
// never surface the card at all. `anchorEl` (the slot itself) is what the
// portalled card positions itself against — see BandDayHoverCard's doc
// comment for why it needs the real DOM element rather than a percentage
// position handed down from here.
const scheduleHoverShow = (segmentId: string, date: string, anchorEl: HTMLElement): void => {
clearHoverTimeout()
if (pointerDownRef.current) return
hoverTimeoutRef.current = setTimeout(() => setHoveredDay({ segmentId, date, anchorEl }), HOVER_SHOW_DELAY_MS)
}
const hideHover = (): void => {
clearHoverTimeout()
setHoveredDay(null)
}
const bandSegments = useMemo(
() => computeBandSegments(weekDates, plannedDays, calendarDaysByDate),
[weekDates, plannedDays, calendarDaysByDate],
@@ -394,21 +542,18 @@ export function CalendarWeek({
const firstRealDay = segment.dayInfos.find((d) => !d.isGap)
const representativeType = firstRealDay?.dayType ?? 'vacation'
const typeLabel = getLabelForType(representativeType, t)
const color = getColorForType(representativeType)
const ink = getInkForType(representativeType)
const anyDubbeldag = segment.dayInfos.some((d) => !d.isGap && d.isDubbeldag)
// Type by fill/ink/outline (`--vacation|--sjukpenning|--lagsta`,
// see the --c-leave-* tokens) and owner by a 2px left cap
// (`--self`/`--partner`) — never by hue, so the two axes stay
// readable side by side and in greyscale.
// readable side by side and in greyscale. The band itself only
// keeps the owner cap now — a mixed-type band's fill/outline is
// drawn per run below (see `runs`), not band-wide from whichever
// type happens to come first.
const typeKey = getTypeKey(representativeType)
const classNames = [
'calendar-band',
`calendar-band--${typeKey}`,
// `leave-swatch--*` (primitives.css) carries the shared
// fill/hatch/outline/cap-mark recipe; `unknown` has no recipe.
typeKey !== 'unknown' ? `leave-swatch--${typeKey}` : '',
segment.parentUuid === personalDataUuid ? 'calendar-band--self' : 'calendar-band--partner',
segment.parentUuid === personalDataUuid ? 'leave-swatch--cap-self' : 'leave-swatch--cap-partner',
segment.isBandStart ? 'calendar-band--start' : '',
@@ -418,6 +563,9 @@ export function CalendarWeek({
.filter(Boolean)
.join(' ')
const dayCount = segment.dayInfos.length
const runs = computeTypeRuns(segment.dayInfos)
const fireClick = (clickedDate?: string) =>
onBandClick?.({
wishId: segment.wishId,
@@ -429,6 +577,12 @@ export function CalendarWeek({
clickedDate: clickedDate ?? segment.overallStart,
})
const hoveredInfo =
hoveredDay?.segmentId === segment.id
? segment.dayInfos.find((d) => d.date === hoveredDay.date)
: undefined
const hoveredIdx = hoveredInfo ? segment.dayInfos.indexOf(hoveredInfo) : -1
return (
<div
key={segment.id}
@@ -438,8 +592,6 @@ export function CalendarWeek({
gridRow: '1',
bottom: `${bandBottomInset + segment.lane * (barHeight + laneGap)}px`,
height: `${barHeight}px`,
backgroundColor: color,
color: ink,
}}
data-testid={`calendar-band-${segment.id}`}
aria-label={`${label} · ${typeLabel}, ${segment.overallStart} – ${segment.overallEnd}`}
@@ -458,43 +610,120 @@ export function CalendarWeek({
}
>
<div className="calendar-band__days">
{segment.dayInfos.map((info) => (
<span
key={info.date}
className={[
'calendar-band__day',
info.isGap ? 'calendar-band__day--gap' : `calendar-band__day--${getTypeKey(info.dayType ?? 'vacation')}`,
info.isPinned ? 'calendar-band__day--pinned' : '',
]
.filter(Boolean)
.join(' ')}
style={info.isGap ? undefined : { backgroundColor: getColorForType(info.dayType ?? 'vacation') }}
title={info.isPinned ? t('calendarDay.pinnedAria') : undefined}
data-testid={info.isPinned ? 'calendar-band-day-pinned' : undefined}
// Clicking one day slot opens the same popover as clicking
// the band, but tells it WHICH date was clicked — "this
// day" must be able to remove a day in the middle of a
// period, not only its first.
onClick={
onBandClick
? (e) => {
e.stopPropagation()
fireClick(info.date)
}
: undefined
}
{segment.dayInfos.map((info) => {
const isHovered = hoveredDay?.segmentId === segment.id && hoveredDay.date === info.date
return (
<span
key={info.date}
className={[
'calendar-band__day',
info.isGap ? 'calendar-band__day--gap' : `calendar-band__day--${getTypeKey(info.dayType ?? 'vacation')}`,
info.isPinned ? 'calendar-band__day--pinned' : '',
]
.filter(Boolean)
.join(' ')}
style={info.isGap ? undefined : { backgroundColor: getColorForType(info.dayType ?? 'vacation') }}
data-testid={info.isPinned ? 'calendar-band-day-pinned' : undefined}
tabIndex={onBandClick ? 0 : undefined}
aria-describedby={isHovered ? hoverCardId : undefined}
// Clicking one day slot opens the same popover as clicking
// the band, but tells it WHICH date was clicked — "this
// day" must be able to remove a day in the middle of a
// period, not only its first.
onClick={
onBandClick
? (e) => {
e.stopPropagation()
fireClick(info.date)
}
: undefined
}
onKeyDown={
onBandClick
? (e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault()
e.stopPropagation()
fireClick(info.date)
}
}
: undefined
}
// The hover/focus card (below) replaces the old `title`
// attribute for a pinned day — a native tooltip and this
// card fighting over the same slot just doubles up.
onPointerEnter={(e) => scheduleHoverShow(segment.id, info.date, e.currentTarget)}
onPointerDown={hideHover}
onPointerLeave={hideHover}
onFocus={(e) => scheduleHoverShow(segment.id, info.date, e.currentTarget)}
onBlur={hideHover}
/>
)
})}
</div>
{runs
.filter((run) => run.typeKey === 'lagsta')
.map((run) => (
<div
key={`lagsta-${run.startIdx}`}
className="calendar-band__run calendar-band__run--lagsta"
style={{
left: `${(run.startIdx / dayCount) * 100}%`,
width: `${((run.endIdx - run.startIdx + 1) / dayCount) * 100}%`,
}}
/>
))}
</div>
<div className="calendar-band__label">
<CalendarEvent
leaveType={typeLabel}
color={color}
isDubbeldag={anyDubbeldag}
parentLabel={label}
paintBackground={false}
/>
</div>
{runs.map((run, runIdx) => {
const runDayType = run.dayType ?? 'vacation'
const runTypeLabel = getLabelForType(runDayType, t)
const runDubbeldag = segment.dayInfos
.slice(run.startIdx, run.endIdx + 1)
.some((d) => !d.isGap && d.isDubbeldag)
return (
<div
key={`label-${run.startIdx}`}
className="calendar-band__label"
style={{
left: `${(run.startIdx / dayCount) * 100}%`,
width: `${((run.endIdx - run.startIdx + 1) / dayCount) * 100}%`,
color: getInkForType(runDayType),
}}
>
<CalendarEvent
leaveType={runTypeLabel}
color={getColorForType(runDayType)}
isDubbeldag={runDubbeldag}
parentLabel={runIdx === 0 ? label : undefined}
paintBackground={false}
/>
</div>
)
})}
{hoveredInfo &&
hoveredIdx >= 0 &&
hoveredDay &&
createPortal(
// Portalled to document.body (not rendered as a normal
// descendant here) so the card escapes `.calendar-week`'s
// and `.calendar-band`'s `overflow: hidden` — both clip
// deliberately (a band must never bleed into the next row),
// which would otherwise also invisibly clip this card the
// moment it pokes above its row. See BandDayHoverCard's doc
// comment for how it then positions itself from `anchorEl`.
<BandDayHoverCard
id={hoverCardId}
parentName={label}
date={hoveredInfo.date}
dayType={hoveredInfo.dayType}
isGap={hoveredInfo.isGap}
isDubbeldag={hoveredInfo.isDubbeldag}
isPinned={hoveredInfo.isPinned}
periodStart={segment.overallStart}
periodEnd={segment.overallEnd}
anchorEl={hoveredDay.anchorEl}
/>,
document.body,
)}
</div>
)
})}
+8
View File
@@ -226,4 +226,12 @@ export const calendar = {
'calendar.wantsDubbeldagHint':
"Both parents are off the same day and each spend one of their own parental-benefit days (no vacation day is used) — only available in the child's first 15 months, capped at 60 dates per child, shared between you.",
'calendar.wantsDubbeldagRequiresBothHint': 'Requires the period to apply to both parents.',
// Calendar band hover/focus card (see BandDayHoverCard) — one label per
// same-type run inside a band replaced the single band-wide label, so a
// per-day card fills in the detail (parent, date, type, flags, period
// range) that no longer fits in the run label itself.
'calendarBand.hover.gapDay': 'Weekend or public holiday — no leave day is drawn here',
'calendarBand.hover.dubbeldag': 'Double day',
'calendarBand.hover.period': '{start} – {end} · {count} {count|day|days}',
} as const
+9
View File
@@ -227,4 +227,13 @@ export const calendar = {
'calendar.wantsDubbeldagHint':
'Båda föräldrarna är lediga samma dag och tar var sin egen föräldrapenningdag (ingen semesterdag går åt) — gäller bara de första 15 månaderna och max 60 dagar per barn, delat mellan er.',
'calendar.wantsDubbeldagRequiresBothHint': 'Kräver att perioden gäller båda föräldrarna.',
// Kort som visas vid hover/fokus på en dag i ett band (se
// BandDayHoverCard) — en etikett per sammanhängande typ i ett band
// ersatte den enda bandbreda etiketten, så det här kortet fyller i
// detaljen (förälder, datum, typ, flaggor, periodens intervall) som inte
// längre får plats i själva etiketten.
'calendarBand.hover.gapDay': 'Helg eller röd dag — ingen ledighetsdag ritas här',
'calendarBand.hover.dubbeldag': 'Dubbeldag',
'calendarBand.hover.period': '{start} – {end} · {count} {count|dag|dagar}',
} as const
+39 -14
View File
@@ -404,9 +404,12 @@
border-bottom-right-radius: var(--radius-band);
}
/* The owner cap and lägsta's outline are drawn on an overlay above the
* per-day ticks (which would otherwise paint over an inset shadow on the
* band itself) and below the label. */
/* The owner cap is drawn on an overlay above the per-day ticks (which would
* otherwise paint over an inset shadow on the band itself) and below the
* label. Lägsta's outline used to be composed here too, band-wide from
* whichever type happened to be the band's first day — now it's a per-run
* overlay instead (`.calendar-band__run--lagsta` below), since a band can
* mix types day to day. */
.calendar-band::after {
content: '';
position: absolute;
@@ -416,14 +419,12 @@
border-radius: inherit;
}
/* The self/partner cap-mark and lägsta's outline are the shared
* `.leave-swatch--cap-self`/`--cap-partner`/`--lagsta` recipe from
* primitives.css, scoped to `::after` here — see the comment there and on
* `.calendar-band::after` above for why. `getTypeKey`'s `--lagsta`/
* `leave-swatch--lagsta` and the owner's `--self`/`--partner`/
* `leave-swatch--cap-*` are added side by side in CalendarWeek.tsx, so
* lägsta's outline and the owner's cap keep composing even though this
* file no longer states the box-shadow values itself. */
/* The self/partner cap-mark is the shared `.leave-swatch--cap-self`/
* `--cap-partner` recipe from primitives.css, scoped to `::after` here —
* see the comment there and on `.calendar-band::after` above for why.
* `CalendarWeek.tsx` adds the class alongside the band's own modifiers, so
* the owner's cap composes onto the band without this file stating the
* box-shadow value itself. */
.calendar-band__day--lagsta {
background-image: repeating-linear-gradient(
@@ -453,11 +454,17 @@
}
}
/* The label: 12px/500 in the type's ink (set inline on the band from
* getInkForType), no shadow. */
/* One label per same-type run inside the band (see `computeTypeRuns` in
* CalendarWeek.tsx), not one label for the whole band — a mixed-type band
* (vacation → sjukpenning mid-period) would otherwise show unlabelled text
* in the wrong ink over the later type's days. Each instance is positioned
* over its own run via inline `left`/`width` percentages and inked from
* that run's own type (inline `color`, from getInkForType), 12px/500, no
* shadow. */
.calendar-band__label {
position: absolute;
inset: 0;
top: 0;
bottom: 0;
z-index: 2;
display: flex;
align-items: center;
@@ -465,6 +472,24 @@
font-size: var(--fs-xs);
color: inherit;
pointer-events: none;
min-width: 0;
}
/* A per-run decoration overlay, sized/positioned like `.calendar-band__label`
* above but drawing no text — currently only the lägsta outline, which used
* to be band-wide (drawn from the first day's type) and wrongly outlined a
* mixed band across every day. Sits above the per-day ticks, below the
* label. */
.calendar-band__run {
position: absolute;
top: 0;
bottom: 0;
pointer-events: none;
}
.calendar-band__run--lagsta {
z-index: 1;
box-shadow: inset 0 0 0 1px var(--c-leave-lagsta-outline);
}
/* The per-day funding-type strip: a period is ONE band, but each day still
+14 -23
View File
@@ -463,8 +463,10 @@
/* Modal shell
* ------------------------------------------------------------------------
* Shared by PeriodPopover, PlannedDayModal, ParentBalanceForm and
* QRCodeModal. The one floating layer in the app, so the one place
* --shadow-float is used. Header and footer have no rules of their own;
* QRCodeModal — the app's main floating layer, so the main place
* --shadow-float is used (the calendar band's day hover card, a much
* smaller non-interactive tooltip, is the other — see
* band-day-hover-card.css). Header and footer have no rules of their own;
* the footer is transparent — spacing does the separating. */
.modal__overlay {
position: fixed;
@@ -596,15 +598,16 @@
* legend; consumers keep only their own size/position rule and add one of
* these classes alongside their existing BEM modifier.
*
* `--lagsta`/`--cap-self`/`--cap-partner` are defined twice: once for a
* plain element (the year-row swatch, the calendar-help sample — nothing
* paints over them) and once scoped to `::after` (the calendar band, whose
* per-day ticks sit on top of the band's own background and would hide an
* inset shadow drawn directly on it — see `.calendar-band::after` in
* calendar.css). The combined lagsta+cap-self / lagsta+cap-partner
* `::after` rules exist because `box-shadow` doesn't merge across two
* separate rules — the more specific combined selector is what lets a
* lägsta band still show its owner's cap. */
* `--cap-self`/`--cap-partner` are defined twice: once for a plain element
* (the year-row swatch, the calendar-help sample — nothing paints over
* them) and once scoped to `::after` (the calendar band, whose per-day
* ticks sit on top of the band's own background and would hide an inset
* shadow drawn directly on it — see `.calendar-band::after` in
* calendar.css). `--lagsta` only needs the plain-element form: the band no
* longer wears it at all (a mixed-type band's lägsta days get their own
* per-run outline overlay instead — `.calendar-band__run--lagsta` in
* calendar.css — since the type varies day to day and the old band-wide
* `--lagsta` class only ever reflected the first day). */
.leave-swatch--vacation {
background-color: var(--c-leave-vacation);
}
@@ -619,10 +622,6 @@
box-shadow: inset 0 0 0 1px var(--c-leave-lagsta-outline);
}
.leave-swatch--lagsta::after {
box-shadow: inset 0 0 0 1px var(--c-leave-lagsta-outline);
}
.leave-swatch--cap-self {
box-shadow: inset 2px 0 0 var(--c-band-cap-self);
}
@@ -639,14 +638,6 @@
box-shadow: inset 2px 0 0 var(--c-band-cap-partner);
}
.leave-swatch--lagsta.leave-swatch--cap-self::after {
box-shadow: inset 0 0 0 1px var(--c-leave-lagsta-outline), inset 2px 0 0 var(--c-band-cap-self);
}
.leave-swatch--lagsta.leave-swatch--cap-partner::after {
box-shadow: inset 0 0 0 1px var(--c-leave-lagsta-outline), inset 2px 0 0 var(--c-band-cap-partner);
}
/* Layout helpers
* ------------------------------------------------------------------------ */
.stack {
+18
View File
@@ -32,6 +32,24 @@ export function formatDateForDisplay(date: string | Date, locale: Locale = 'sv')
return formatter.format(d)
}
/**
* Format a date as "weekday, day month" for display (e.g. "fredag 5 juni" in
* Swedish, "Friday, June 5" in English) — used by the calendar band hover
* card, which needs the weekday alongside the date and has no calendar cell
* of its own to supply it from context. Unlike `getDayName`/`getMonthName`
* (Swedish-only), this takes the active locale like `formatDateForDisplay`.
*/
export function formatWeekdayAndDate(date: string | Date, locale: Locale = 'sv'): string {
const d = typeof date === 'string' ? new Date(date + 'T00:00:00Z') : date
if (!isValidDate(d)) return ''
const formatter = new Intl.DateTimeFormat(DISPLAY_INTL_LOCALE[locale], {
weekday: 'long',
day: 'numeric',
month: 'long',
})
return formatter.format(d)
}
/**
* Format a date for HTML input value (e.g. "2026-06-01").
* Accepts ISO string or Date object.