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 { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
import { render, screen, fireEvent } from '@testing-library/react' import { render, screen, fireEvent, act } from '@testing-library/react'
import { CalendarWeek } from './CalendarWeek' import { CalendarWeek, computeTypeRuns } from './CalendarWeek'
import type { BandClickInfo } from './CalendarWeek' import type { BandClickInfo, BandDayInfo } from './CalendarWeek'
import type { PlannedLeaveDay, CalendarDayEntry } from '@/types' import type { PlannedLeaveDay, CalendarDayEntry } from '@/types'
import type { CalendarDayProps } from './CalendarDay' import type { CalendarDayProps } from './CalendarDay'
import type { CalendarEventProps } from './CalendarEvent' import type { CalendarEventProps } from './CalendarEvent'
@@ -15,9 +15,13 @@ vi.mock('@/i18n', () => ({
'leaveType.sjukpenning': 'Sjukpenning', 'leaveType.sjukpenning': 'Sjukpenning',
'leaveType.lagsta': 'Lägsta nivå', 'leaveType.lagsta': 'Lägsta nivå',
'calendarDay.pinnedAria': 'pinned', 'calendarDay.pinnedAria': 'pinned',
'calendarBand.hover.gapDay': 'Weekend or public holiday',
'calendarBand.hover.dubbeldag': 'Double day',
'calendarBand.hover.period': 'period range',
} }
return translations[key] ?? key 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', () => { describe('CalendarWeek', () => {
const defaultProps = { const defaultProps = {
weekDates: ['2026-01-05', '2026-01-06', '2026-01-07', '2026-01-08', '2026-01-09', '2026-01-10', '2026-01-11'], 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') 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 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 { getISOWeekNumber, formatDateLocalISO } from '@/utils/dates'
import { useTranslation } from '@/i18n' import { useTranslation } from '@/i18n'
import { CalendarDay } from './CalendarDay' import { CalendarDay } from './CalendarDay'
import { CalendarEvent } from './CalendarEvent' 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 /** 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 * 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 * (`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 * a bridged free-day gap (a weekend/red day the band passes through without
* needing a funded row — see `isBridgeableGap`). */ * needing a funded row — see `isBridgeableGap`). */
interface BandDayInfo { export interface BandDayInfo {
date: string date: string
dayType: PlannedLeaveDay['dayType'] | null dayType: PlannedLeaveDay['dayType'] | null
isDubbeldag: boolean isDubbeldag: boolean
@@ -82,6 +89,91 @@ interface BandDayInfo {
isGap: boolean 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 { interface BandSegment {
id: string id: string
wishId: string | null wishId: string | null
@@ -299,6 +391,62 @@ export function CalendarWeek({
monthMaxLaneCount, monthMaxLaneCount,
}: CalendarWeekProps): ReactNode { }: CalendarWeekProps): ReactNode {
const { t } = useTranslation() 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( const bandSegments = useMemo(
() => computeBandSegments(weekDates, plannedDays, calendarDaysByDate), () => computeBandSegments(weekDates, plannedDays, calendarDaysByDate),
[weekDates, plannedDays, calendarDaysByDate], [weekDates, plannedDays, calendarDaysByDate],
@@ -394,21 +542,18 @@ export function CalendarWeek({
const firstRealDay = segment.dayInfos.find((d) => !d.isGap) const firstRealDay = segment.dayInfos.find((d) => !d.isGap)
const representativeType = firstRealDay?.dayType ?? 'vacation' const representativeType = firstRealDay?.dayType ?? 'vacation'
const typeLabel = getLabelForType(representativeType, t) 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`, // Type by fill/ink/outline (`--vacation|--sjukpenning|--lagsta`,
// see the --c-leave-* tokens) and owner by a 2px left cap // see the --c-leave-* tokens) and owner by a 2px left cap
// (`--self`/`--partner`) — never by hue, so the two axes stay // (`--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 typeKey = getTypeKey(representativeType)
const classNames = [ const classNames = [
'calendar-band', 'calendar-band',
`calendar-band--${typeKey}`, `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 ? 'calendar-band--self' : 'calendar-band--partner',
segment.parentUuid === personalDataUuid ? 'leave-swatch--cap-self' : 'leave-swatch--cap-partner', segment.parentUuid === personalDataUuid ? 'leave-swatch--cap-self' : 'leave-swatch--cap-partner',
segment.isBandStart ? 'calendar-band--start' : '', segment.isBandStart ? 'calendar-band--start' : '',
@@ -418,6 +563,9 @@ export function CalendarWeek({
.filter(Boolean) .filter(Boolean)
.join(' ') .join(' ')
const dayCount = segment.dayInfos.length
const runs = computeTypeRuns(segment.dayInfos)
const fireClick = (clickedDate?: string) => const fireClick = (clickedDate?: string) =>
onBandClick?.({ onBandClick?.({
wishId: segment.wishId, wishId: segment.wishId,
@@ -429,6 +577,12 @@ export function CalendarWeek({
clickedDate: clickedDate ?? segment.overallStart, 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 ( return (
<div <div
key={segment.id} key={segment.id}
@@ -438,8 +592,6 @@ export function CalendarWeek({
gridRow: '1', gridRow: '1',
bottom: `${bandBottomInset + segment.lane * (barHeight + laneGap)}px`, bottom: `${bandBottomInset + segment.lane * (barHeight + laneGap)}px`,
height: `${barHeight}px`, height: `${barHeight}px`,
backgroundColor: color,
color: ink,
}} }}
data-testid={`calendar-band-${segment.id}`} data-testid={`calendar-band-${segment.id}`}
aria-label={`${label} · ${typeLabel}, ${segment.overallStart} – ${segment.overallEnd}`} aria-label={`${label} · ${typeLabel}, ${segment.overallStart} – ${segment.overallEnd}`}
@@ -458,43 +610,120 @@ export function CalendarWeek({
} }
> >
<div className="calendar-band__days"> <div className="calendar-band__days">
{segment.dayInfos.map((info) => ( {segment.dayInfos.map((info) => {
<span const isHovered = hoveredDay?.segmentId === segment.id && hoveredDay.date === info.date
key={info.date} return (
className={[ <span
'calendar-band__day', key={info.date}
info.isGap ? 'calendar-band__day--gap' : `calendar-band__day--${getTypeKey(info.dayType ?? 'vacation')}`, className={[
info.isPinned ? 'calendar-band__day--pinned' : '', 'calendar-band__day',
] info.isGap ? 'calendar-band__day--gap' : `calendar-band__day--${getTypeKey(info.dayType ?? 'vacation')}`,
.filter(Boolean) info.isPinned ? 'calendar-band__day--pinned' : '',
.join(' ')} ]
style={info.isGap ? undefined : { backgroundColor: getColorForType(info.dayType ?? 'vacation') }} .filter(Boolean)
title={info.isPinned ? t('calendarDay.pinnedAria') : undefined} .join(' ')}
data-testid={info.isPinned ? 'calendar-band-day-pinned' : undefined} style={info.isGap ? undefined : { backgroundColor: getColorForType(info.dayType ?? 'vacation') }}
// Clicking one day slot opens the same popover as clicking data-testid={info.isPinned ? 'calendar-band-day-pinned' : undefined}
// the band, but tells it WHICH date was clicked — "this tabIndex={onBandClick ? 0 : undefined}
// day" must be able to remove a day in the middle of a aria-describedby={isHovered ? hoverCardId : undefined}
// period, not only its first. // Clicking one day slot opens the same popover as clicking
onClick={ // the band, but tells it WHICH date was clicked — "this
onBandClick // day" must be able to remove a day in the middle of a
? (e) => { // period, not only its first.
e.stopPropagation() onClick={
fireClick(info.date) onBandClick
} ? (e) => {
: undefined 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> {runs.map((run, runIdx) => {
<div className="calendar-band__label"> const runDayType = run.dayType ?? 'vacation'
<CalendarEvent const runTypeLabel = getLabelForType(runDayType, t)
leaveType={typeLabel} const runDubbeldag = segment.dayInfos
color={color} .slice(run.startIdx, run.endIdx + 1)
isDubbeldag={anyDubbeldag} .some((d) => !d.isGap && d.isDubbeldag)
parentLabel={label} return (
paintBackground={false} <div
/> key={`label-${run.startIdx}`}
</div> 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> </div>
) )
})} })}
+8
View File
@@ -226,4 +226,12 @@ export const calendar = {
'calendar.wantsDubbeldagHint': '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.", "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.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 } as const
+9
View File
@@ -227,4 +227,13 @@ export const calendar = {
'calendar.wantsDubbeldagHint': '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.', '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.', '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 } as const
+39 -14
View File
@@ -404,9 +404,12 @@
border-bottom-right-radius: var(--radius-band); border-bottom-right-radius: var(--radius-band);
} }
/* The owner cap and lägsta's outline are drawn on an overlay above the /* The owner cap is drawn on an overlay above the per-day ticks (which would
* per-day ticks (which would otherwise paint over an inset shadow on the * otherwise paint over an inset shadow on the band itself) and below the
* band itself) and below the label. */ * 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 { .calendar-band::after {
content: ''; content: '';
position: absolute; position: absolute;
@@ -416,14 +419,12 @@
border-radius: inherit; border-radius: inherit;
} }
/* The self/partner cap-mark and lägsta's outline are the shared /* The self/partner cap-mark is the shared `.leave-swatch--cap-self`/
* `.leave-swatch--cap-self`/`--cap-partner`/`--lagsta` recipe from * `--cap-partner` recipe from primitives.css, scoped to `::after` here —
* primitives.css, scoped to `::after` here — see the comment there and on * see the comment there and on `.calendar-band::after` above for why.
* `.calendar-band::after` above for why. `getTypeKey`'s `--lagsta`/ * `CalendarWeek.tsx` adds the class alongside the band's own modifiers, so
* `leave-swatch--lagsta` and the owner's `--self`/`--partner`/ * the owner's cap composes onto the band without this file stating the
* `leave-swatch--cap-*` are added side by side in CalendarWeek.tsx, so * box-shadow value itself. */
* lägsta's outline and the owner's cap keep composing even though this
* file no longer states the box-shadow values itself. */
.calendar-band__day--lagsta { .calendar-band__day--lagsta {
background-image: repeating-linear-gradient( background-image: repeating-linear-gradient(
@@ -453,11 +454,17 @@
} }
} }
/* The label: 12px/500 in the type's ink (set inline on the band from /* One label per same-type run inside the band (see `computeTypeRuns` in
* getInkForType), no shadow. */ * 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 { .calendar-band__label {
position: absolute; position: absolute;
inset: 0; top: 0;
bottom: 0;
z-index: 2; z-index: 2;
display: flex; display: flex;
align-items: center; align-items: center;
@@ -465,6 +472,24 @@
font-size: var(--fs-xs); font-size: var(--fs-xs);
color: inherit; color: inherit;
pointer-events: none; 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 /* 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 /* Modal shell
* ------------------------------------------------------------------------ * ------------------------------------------------------------------------
* Shared by PeriodPopover, PlannedDayModal, ParentBalanceForm and * Shared by PeriodPopover, PlannedDayModal, ParentBalanceForm and
* QRCodeModal. The one floating layer in the app, so the one place * QRCodeModal — the app's main floating layer, so the main place
* --shadow-float is used. Header and footer have no rules of their own; * --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. */ * the footer is transparent — spacing does the separating. */
.modal__overlay { .modal__overlay {
position: fixed; position: fixed;
@@ -596,15 +598,16 @@
* legend; consumers keep only their own size/position rule and add one of * legend; consumers keep only their own size/position rule and add one of
* these classes alongside their existing BEM modifier. * these classes alongside their existing BEM modifier.
* *
* `--lagsta`/`--cap-self`/`--cap-partner` are defined twice: once for a * `--cap-self`/`--cap-partner` are defined twice: once for a plain element
* plain element (the year-row swatch, the calendar-help sample — nothing * (the year-row swatch, the calendar-help sample — nothing paints over
* paints over them) and once scoped to `::after` (the calendar band, whose * them) and once scoped to `::after` (the calendar band, whose per-day
* per-day ticks sit on top of the band's own background and would hide an * ticks sit on top of the band's own background and would hide an inset
* inset shadow drawn directly on it — see `.calendar-band::after` in * shadow drawn directly on it — see `.calendar-band::after` in
* calendar.css). The combined lagsta+cap-self / lagsta+cap-partner * calendar.css). `--lagsta` only needs the plain-element form: the band no
* `::after` rules exist because `box-shadow` doesn't merge across two * longer wears it at all (a mixed-type band's lägsta days get their own
* separate rules — the more specific combined selector is what lets a * per-run outline overlay instead — `.calendar-band__run--lagsta` in
* lägsta band still show its owner's cap. */ * 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 { .leave-swatch--vacation {
background-color: var(--c-leave-vacation); background-color: var(--c-leave-vacation);
} }
@@ -619,10 +622,6 @@
box-shadow: inset 0 0 0 1px var(--c-leave-lagsta-outline); 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 { .leave-swatch--cap-self {
box-shadow: inset 2px 0 0 var(--c-band-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); 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 /* Layout helpers
* ------------------------------------------------------------------------ */ * ------------------------------------------------------------------------ */
.stack { .stack {
+18
View File
@@ -32,6 +32,24 @@ export function formatDateForDisplay(date: string | Date, locale: Locale = 'sv')
return formatter.format(d) 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"). * Format a date for HTML input value (e.g. "2026-06-01").
* Accepts ISO string or Date object. * Accepts ISO string or Date object.