Redraw the calendar as an almanac: gridless cells, typed bands

Day cells lose their borders and fills; red days, klämdagar and
Saturdays are told apart by the numeral's ink with a 12px R/K mark,
today is a filled circle, overrides and exclusions mark the numeral.
Bands carry their funding type by fill, ink and hatching and their
owner by a left cap. The preset rail becomes a chip row, the legend a
help popover, the period popover's three delete buttons fold behind
one "Ta bort…", and solo users pick a type directly in the day modal.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XBRLomdLK9jF7wU7a8AA6H
This commit is contained in:
2026-09-19 19:02:02 +02:00
parent d14f86478a
commit dbef997da4
28 changed files with 1486 additions and 1109 deletions
+18 -11
View File
@@ -10,9 +10,10 @@
* default it now creates a fixed, one-off `LeaveWish` and lets the planner
* fund it (a `calendar-band-wish:` band, not `adhoc:`) — see
* `Calendar.tsx`'s `handleSaveWish`. The old "write exact ad-hoc
* `planned_leave_day` rows" behaviour still exists, behind the modal's
* "Advanced" disclosure (`handleSavePinnedDay`) — the first test below
* opens it explicitly to keep exercising that path.
* `planned_leave_day` rows" behaviour still exists (`handleSavePinnedDay`):
* a solo household reaches it by picking a concrete type in the modal's
* type select (a couple through the "Advanced" disclosure) — the first
* test below does so explicitly to keep exercising that path.
*
* The drag deliberately crosses a month boundary (the last two days of one
* month into the first day of the next) — a regression guard for the
@@ -57,7 +58,7 @@ function targetRange(): { month: Date; dates: [string, string, string] } {
return { month, dates }
}
test('dragging a range and pinning it through Advanced plans every day exactly, and deleting it removes the days for good', async ({
test('dragging a range and pinning an exact type plans every day exactly, and deleting it removes the days for good', async ({
page,
}) => {
await registerAndReachOnboarding(page, uniqueEmail('drag-delete'), 'Robin Testsson')
@@ -79,10 +80,12 @@ test('dragging a range and pinning it through Advanced plans every day exactly,
// A multi-day drag opens the day popover pre-selected on every dragged
// date — it does not create a period/wish by itself.
await expect(page.getByTestId('modal-title')).toHaveText('Planera 3 dagar')
// The default save path would create a fixed wish instead (see the next
// test) — opening "Advanced" keeps this test on the exact-pinned-days
// path todo.md #10/#11 kept as an escape hatch.
await page.getByTestId('advanced-toggle').click()
// The default save path ("let the planner choose") would create a fixed
// wish instead (see the next test) — picking a concrete type keeps this
// test on the exact-pinned-days path todo.md #10/#11 kept as an escape
// hatch. A solo account has no "Advanced" toggle: the select is right
// there.
await page.getByTestId('day-type-select').selectOption('vacation')
await page.getByTestId('save-button').click()
await expect(page.getByTestId('planned-day-modal__content')).not.toBeVisible()
@@ -112,9 +115,11 @@ test('dragging a range and pinning it through Advanced plans every day exactly,
await expect(page.getByTestId('period-adhoc-notice')).toBeVisible()
await expect(page.getByTestId('period-popover__content')).toContainText(`${firstDate} – ${lastDate}`)
// todo.md #9 — an ad-hoc band has no recurring plan, so "every year" is
// disabled; "this day"/"this period" are the only meaningful choices, and
// "this period" removes the whole ad-hoc range (today's only behaviour).
// todo.md #9 — the three delete scopes sit behind "Ta bort…". An ad-hoc
// band has no recurring plan, so "every year" is disabled; "this day"/
// "this period" are the only meaningful choices, and "this period"
// removes the whole ad-hoc range (today's only behaviour).
await page.getByTestId('period-delete-menu-button').click()
await expect(page.getByTestId('period-delete-all-years-button')).toBeDisabled()
await page.getByTestId('period-delete-year-button').click()
await expect(page.getByTestId('period-delete-confirm')).toBeVisible()
@@ -183,6 +188,7 @@ test('a drag saved without opening Advanced creates a planner-funded wish, and t
// thing, same as "every year".
await wishBand.first().click()
await expect(page.getByTestId('period-popover__content')).toBeVisible()
await page.getByTestId('period-delete-menu-button').click()
await expect(page.getByTestId('period-delete-day-button')).toBeEnabled()
await page.getByTestId('period-delete-year-button').click()
@@ -224,6 +230,7 @@ test('removing a single day out of a planner-placed period keeps it removed acro
// `clickedDate`). Either way the popover's "this day" removes exactly one.
await wishBand.first().click()
await expect(page.getByTestId('period-popover__content')).toBeVisible()
await page.getByTestId('period-delete-menu-button').click()
await page.getByTestId('period-delete-day-button').click()
await expect(page.getByTestId('period-delete-confirm')).toBeVisible()
await page.getByTestId('period-delete-confirm-button').click()
+30 -12
View File
@@ -24,40 +24,52 @@ import { calendarDayCell, goToCalendarMonth } from '../support/calendarNav'
* element actually painted with it — i.e. exactly what `toHaveCSS` compares
* against. Reading the token instead of a hardcoded literal means a future
* restyle that moves the palette can't silently desync this spec from
* calendar.css: whatever `--c-danger-weak`/`--c-weekend-bg` resolve to at
* test time is what's asserted.
* calendar.css: whatever `--c-danger`/`--c-text-faint` resolve to at test
* time is what's asserted.
*/
async function resolveCssVar(page: Page, varName: string): Promise<string> {
return page.evaluate((name) => {
const probe = document.createElement('div')
probe.style.backgroundColor = `var(${name})`
probe.style.color = `var(${name})`
document.body.appendChild(probe)
const resolved = getComputedStyle(probe).backgroundColor
const resolved = getComputedStyle(probe).color
probe.remove()
return resolved
}, varName)
}
/** The day's numeral — the element that carries the red/Saturday colour in
* the Almanacka design (cells themselves have no fill). */
function dayNumeral(cell: ReturnType<typeof calendarDayCell>) {
return cell.locator('.calendar-day__number')
}
test.describe('red days, klämdagar and the combined-holiday-name trap', () => {
test('Easter Sunday (Påskdagen) renders as a named red day', async ({ page }) => {
const easter = nextEasterOnOrAfter(new Date())
await goToCalendarMonth(page, easter)
const redDayBg = await resolveCssVar(page, '--c-danger-weak') // .calendar-day--red-day
const redInk = await resolveCssVar(page, '--c-danger') // .calendar-day--red-day .calendar-day__number
const redFill = await resolveCssVar(page, '--c-danger-weak')
const cell = calendarDayCell(page, toIsoDate(easter))
await expect(cell).toHaveClass(/calendar-day--red-day/)
await expect(cell).toHaveCSS('background-color', redDayBg)
// The numeral carries the colour; the cell itself has no red fill.
await expect(dayNumeral(cell)).toHaveCSS('color', redInk)
await expect(cell).not.toHaveCSS('background-color', redFill)
await expect(cell.getByTestId('calendar-day-type-badge')).toHaveText('R')
await expect(cell.getByTestId('calendar-day-holiday-name')).toHaveText('Påskdagen')
})
test('Whit Sunday (Pingstdagen) renders as a named red day', async ({ page }) => {
const whitsun = whitSunday(nextEasterOnOrAfter(new Date()))
await goToCalendarMonth(page, whitsun)
const redDayBg = await resolveCssVar(page, '--c-danger-weak') // .calendar-day--red-day
const redInk = await resolveCssVar(page, '--c-danger') // .calendar-day--red-day .calendar-day__number
const redFill = await resolveCssVar(page, '--c-danger-weak')
const cell = calendarDayCell(page, toIsoDate(whitsun))
await expect(cell).toHaveClass(/calendar-day--red-day/)
await expect(cell).toHaveCSS('background-color', redDayBg)
await expect(dayNumeral(cell)).toHaveCSS('color', redInk)
await expect(cell).not.toHaveCSS('background-color', redFill)
await expect(cell.getByTestId('calendar-day-holiday-name')).toHaveText('Pingstdagen')
})
@@ -77,18 +89,24 @@ test.describe('red days, klämdagar and the combined-holiday-name trap', () => {
// that first Mon–Sun week row (see Calendar.tsx's `startOffset`) — so
// this never depends on the two dates sharing a calendar month.
await goToCalendarMonth(page, plainSunday)
const redDayBg = await resolveCssVar(page, '--c-danger-weak') // .calendar-day--red-day
const weekendBg = await resolveCssVar(page, '--c-weekend-bg') // .calendar-day--weekend
const redInk = await resolveCssVar(page, '--c-danger') // .calendar-day--red-day .calendar-day__number
const redFill = await resolveCssVar(page, '--c-danger-weak')
const saturdayInk = await resolveCssVar(page, '--c-text-faint') // .calendar-day--weekend .calendar-day__number
const sundayCell = calendarDayCell(page, toIsoDate(plainSunday))
await expect(sundayCell).toHaveClass(/calendar-day--red-day/)
await expect(sundayCell).not.toHaveClass(/calendar-day--weekend/)
await expect(sundayCell).toHaveCSS('background-color', redDayBg)
await expect(dayNumeral(sundayCell)).toHaveCSS('color', redInk)
await expect(sundayCell).not.toHaveCSS('background-color', redFill)
await expect(sundayCell.getByTestId('calendar-day-type-badge')).toHaveText('R')
await expect(sundayCell.getByTestId('calendar-day-holiday-name')).toHaveCount(0)
const saturdayCell = calendarDayCell(page, toIsoDate(plainSaturday))
await expect(saturdayCell).toHaveClass(/calendar-day--weekend/)
await expect(saturdayCell).not.toHaveClass(/calendar-day--red-day/)
await expect(saturdayCell).toHaveCSS('background-color', weekendBg)
// A plain Saturday is a faint numeral, not a red one, and no fill.
await expect(dayNumeral(saturdayCell)).toHaveCSS('color', saturdayInk)
await expect(dayNumeral(saturdayCell)).not.toHaveCSS('color', redInk)
await expect(saturdayCell).not.toHaveCSS('background-color', redFill)
})
})
@@ -0,0 +1,86 @@
import { describe, it, expect } from 'vitest'
import { render, screen, fireEvent } from '@testing-library/react'
import { CalendarHelpPopover } from './CalendarHelpPopover'
describe('CalendarHelpPopover', () => {
it('starts closed, with the button collapsed', () => {
render(<CalendarHelpPopover />)
expect(screen.getByTestId('calendar-help-button')).toHaveAttribute('aria-expanded', 'false')
expect(screen.queryByTestId('calendar-help-panel')).not.toBeInTheDocument()
})
it('opens on click and lists every day mark and band style', () => {
render(<CalendarHelpPopover />)
fireEvent.click(screen.getByTestId('calendar-help-button'))
expect(screen.getByTestId('calendar-help-button')).toHaveAttribute('aria-expanded', 'true')
const panel = screen.getByTestId('calendar-help-panel')
expect(panel).toHaveTextContent('Röd dag')
expect(panel).toHaveTextContent('Klämdag')
expect(panel).toHaveTextContent('Lördag')
expect(panel).toHaveTextContent('Idag')
expect(panel).toHaveTextContent('Undantag')
expect(panel).toHaveTextContent('Borttagen dag')
expect(panel).toHaveTextContent('Semester')
expect(panel).toHaveTextContent('Sjukpenningnivå')
expect(panel).toHaveTextContent('Lägstanivå')
expect(panel).toHaveTextContent('Dubbeldag')
// The samples are drawn with the grid's own marks.
expect(panel.querySelector('.calendar-help__day--red')).toHaveTextContent('R')
expect(panel.querySelector('.calendar-help__day--klamdag')).toHaveTextContent('K')
expect(panel.querySelector('.calendar-help__day--override [data-icon="briefcase"]')).toBeInTheDocument()
expect(panel.querySelector('.calendar-help__band--dubbeldag')).toHaveTextContent('2×')
})
it('closes again on a second click', () => {
render(<CalendarHelpPopover />)
fireEvent.click(screen.getByTestId('calendar-help-button'))
fireEvent.click(screen.getByTestId('calendar-help-button'))
expect(screen.queryByTestId('calendar-help-panel')).not.toBeInTheDocument()
})
it('closes on Escape', () => {
render(<CalendarHelpPopover />)
fireEvent.click(screen.getByTestId('calendar-help-button'))
fireEvent.keyDown(document, { key: 'Escape' })
expect(screen.queryByTestId('calendar-help-panel')).not.toBeInTheDocument()
})
it('ignores other keys', () => {
render(<CalendarHelpPopover />)
fireEvent.click(screen.getByTestId('calendar-help-button'))
fireEvent.keyDown(document, { key: 'Enter' })
expect(screen.getByTestId('calendar-help-panel')).toBeInTheDocument()
})
it('closes on a pointer down outside, but not on one inside the panel', () => {
render(
<div>
<CalendarHelpPopover />
<button type="button" data-testid="outside">
outside
</button>
</div>,
)
fireEvent.click(screen.getByTestId('calendar-help-button'))
fireEvent.pointerDown(screen.getByTestId('calendar-help-panel'))
expect(screen.getByTestId('calendar-help-panel')).toBeInTheDocument()
fireEvent.pointerDown(screen.getByTestId('outside'))
expect(screen.queryByTestId('calendar-help-panel')).not.toBeInTheDocument()
})
it('does not listen for outside clicks while closed', () => {
render(
<div>
<CalendarHelpPopover />
<button type="button" data-testid="outside">
outside
</button>
</div>,
)
fireEvent.pointerDown(screen.getByTestId('outside'))
fireEvent.click(screen.getByTestId('calendar-help-button'))
expect(screen.getByTestId('calendar-help-panel')).toBeInTheDocument()
})
})
@@ -0,0 +1,104 @@
import { ReactNode, useEffect, useId, useRef, useState } from 'react'
import { useTranslation, type TranslationKey } from '@/i18n'
import { Icon } from '@/components/common/Icon'
import './calendar-help-popover.css'
/** The day marks, each drawn the same way the grid draws it. */
const DAY_MARKS: ReadonlyArray<{ key: string; className: string; labelKey: TranslationKey; sample: string }> = [
{ key: 'red', className: 'calendar-help__day calendar-help__day--red', labelKey: 'calendar.helpRedDay', sample: '6' },
{ key: 'klamdag', className: 'calendar-help__day calendar-help__day--klamdag', labelKey: 'calendar.helpKlamdag', sample: '5' },
{ key: 'saturday', className: 'calendar-help__day calendar-help__day--saturday', labelKey: 'calendar.helpSaturday', sample: '12' },
{ key: 'today', className: 'calendar-help__day calendar-help__day--today', labelKey: 'calendar.helpToday', sample: '19' },
{ key: 'override', className: 'calendar-help__day calendar-help__day--override', labelKey: 'calendar.helpOverride', sample: '24' },
{ key: 'excluded', className: 'calendar-help__day calendar-help__day--excluded', labelKey: 'calendar.helpExcluded', sample: '17' },
]
/** The band styles: type by fill/ink/outline, owner by the left cap. */
const BAND_MARKS: ReadonlyArray<{ key: string; className: string; labelKey: TranslationKey; text?: string }> = [
{ key: 'vacation', className: 'calendar-help__band calendar-help__band--vacation', labelKey: 'calendar.helpVacation' },
{ key: 'sjukpenning', className: 'calendar-help__band calendar-help__band--sjukpenning', labelKey: 'calendar.helpSjukpenning' },
{ key: 'lagsta', className: 'calendar-help__band calendar-help__band--lagsta', labelKey: 'calendar.helpLagsta' },
{ key: 'self', className: 'calendar-help__band calendar-help__band--self', labelKey: 'calendar.helpSelf' },
{ key: 'partner', className: 'calendar-help__band calendar-help__band--partner', labelKey: 'calendar.helpPartner' },
{ key: 'dubbeldag', className: 'calendar-help__band calendar-help__band--dubbeldag', labelKey: 'calendar.helpDubbeldag', text: '2×' },
{ key: 'pinned', className: 'calendar-help__band calendar-help__band--pinned', labelKey: 'calendar.helpPinned' },
]
/**
* The "?" beside the month title: a small floating panel that explains
* every mark the grid uses (the R/K superscripts, the today circle, the
* band fills and caps). It replaces the seven-item legend that used to sit
* under the calendar — the marks are self-explanatory most of the time, so
* the explanation is a click away rather than always on screen.
*
* Closes on Escape, on a click outside, or on the button again.
*/
export function CalendarHelpPopover(): ReactNode {
const { t } = useTranslation()
const [isOpen, setIsOpen] = useState(false)
const rootRef = useRef<HTMLDivElement>(null)
const panelId = useId()
useEffect(() => {
if (!isOpen) return
const handlePointerDown = (event: PointerEvent) => {
if (rootRef.current && event.target instanceof Node && !rootRef.current.contains(event.target)) {
setIsOpen(false)
}
}
const handleKeyDown = (event: KeyboardEvent) => {
if (event.key === 'Escape') setIsOpen(false)
}
document.addEventListener('pointerdown', handlePointerDown)
document.addEventListener('keydown', handleKeyDown)
return () => {
document.removeEventListener('pointerdown', handlePointerDown)
document.removeEventListener('keydown', handleKeyDown)
}
}, [isOpen])
return (
<div className="calendar-help" ref={rootRef}>
<button
type="button"
className="btn btn--icon calendar-help__button"
onClick={() => setIsOpen((open) => !open)}
aria-label={t('calendar.helpAria')}
aria-expanded={isOpen}
aria-controls={panelId}
data-testid="calendar-help-button"
>
<Icon name="help" size={20} />
</button>
{isOpen && (
<div className="calendar-help__panel" id={panelId} role="dialog" aria-label={t('calendar.helpTitle')} data-testid="calendar-help-panel">
<h3 className="calendar-help__title">{t('calendar.helpTitle')}</h3>
<ul className="calendar-help__list">
{DAY_MARKS.map((mark) => (
<li key={mark.key} className="calendar-help__item">
<span className={mark.className} aria-hidden="true">
<span className="calendar-help__numeral">{mark.sample}</span>
{mark.key === 'red' && <span className="calendar-help__sup">R</span>}
{mark.key === 'klamdag' && <span className="calendar-help__sup">K</span>}
{mark.key === 'override' && <Icon name="briefcase" size={16} />}
</span>
<span className="calendar-help__label">{t(mark.labelKey)}</span>
</li>
))}
</ul>
<h3 className="calendar-help__title">{t('calendar.helpBandsTitle')}</h3>
<ul className="calendar-help__list">
{BAND_MARKS.map((mark) => (
<li key={mark.key} className="calendar-help__item">
<span className={mark.className} aria-hidden="true">
{mark.text}
</span>
<span className="calendar-help__label">{t(mark.labelKey)}</span>
</li>
))}
</ul>
</div>
)}
</div>
)
}
@@ -23,11 +23,47 @@ describe('PeriodPopover', () => {
vi.clearAllMocks()
})
/** The three delete scopes sit behind "Ta bort…" — press it first. */
function openDeleteMenu() {
fireEvent.click(screen.getByTestId('period-delete-menu-button'))
}
it('returns null when not open', () => {
const { container } = render(<PeriodPopover {...defaultProps} isOpen={false} />)
expect(container.firstChild).toBeNull()
})
describe('the delete menu', () => {
it('keeps the three delete scopes hidden until "Ta bort…" is pressed', () => {
render(<PeriodPopover {...defaultProps} />)
expect(screen.queryByTestId('period-delete-group')).not.toBeInTheDocument()
expect(screen.getByTestId('period-delete-menu-button')).toHaveTextContent('Ta bort…')
expect(screen.getByTestId('period-delete-menu-button')).toHaveAttribute('aria-expanded', 'false')
openDeleteMenu()
expect(screen.getByTestId('period-delete-menu-button')).toHaveAttribute('aria-expanded', 'true')
expect(screen.getByTestId('period-delete-group')).toBeInTheDocument()
expect(screen.getByTestId('period-delete-day-button')).toBeInTheDocument()
expect(screen.getByTestId('period-delete-year-button')).toBeInTheDocument()
expect(screen.getByTestId('period-delete-all-years-button')).toBeInTheDocument()
})
it('folds the scopes away again on a second press', () => {
render(<PeriodPopover {...defaultProps} />)
openDeleteMenu()
openDeleteMenu()
expect(screen.queryByTestId('period-delete-group')).not.toBeInTheDocument()
})
it('starts folded again each time the popover reopens', () => {
const { rerender } = render(<PeriodPopover {...defaultProps} />)
openDeleteMenu()
rerender(<PeriodPopover {...defaultProps} isOpen={false} />)
rerender(<PeriodPopover {...defaultProps} isOpen={true} />)
expect(screen.queryByTestId('period-delete-group')).not.toBeInTheDocument()
})
})
describe('ad-hoc band (no wish)', () => {
it('shows the ad-hoc notice and date range instead of editable fields', () => {
render(<PeriodPopover {...defaultProps} />)
@@ -43,6 +79,7 @@ describe('PeriodPopover', () => {
it('shows the ad-hoc "this period" label and disables "every year" (nothing recurring to remove)', () => {
render(<PeriodPopover {...defaultProps} />)
openDeleteMenu()
expect(screen.getByTestId('period-delete-year-button')).toHaveTextContent('Ta bort dessa dagar')
expect(screen.getByTestId('period-delete-day-button')).not.toBeDisabled()
expect(screen.getByTestId('period-delete-all-years-button')).toBeDisabled()
@@ -54,6 +91,7 @@ describe('PeriodPopover', () => {
it('shows an inline confirmation instead of deleting immediately', () => {
render(<PeriodPopover {...defaultProps} />)
openDeleteMenu()
fireEvent.click(screen.getByTestId('period-delete-year-button'))
expect(mockOnDelete).not.toHaveBeenCalled()
expect(screen.getByTestId('period-delete-confirm')).toBeInTheDocument()
@@ -62,6 +100,7 @@ describe('PeriodPopover', () => {
it('calls onDelete with the "year" scope after confirming inline', async () => {
mockOnDelete.mockResolvedValue(undefined)
render(<PeriodPopover {...defaultProps} />)
openDeleteMenu()
fireEvent.click(screen.getByTestId('period-delete-year-button'))
fireEvent.click(screen.getByTestId('period-delete-confirm-button'))
await waitFor(() => expect(mockOnDelete).toHaveBeenCalledWith('year'))
@@ -70,6 +109,7 @@ describe('PeriodPopover', () => {
it('calls onDelete with the "day" scope for the day button', async () => {
mockOnDelete.mockResolvedValue(undefined)
render(<PeriodPopover {...defaultProps} />)
openDeleteMenu()
fireEvent.click(screen.getByTestId('period-delete-day-button'))
fireEvent.click(screen.getByTestId('period-delete-confirm-button'))
await waitFor(() => expect(mockOnDelete).toHaveBeenCalledWith('day'))
@@ -77,6 +117,7 @@ describe('PeriodPopover', () => {
it('does not call onDelete when the inline confirmation is cancelled', () => {
render(<PeriodPopover {...defaultProps} />)
openDeleteMenu()
fireEvent.click(screen.getByTestId('period-delete-year-button'))
fireEvent.click(screen.getByTestId('period-delete-cancel-button'))
expect(mockOnDelete).not.toHaveBeenCalled()
@@ -394,6 +435,7 @@ describe('PeriodPopover', () => {
// the option is live — see Calendar.tsx's `excludeDay`.
it('enables "this day" for a wish-linked band and explains what it does', () => {
render(<PeriodPopover {...defaultProps} wish={wish} />)
openDeleteMenu()
expect(screen.getByTestId('period-delete-day-button')).not.toBeDisabled()
expect(screen.getByTestId('period-delete-day-button')).toHaveAttribute(
'title',
@@ -406,6 +448,7 @@ describe('PeriodPopover', () => {
it('calls onDelete with the "day" scope for a wish-linked band after confirmation', async () => {
mockOnDelete.mockResolvedValue(undefined)
render(<PeriodPopover {...defaultProps} wish={wish} />)
openDeleteMenu()
fireEvent.click(screen.getByTestId('period-delete-day-button'))
fireEvent.click(screen.getByTestId('period-delete-confirm-button'))
await waitFor(() => expect(mockOnDelete).toHaveBeenCalledWith('day'))
@@ -420,6 +463,7 @@ describe('PeriodPopover', () => {
removeDayParentName="Alex"
/>,
)
openDeleteMenu()
fireEvent.click(screen.getByTestId('period-delete-day-button'))
expect(screen.getByTestId('period-delete-confirm')).toHaveTextContent(
'Ta bort 17 juni 2028 för Alex?',
@@ -428,6 +472,7 @@ describe('PeriodPopover', () => {
it('falls back to the generic confirmation when the parent is unknown', () => {
render(<PeriodPopover {...defaultProps} wish={wish} removeDayLabel="17 juni 2028" />)
openDeleteMenu()
fireEvent.click(screen.getByTestId('period-delete-day-button'))
expect(screen.getByTestId('period-delete-confirm')).toHaveTextContent(
'Är du säker på att du vill ta bort den här dagen?',
@@ -437,6 +482,7 @@ describe('PeriodPopover', () => {
it('displays a generic local error when onDelete rejects with a non-Error value', async () => {
mockOnDelete.mockRejectedValue('boom')
render(<PeriodPopover {...defaultProps} wish={wish} />)
openDeleteMenu()
fireEvent.click(screen.getByTestId('period-delete-year-button'))
fireEvent.click(screen.getByTestId('period-delete-confirm-button'))
await waitFor(() => {
@@ -447,6 +493,7 @@ describe('PeriodPopover', () => {
it('calls onDelete with the "allYears" scope for a wish-linked band after confirmation', async () => {
mockOnDelete.mockResolvedValue(undefined)
render(<PeriodPopover {...defaultProps} wish={wish} />)
openDeleteMenu()
fireEvent.click(screen.getByTestId('period-delete-all-years-button'))
fireEvent.click(screen.getByTestId('period-delete-confirm-button'))
await waitFor(() => expect(mockOnDelete).toHaveBeenCalledWith('allYears'))
@@ -455,6 +502,7 @@ describe('PeriodPopover', () => {
it('calls onDelete with the "year" scope for a wish-linked band', async () => {
mockOnDelete.mockResolvedValue(undefined)
render(<PeriodPopover {...defaultProps} wish={wish} />)
openDeleteMenu()
fireEvent.click(screen.getByTestId('period-delete-year-button'))
fireEvent.click(screen.getByTestId('period-delete-confirm-button'))
await waitFor(() => expect(mockOnDelete).toHaveBeenCalledWith('year'))
@@ -462,12 +510,14 @@ describe('PeriodPopover', () => {
it('shows the wish-linked "this period" and "every year" labels', () => {
render(<PeriodPopover {...defaultProps} wish={wish} />)
openDeleteMenu()
expect(screen.getByTestId('period-delete-year-button')).toHaveTextContent('Den här perioden')
expect(screen.getByTestId('period-delete-all-years-button')).toHaveTextContent('Den här perioden, varje år')
})
it('shows a scope-specific confirmation message for each delete button', () => {
render(<PeriodPopover {...defaultProps} wish={wish} />)
openDeleteMenu()
fireEvent.click(screen.getByTestId('period-delete-year-button'))
expect(screen.getByTestId('period-delete-confirm')).toHaveTextContent(
'Är du säker på att du vill ta bort den här perioden (bara det här året)?',
@@ -519,7 +569,9 @@ describe('PeriodPopover', () => {
describe('close behaviour', () => {
it('calls onClose on the close button', () => {
render(<PeriodPopover {...defaultProps} />)
fireEvent.click(screen.getByLabelText('Stäng'))
const closeButton = screen.getByLabelText('Stäng')
expect(closeButton.querySelector('[data-icon="close"]')).toBeInTheDocument()
fireEvent.click(closeButton)
expect(mockOnClose).toHaveBeenCalled()
})
@@ -2,6 +2,7 @@ import { ReactNode, useEffect, useState } from 'react'
import type { LeaveWish, LeaveWishScope } from '@/types'
import { useTranslation, type TranslationKey } from '@/i18n'
import { resolveWishName } from '@/utils/wishName'
import { Icon } from '@/components/common/Icon'
import './period-popover.css'
export interface PeriodPopoverSavePayload {
@@ -155,6 +156,10 @@ export function PeriodPopover({
const [form, setForm] = useState<FormState>({ ...EMPTY_FORM, startDate, endDate })
const [localError, setLocalError] = useState<string | null>(null)
const [deleteScope, setDeleteScope] = useState<PeriodDeleteScope | null>(null)
// "Ta bort…" reveals the three delete scopes stacked under the footer;
// they stay revealed while a scope's confirmation is cancelled, so the
// user lands back on the choice they were making.
const [isDeleteMenuOpen, setIsDeleteMenuOpen] = useState(false)
useEffect(() => {
if (wish) {
@@ -164,6 +169,7 @@ export function PeriodPopover({
}
setLocalError(null)
setDeleteScope(null)
setIsDeleteMenuOpen(false)
}, [isOpen, wish, startDate, endDate, t])
if (!isOpen) return null
@@ -253,7 +259,7 @@ export function PeriodPopover({
onClick={onClose}
aria-label={t('calendar.periodCloseAria')}
>
×
<Icon name="close" size={20} />
</button>
</div>
@@ -496,37 +502,16 @@ export function PeriodPopover({
</div>
) : (
<>
<div className="period-popover__delete-group btn--start" data-testid="period-delete-group">
<button
type="button"
className="btn btn--danger"
onClick={() => handleDeleteClick('day')}
disabled={isSaving}
title={t('calendar.periodDeleteDayHint')}
data-testid="period-delete-day-button"
>
{t('calendar.periodDeleteDayButton')}
</button>
<button
type="button"
className="btn btn--danger"
onClick={() => handleDeleteClick('year')}
disabled={isSaving}
data-testid="period-delete-year-button"
>
{wish ? t('calendar.periodDeleteYearButton') : t('calendar.periodDeleteAdHocButton')}
</button>
<button
type="button"
className="btn btn--danger"
onClick={() => handleDeleteClick('allYears')}
disabled={isSaving || wish === null}
title={wish === null ? t('calendar.periodDeleteAllYearsDisabledHint') : undefined}
data-testid="period-delete-all-years-button"
>
{t('calendar.periodDeleteAllYearsButton')}
</button>
</div>
<button
type="button"
className="btn btn--secondary btn--start period-popover__delete-menu-button"
onClick={() => setIsDeleteMenuOpen((open) => !open)}
disabled={isSaving}
aria-expanded={isDeleteMenuOpen}
data-testid="period-delete-menu-button"
>
{t('calendar.periodDeleteMenuButton')}
</button>
<button
type="button"
className="btn btn--secondary"
@@ -547,6 +532,39 @@ export function PeriodPopover({
{isSaving ? t('calendar.periodSavingButton') : t('calendar.periodSaveButton')}
</button>
)}
{isDeleteMenuOpen && (
<div className="period-popover__delete-group" data-testid="period-delete-group">
<button
type="button"
className="btn btn--secondary btn--block period-popover__delete-option"
onClick={() => handleDeleteClick('day')}
disabled={isSaving}
title={t('calendar.periodDeleteDayHint')}
data-testid="period-delete-day-button"
>
{t('calendar.periodDeleteDayButton')}
</button>
<button
type="button"
className="btn btn--secondary btn--block period-popover__delete-option"
onClick={() => handleDeleteClick('year')}
disabled={isSaving}
data-testid="period-delete-year-button"
>
{wish ? t('calendar.periodDeleteYearButton') : t('calendar.periodDeleteAdHocButton')}
</button>
<button
type="button"
className="btn btn--secondary btn--block period-popover__delete-option"
onClick={() => handleDeleteClick('allYears')}
disabled={isSaving || wish === null}
title={wish === null ? t('calendar.periodDeleteAllYearsDisabledHint') : undefined}
data-testid="period-delete-all-years-button"
>
{t('calendar.periodDeleteAllYearsButton')}
</button>
</div>
)}
</>
)}
</div>
@@ -14,9 +14,20 @@ describe('PresetRail', () => {
}
})
it('renders the heading and hint', () => {
it('renders as a labelled chip group with a visually hidden heading', () => {
render(<PresetRail onSelectPreset={vi.fn()} />)
expect(screen.getByTestId('preset-rail')).toBeInTheDocument()
const group = screen.getByTestId('preset-rail')
expect(group).toHaveAttribute('role', 'group')
expect(group).toHaveAttribute('aria-label', 'Lägg till ledighet')
expect(screen.getByRole('heading', { level: 3 })).toHaveClass('visually-hidden')
})
it('renders each preset as a chip with a plus icon and an "add" label', () => {
render(<PresetRail onSelectPreset={vi.fn()} />)
const chip = screen.getByTestId('preset-rail-season-sommar')
expect(chip).toHaveClass('btn--chip')
expect(chip.querySelector('[data-icon="plus"]')).toBeInTheDocument()
expect(chip).toHaveAttribute('aria-label', 'Lägg till Sommar')
})
it('calls onSelectPreset with the clicked preset', () => {
@@ -52,10 +63,13 @@ describe('PresetRail', () => {
expect(onSelectPreset).not.toHaveBeenCalled()
})
it('shows the week count and the flexible badge for each preset', () => {
it('shows the week count on the chip and the full detail as its title', () => {
render(<PresetRail onSelectPreset={vi.fn()} />)
const summerButton = screen.getByTestId('preset-rail-season-sommar')
expect(summerButton).toHaveTextContent('6')
expect(summerButton).toHaveTextContent('när det passar bäst')
expect(summerButton).toHaveTextContent('Sommar')
expect(summerButton).toHaveTextContent('6 v')
expect(summerButton).toHaveAttribute('title', '6 veckor · när det passar bäst')
// Singular week unit for the one-week presets.
expect(screen.getByTestId('preset-rail-season-var')).toHaveAttribute('title', '1 vecka · när det passar bäst')
})
})
+35 -28
View File
@@ -1,5 +1,6 @@
import { ReactNode } from 'react'
import { useTranslation, type TranslationKey } from '@/i18n'
import { Icon } from '@/components/common/Icon'
import './preset-rail.css'
export interface SeasonPreset {
@@ -35,39 +36,45 @@ export interface PresetRailProps {
}
/**
* A side rail of one-click season presets. Clicking one creates a flexible
* `LeaveWish` and syncs the plan — the allocator picks the best placement,
* and the calendar scrolls to it and flashes the resulting band (see
* Calendar.tsx's `handleSelectPreset`). This rail only renders the choices
* and reports which one was picked; it owns no request/scroll logic itself.
* A row of one-click season presets ("+ Sommar · 6 v"), sitting above the
* month grid. Clicking one creates a flexible `LeaveWish` and syncs the
* plan — the allocator picks the best placement, and the calendar scrolls
* to it and flashes the resulting band (see Calendar.tsx's
* `handleSelectPreset`). This row only renders the choices and reports
* which one was picked; it owns no request/scroll logic itself.
*
* The component keeps its `PresetRail` name and `preset-rail*` test ids
* from when it was a side rail, so the page test and the e2e suite need no
* change.
*/
export function PresetRail({ isBusy = false, onSelectPreset }: PresetRailProps): ReactNode {
const { t } = useTranslation()
return (
<aside className="preset-rail" data-testid="preset-rail" aria-label={t('calendar.railHeading')}>
<h3 className="preset-rail__heading">{t('calendar.railHeading')}</h3>
<p className="preset-rail__hint">{t('calendar.railHint')}</p>
<div className="preset-rail__buttons">
{SEASON_PRESETS.map((preset) => (
<button
key={preset.key}
type="button"
className="preset-rail__button"
onClick={() => void onSelectPreset(preset)}
disabled={isBusy}
data-testid={`preset-rail-${preset.key}`}
aria-label={t('calendar.presetAddAria', { name: t(preset.labelKey) })}
>
<span className="preset-rail__button-name">{t(preset.labelKey)}</span>
<span className="preset-rail__button-detail">
{preset.defaultWeeks} {t(preset.defaultWeeks === 1 ? 'wishEditor.weekUnit' : 'wishEditor.weeksUnit')}
{' · '}
{t('wishEditor.flexBadge')}
</span>
</button>
))}
<div className="preset-chips" data-testid="preset-rail" role="group" aria-label={t('calendar.presetChipsLabel')}>
<h3 className="visually-hidden">{t('calendar.railHeading')}</h3>
<p className="visually-hidden">{t('calendar.railHint')}</p>
<div className="preset-chips__row">
{SEASON_PRESETS.map((preset) => {
const weeks = `${preset.defaultWeeks} ${t(preset.defaultWeeks === 1 ? 'wishEditor.weekUnit' : 'wishEditor.weeksUnit')}`
return (
<button
key={preset.key}
type="button"
className="btn btn--chip preset-chips__chip"
onClick={() => void onSelectPreset(preset)}
disabled={isBusy}
data-testid={`preset-rail-${preset.key}`}
aria-label={t('calendar.presetAddAria', { name: t(preset.labelKey) })}
title={`${weeks} · ${t('wishEditor.flexBadge')}`}
>
<Icon name="plus" size={16} />
<span className="preset-chips__name">{t(preset.labelKey)}</span>
<span className="preset-chips__meta">{t('calendar.presetWeeksShort', { count: preset.defaultWeeks })}</span>
</button>
)
})}
</div>
</aside>
</div>
)
}
@@ -0,0 +1,156 @@
/* The "?" help popover beside the month title. The panel is a floating
* layer, so it is one of the few places --shadow-float appears; the samples
* inside it reuse the grid's own day/band rules so they never drift from
* what the calendar actually draws. */
.calendar-help {
position: relative;
flex-shrink: 0;
}
.calendar-help__panel {
position: absolute;
top: calc(100% + var(--sp-1));
right: 0;
z-index: var(--z-popover);
width: min(320px, calc(100vw - var(--sp-6)));
padding: var(--sp-4);
background-color: var(--c-surface);
border-radius: var(--radius-lg);
box-shadow: var(--shadow-float);
}
.calendar-help__title {
margin: 0 0 var(--sp-2);
font-size: var(--fs-sm);
font-weight: 600;
color: var(--c-text-muted);
}
.calendar-help__title + .calendar-help__list + .calendar-help__title {
margin-top: var(--sp-4);
}
.calendar-help__list {
list-style: none;
margin: 0;
padding: 0;
}
.calendar-help__item {
display: flex;
align-items: center;
gap: var(--sp-3);
margin: 0;
min-height: 28px;
font-size: var(--fs-sm);
color: var(--c-text);
}
.calendar-help__label {
min-width: 0;
}
/* Day samples — the same recipe as `.calendar-day__number` and its
* modifiers in calendar.css, at the same 14px. */
.calendar-help__day {
display: inline-flex;
align-items: flex-start;
gap: 2px;
width: 44px;
flex-shrink: 0;
font-size: var(--fs-sm);
font-weight: 500;
font-variant-numeric: tabular-nums;
color: var(--c-text);
}
.calendar-help__numeral {
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 24px;
height: 24px;
}
.calendar-help__sup {
font-size: var(--fs-xs);
font-weight: 600;
line-height: 1;
margin-top: 2px;
}
.calendar-help__day--red {
color: var(--c-day-red);
}
.calendar-help__day--klamdag {
color: var(--c-day-klamdag);
}
.calendar-help__day--saturday {
color: var(--c-day-saturday);
}
.calendar-help__day--today .calendar-help__numeral {
background-color: var(--c-accent);
color: var(--c-surface);
border-radius: var(--radius-full);
}
.calendar-help__day--override {
color: var(--c-text-muted);
}
.calendar-help__day--override .calendar-help__numeral {
color: var(--c-text);
text-decoration: underline dotted;
text-underline-offset: 3px;
}
.calendar-help__day--excluded .calendar-help__numeral {
text-decoration: line-through;
color: var(--c-text-muted);
}
/* Band samples — same fills/inks/outline as `.calendar-band--*`. */
.calendar-help__band {
display: inline-flex;
align-items: center;
justify-content: center;
width: 44px;
height: 18px;
flex-shrink: 0;
border-radius: var(--radius-band);
font-size: var(--fs-xs);
font-weight: 600;
background-color: var(--c-leave-vacation);
color: var(--c-leave-vacation-ink);
}
.calendar-help__band--sjukpenning {
background-color: var(--c-leave-sjukpenning);
color: var(--c-leave-sjukpenning-ink);
}
.calendar-help__band--lagsta {
background-color: var(--c-leave-lagsta);
background-image: repeating-linear-gradient(
135deg,
transparent 0 4px,
var(--c-leave-lagsta-hatch) 4px 5px
);
box-shadow: inset 0 0 0 1px var(--c-leave-lagsta-outline);
color: var(--c-leave-lagsta-ink);
}
.calendar-help__band--self {
box-shadow: inset 2px 0 0 var(--c-band-cap-self);
}
.calendar-help__band--partner {
box-shadow: inset 2px 0 0 var(--c-band-cap-partner);
}
.calendar-help__band--pinned {
box-shadow: inset 0 -2px 0 var(--c-leave-vacation-ink);
}
@@ -1,3 +1,6 @@
/* Period popover — the modal shell, fields, buttons and the inline delete
* confirmation all come from primitives.css; this file only holds what is
* specific to editing a period. */
.period-popover__adhoc-notice {
font-size: var(--fs-sm);
color: var(--c-text-muted);
@@ -8,12 +11,13 @@
font-size: var(--fs-base);
font-weight: 500;
color: var(--c-text);
font-variant-numeric: tabular-nums;
}
.period-popover__fieldset {
border: none;
padding: 0;
margin: 0 0 var(--sp-4) 0;
margin: 0 0 var(--sp-2) 0;
}
.period-popover__radio-label,
@@ -21,6 +25,7 @@
display: flex;
align-items: center;
font-size: var(--fs-sm);
font-weight: 400;
cursor: pointer;
user-select: none;
margin-bottom: var(--sp-2);
@@ -38,18 +43,28 @@
.period-popover__hint {
font-size: var(--fs-xs);
color: var(--c-text-faint);
margin: calc(var(--sp-2) * -1) 0 var(--sp-2) var(--sp-6);
font-style: italic;
color: var(--c-text-muted);
margin: 0 0 var(--sp-2) var(--sp-5);
}
/* todo.md #9 — the three-way remove: the delete-scope buttons sit side by
* side, same row as the other footer buttons. */
/* "Ta bort…" reveals the three delete scopes as a stack under the footer's
* own row: the group takes the full footer width and sorts itself last, so
* the Cancel/Save pair keeps its place above it. */
.period-popover__delete-group {
display: flex;
flex-direction: column;
gap: var(--sp-2);
flex-basis: 100%;
order: 10;
padding-top: var(--sp-2);
}
/* Footer, buttons and the inline delete-confirmation row are shared with
* the other modals via .modal__footer / .btn* / .modal__delete-confirm* in
* src/styles/primitives.css — see that file for the button formula. */
.period-popover__delete-option {
color: var(--c-danger);
justify-content: flex-start;
}
.period-popover__delete-option:hover:not(:disabled) {
border-color: var(--c-danger);
color: var(--c-danger);
}
@@ -1,63 +1,28 @@
.preset-rail {
width: 200px;
flex-shrink: 0;
padding: var(--sp-4);
background-color: var(--c-surface);
border: 1px solid var(--c-border);
border-radius: var(--radius-lg);
align-self: flex-start;
/* The season presets as a chip row above the month grid. The chips
* themselves are the shared `.btn--chip` primitive; this file only lays the
* row out and sizes the muted "6 v" suffix. */
.preset-chips {
margin-bottom: var(--sp-4);
}
.preset-rail__heading {
margin: 0 0 var(--sp-1) 0;
font-size: var(--fs-sm);
font-weight: 600;
color: var(--c-text);
}
.preset-rail__hint {
margin: 0 0 var(--sp-3) 0;
font-size: var(--fs-xs);
color: var(--c-text-muted);
}
.preset-rail__buttons {
.preset-chips__row {
display: flex;
flex-direction: column;
flex-wrap: wrap;
gap: var(--sp-2);
}
.preset-rail__button {
display: flex;
flex-direction: column;
align-items: flex-start;
gap: 2px;
padding: var(--sp-2) var(--sp-3);
border: 1px solid var(--c-border);
border-radius: var(--radius-sm);
background-color: var(--c-surface);
cursor: pointer;
text-align: left;
transition: background-color 0.2s, border-color 0.2s;
.preset-chips__chip {
gap: var(--sp-1);
padding-left: var(--sp-2);
flex-shrink: 0;
}
.preset-rail__button:hover:not(:disabled) {
background-color: var(--c-accent-weak);
border-color: var(--c-accent);
}
.preset-rail__button:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.preset-rail__button-name {
font-size: var(--fs-sm);
font-weight: 600;
.preset-chips__name {
color: var(--c-text);
}
.preset-rail__button-detail {
font-size: var(--fs-xs);
color: var(--c-text-muted);
.preset-chips__meta {
color: var(--c-text-faint);
font-weight: 400;
font-variant-numeric: tabular-nums;
}
@@ -36,7 +36,14 @@ describe('CalendarDay', () => {
const { getByTestId } = render(<CalendarDay date="2026-06-15" />)
const day = getByTestId('calendar-day')
expect(day).toBeInTheDocument()
expect(day.textContent).toContain('15')
expect(day.querySelector('.calendar-day__number')).toHaveTextContent('15')
})
it('keeps the R mark beside the numeral, inside the same head row', () => {
const { getByTestId } = render(<CalendarDay date="2026-06-14" dayType="weekend" />)
const head = getByTestId('calendar-day').querySelector('.calendar-day__head')
expect(head?.querySelector('.calendar-day__number')).toHaveTextContent('14')
expect(head?.querySelector('[data-testid="calendar-day-type-badge"]')).toHaveTextContent('R')
})
it('renders as disabled when isCurrentMonth is false', () => {
@@ -47,32 +54,6 @@ describe('CalendarDay', () => {
expect(day).toHaveClass('calendar-day--disabled')
})
it('renders events when provided', () => {
const { getByTestId } = render(
<CalendarDay
date="2026-06-15"
events={[
{ type: 'vacation', color: '#FF5733', title: 'Vacation' },
{ type: 'sick', color: '#FFD700', title: 'Sick leave' },
]}
/>
)
const events = getByTestId('calendar-day-events')
expect(events).toBeInTheDocument()
expect(events.children).toHaveLength(2)
})
it('applies has-events class when events exist', () => {
const { getByTestId } = render(
<CalendarDay
date="2026-06-15"
events={[{ type: 'vacation', color: '#FF5733' }]}
/>
)
const day = getByTestId('calendar-day')
expect(day).toHaveClass('calendar-day--has-events')
})
it('calls onClick when clicked', () => {
const onClick = vi.fn()
const { getByTestId } = render(
@@ -115,26 +96,6 @@ describe('CalendarDay', () => {
expect(onClick).toHaveBeenCalledTimes(1)
})
it('provides accessibility label with event count', () => {
const { getByTestId } = render(
<CalendarDay
date="2026-06-15"
events={[
{ type: 'vacation', color: '#FF5733' },
{ type: 'sick', color: '#FFD700' },
]}
/>
)
const day = getByTestId('calendar-day')
expect(day).toHaveAttribute('aria-label', '2026-06-15 med 2 händelse(r)')
})
it('renders without events by default', () => {
const { queryByTestId } = render(<CalendarDay date="2026-06-15" />)
const events = queryByTestId('calendar-day-events')
expect(events).not.toBeInTheDocument()
})
it('applies today class when isToday is true', () => {
const { getByTestId } = render(
<CalendarDay date="2026-06-15" isToday={true} />
@@ -234,23 +195,25 @@ describe('CalendarDay', () => {
})
describe('§3.6 workspace day overrides', () => {
it('marks an override with a distinct class and badge, working-day variant', () => {
it('marks an override with a distinct class and a briefcase icon, working-day variant', () => {
const { getByTestId } = render(
<CalendarDay date="2026-06-15" isOverride={true} overrideIsWorkingDay={true} />
)
const day = getByTestId('calendar-day')
expect(day).toHaveClass('calendar-day--override')
expect(day.getAttribute('aria-label')).toContain('arbetsdag')
expect(getByTestId('calendar-day-override-badge')).toBeInTheDocument()
const badge = getByTestId('calendar-day-override-badge')
expect(badge.querySelector('[data-icon="briefcase"]')).toBeInTheDocument()
})
it('marks an override with a distinct class and badge, free-day variant', () => {
it('marks an override with a distinct class and a check icon, free-day variant', () => {
const { getByTestId } = render(
<CalendarDay date="2026-06-15" isOverride={true} overrideIsWorkingDay={false} />
)
const day = getByTestId('calendar-day')
expect(day).toHaveClass('calendar-day--override')
expect(day.getAttribute('aria-label')).toContain('ledig dag')
expect(getByTestId('calendar-day-override-badge').querySelector('[data-icon="check"]')).toBeInTheDocument()
})
it('combines a statutory red day with an override distinctly', () => {
@@ -272,11 +235,13 @@ describe('CalendarDay', () => {
})
describe('user-removed days (PlannedDayExclusion)', () => {
it('marks a removed day with its own class, badge and aria descriptor', () => {
it('marks a removed day with its own class, a hidden badge hook and an aria descriptor', () => {
const { getByTestId } = render(<CalendarDay date="2026-06-15" isExcluded={true} />)
const day = getByTestId('calendar-day')
expect(day).toHaveClass('calendar-day--excluded')
expect(getByTestId('calendar-day-excluded-badge')).toBeInTheDocument()
// The strike-through numeral is the visible mark; the badge only
// keeps a findable hook and stays out of sight.
expect(getByTestId('calendar-day-excluded-badge')).toHaveClass('visually-hidden')
expect(day.getAttribute('aria-label')).toContain('borttagen dag')
})
+26 -30
View File
@@ -2,10 +2,10 @@ import { ReactNode } from 'react'
import { useTranslation } from '@/i18n'
import { formatHolidayName } from '@/utils/dates'
import type { CalendarDayType } from '@/types'
import { Icon } from './Icon'
export interface CalendarDayProps {
date: string
events?: { type: string; color: string; title?: string }[] | undefined
isCurrentMonth?: boolean | undefined
onClick?: (() => void) | undefined
isToday?: boolean | undefined
@@ -28,9 +28,16 @@ export interface CalendarDayProps {
isExcluded?: boolean | undefined
}
/**
* One day cell. Every state is carried by the numeral, never by a cell fill
* (see calendar.css): a red day is a red numeral with a small "R", a
* klämdag an ochre numeral with a "K", today a filled circle, an override a
* dotted underline plus a 16px icon, a removed day a struck-through numeral.
* The R/K/removed marks keep their `data-testid`s so the e2e suite and the
* unit tests can find them regardless of how they are drawn.
*/
export function CalendarDay({
date,
events = [],
isCurrentMonth = true,
onClick,
isToday = false,
@@ -92,14 +99,12 @@ export function CalendarDay({
descriptors.push(t('calendarDay.excludedAria'))
}
const baseLabel = events.length > 0 ? t('calendarDay.ariaWithEvents', { date, count: events.length }) : date
const ariaLabel = descriptors.length > 0 ? `${baseLabel}, ${descriptors.join(', ')}` : baseLabel
const ariaLabel = descriptors.length > 0 ? `${date}, ${descriptors.join(', ')}` : date
const title = descriptors.length > 0 ? descriptors.join(' · ') : undefined
const className = [
'calendar-day',
!isCurrentMonth ? 'calendar-day--disabled' : '',
events.length > 0 ? 'calendar-day--has-events' : '',
isToday ? 'calendar-day--today' : '',
isSelected ? 'calendar-day--selected' : '',
isRedDay ? 'calendar-day--red-day' : '',
@@ -137,44 +142,35 @@ export function CalendarDay({
}}
onPointerEnter={() => onPointerEnter?.(date)}
>
<div className="calendar-day__number">{dayNum}</div>
{(isRedDay || dayType === 'klamdag') && (
<span className="calendar-day__type-badge" data-testid="calendar-day-type-badge" aria-hidden="true">
{dayType === 'klamdag' ? 'K' : 'R'}
</span>
)}
<div className="calendar-day__head">
<span className="calendar-day__number">{dayNum}</span>
{(isRedDay || dayType === 'klamdag') && (
<span className="calendar-day__type-badge" data-testid="calendar-day-type-badge" aria-hidden="true">
{dayType === 'klamdag' ? 'K' : 'R'}
</span>
)}
{isOverride && (
<span className="calendar-day__override-badge" data-testid="calendar-day-override-badge" aria-hidden="true">
<Icon name={overrideIsWorkingDay ? 'briefcase' : 'check'} size={16} />
</span>
)}
</div>
{cleanHolidayName && (
<div className="calendar-day__holiday-name" data-testid="calendar-day-holiday-name" title={cleanHolidayName}>
{cleanHolidayName}
</div>
)}
{isOverride && (
<span className="calendar-day__override-badge" data-testid="calendar-day-override-badge" aria-hidden="true">
{overrideIsWorkingDay ? '⚒' : '✓'}
</span>
)}
{isExcluded && (
// The strike-through numeral is the visible mark; this span only
// keeps a findable hook for tests and the e2e suite.
<span
className="calendar-day__excluded-badge"
className="calendar-day__excluded-badge visually-hidden"
data-testid="calendar-day-excluded-badge"
aria-hidden="true"
>
⊘
</span>
)}
{events.length > 0 && (
<div className="calendar-day__events" data-testid="calendar-day-events">
{events.map((event, idx) => (
<div
key={idx}
className="calendar-day__event"
style={{ backgroundColor: event.color }}
title={event.title ?? event.type}
data-testid={`event-${idx}`}
/>
))}
</div>
)}
</div>
)
}
@@ -33,7 +33,8 @@ describe('CalendarEvent', () => {
)
const button = getByTestId('dismiss-button')
expect(button).toBeInTheDocument()
expect(button.textContent).toBe('×')
expect(button).toHaveAttribute('aria-label', 'Avfärda vacation')
expect(button.querySelector('[data-icon="close"]')).toBeInTheDocument()
})
it('does not render dismiss button when onDismiss not provided', () => {
@@ -1,5 +1,6 @@
import { ReactNode } from 'react'
import { useTranslation } from '@/i18n'
import { Icon } from './Icon'
export interface CalendarEventProps {
leaveType: string
@@ -51,7 +52,7 @@ export function CalendarEvent({
aria-label={t('calendarEvent.dismissAria', { leaveType })}
data-testid="dismiss-button"
>
×
<Icon name="close" size={16} />
</button>
)}
</div>
@@ -411,6 +411,57 @@ describe('CalendarWeek', () => {
})
})
describe('band modifiers: type by fill/ink/outline, owner by the left cap', () => {
it('names the representative funding type as a bare modifier', () => {
const plannedDays = [pd({ id: '1', date: '2026-01-05', dayType: 'lagsta' })]
render(<CalendarWeek {...defaultProps} plannedDays={plannedDays} />)
const band = screen.getAllByTestId(/^calendar-band-/)[0]
expect(band).toHaveClass('calendar-band--lagsta')
expect(band).not.toHaveClass('calendar-band--vacation')
})
it('marks the logged-in parent\'s band --self and anyone else\'s --partner', () => {
const plannedDays = [
pd({ id: '1', date: '2026-01-05', parentPersonalDataUuid: 'parent-1' }),
pd({ id: '2', date: '2026-01-06', parentPersonalDataUuid: 'other-parent' }),
]
render(<CalendarWeek {...defaultProps} plannedDays={plannedDays} />)
const bands = screen.getAllByTestId(/^calendar-band-/)
const own = bands.find((b) => b.getAttribute('data-testid')?.includes('parent-1'))
const partner = bands.find((b) => b.getAttribute('data-testid')?.includes('other-parent'))
expect(own).toHaveClass('calendar-band--self')
expect(own).not.toHaveClass('calendar-band--partner')
expect(partner).toHaveClass('calendar-band--partner')
expect(partner).not.toHaveClass('calendar-band--self')
})
it('gives each funded tick its own type modifier, and none to a bridged gap', () => {
const plannedDays = [
pd({ id: '1', date: '2026-01-09', wishId: 'wish-1', dayType: 'sjukpenning' }),
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} />,
)
const ticks = Array.from(container.querySelectorAll('.calendar-band__day'))
expect(ticks[0]).toHaveClass('calendar-band__day--sjukpenning')
expect(ticks[1]).toHaveClass('calendar-band__day--gap')
expect(ticks[1]?.className).not.toMatch(/calendar-band__day--(vacation|sjukpenning|lagsta)/)
})
it('sets the band height and lane offsets from the 18px bar geometry', () => {
const plannedDays = [pd({ id: '1', date: '2026-01-05' })]
render(<CalendarWeek {...defaultProps} plannedDays={plannedDays} />)
const band = screen.getAllByTestId(/^calendar-band-/)[0]!
expect(band.style.height).toBe('18px')
expect(band.style.bottom).toBe('4px')
})
})
describe('band caps: rounded only at the true start/end of the whole period', () => {
it('rounds both ends when the whole band fits inside this week', () => {
const plannedDays = [pd({ id: '1', date: '2026-01-06' }), pd({ id: '2', date: '2026-01-07' })]
@@ -642,8 +693,8 @@ describe('CalendarWeek', () => {
// every row in the month renders the same height.
const { container } = render(<CalendarWeek {...defaultProps} plannedDays={[]} monthMaxLaneCount={2} />)
const week = container.querySelector('.calendar-week') as HTMLElement
// bandBottomInset(4) + 2 lanes * (barHeight(20) + laneGap(2)) = 48px
expect(week.style.getPropertyValue('--band-reserved-height')).toBe('48px')
// bandBottomInset(4) + 2 lanes * (barHeight(18) + laneGap(2)) = 44px
expect(week.style.getPropertyValue('--band-reserved-height')).toBe('44px')
})
it('does not shrink below what this week\'s own bands need, even if monthMaxLaneCount is smaller', () => {
@@ -660,15 +711,15 @@ describe('CalendarWeek', () => {
)
const week = container.querySelector('.calendar-week') as HTMLElement
// This week's own 2 lanes win over the smaller monthMaxLaneCount.
expect(week.style.getPropertyValue('--band-reserved-height')).toBe('48px')
expect(week.style.getPropertyValue('--band-reserved-height')).toBe('44px')
})
it('falls back to this week\'s own lane count when monthMaxLaneCount is omitted (single-week render)', () => {
const plannedDays = [pd({ id: '1', date: '2026-01-05' })]
const { container } = render(<CalendarWeek {...defaultProps} plannedDays={plannedDays} />)
const week = container.querySelector('.calendar-week') as HTMLElement
// bandBottomInset(4) + 1 lane * (barHeight(20) + laneGap(2)) = 26px
expect(week.style.getPropertyValue('--band-reserved-height')).toBe('26px')
// bandBottomInset(4) + 1 lane * (barHeight(18) + laneGap(2)) = 24px
expect(week.style.getPropertyValue('--band-reserved-height')).toBe('24px')
})
it('gives every week row the same --band-reserved-height when rendered together with a shared monthMaxLaneCount, even though only one has bands', () => {
@@ -695,7 +746,7 @@ describe('CalendarWeek', () => {
expect(weeks).toHaveLength(2)
const heights = weeks.map((week) => week.style.getPropertyValue('--band-reserved-height'))
expect(new Set(heights).size).toBe(1)
expect(heights[0]).toBe('48px')
expect(heights[0]).toBe('44px')
})
})
@@ -1,6 +1,6 @@
import { ReactNode, useMemo } from 'react'
import type { PlannedLeaveDay, CalendarDayEntry } from '@/types'
import { getColorForType, getInkForType, getLabelForType, getTypeModifier } from '@/utils/leaveTypes'
import { getColorForType, getInkForType, getLabelForType, getTypeKey } from '@/utils/leaveTypes'
import { getISOWeekNumber, formatDateLocalISO } from '@/utils/dates'
import { useTranslation } from '@/i18n'
import { CalendarDay } from './CalendarDay'
@@ -311,7 +311,7 @@ export function CalendarWeek({
// edge AND, via `bandsReservedHeight`, how much bottom padding
// `.calendar-day` reserves so its number/badges/holiday name never sit
// under a band.
const barHeight = 20
const barHeight = 18
const laneGap = 2
const bandBottomInset = 4
const laneCount = bandSegments.length > 0 ? Math.max(...bandSegments.map((b) => b.lane)) + 1 : 0
@@ -398,9 +398,14 @@ export function CalendarWeek({
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.
const classNames = [
'calendar-band',
`calendar-band--${getTypeModifier(representativeType)}`,
`calendar-band--${getTypeKey(representativeType)}`,
segment.parentUuid === personalDataUuid ? 'calendar-band--self' : 'calendar-band--partner',
segment.isBandStart ? 'calendar-band--start' : '',
segment.isBandEnd ? 'calendar-band--end' : '',
segment.wishId && segment.wishId === flashWishId ? 'calendar-band--flash' : '',
@@ -453,7 +458,7 @@ export function CalendarWeek({
key={info.date}
className={[
'calendar-band__day',
info.isGap ? 'calendar-band__day--gap' : '',
info.isGap ? 'calendar-band__day--gap' : `calendar-band__day--${getTypeKey(info.dayType ?? 'vacation')}`,
info.isPinned ? 'calendar-band__day--pinned' : '',
]
.filter(Boolean)
@@ -16,6 +16,8 @@ describe('PlannedDayModal', () => {
{ id: 'p2', name: 'Parent 2' },
]
// A solo household: the type select is shown directly (no "Advanced"
// disclosure), defaulting to "let the planner choose".
const defaultProps = {
date: '2026-06-15',
isOpen: true,
@@ -27,14 +29,23 @@ describe('PlannedDayModal', () => {
onClose: mockOnClose,
}
// A couple: the pinned/exact controls sit behind the "Advanced" link.
const coupleProps = { ...defaultProps, parents: twoParents }
beforeEach(() => {
vi.clearAllMocks()
})
/** Couple mode only — a solo household has no toggle (see `pickType`). */
async function openAdvanced() {
await userEvent.click(screen.getByTestId('advanced-toggle'))
}
/** Solo mode's way onto the pinned path: pick a concrete type. */
async function pickType(type: 'vacation' | 'sjukpenning' | 'lagsta') {
await userEvent.selectOptions(screen.getByTestId('day-type-select'), type)
}
describe('visibility and rendering', () => {
it('returns null when modal is not open', () => {
const { container } = render(<PlannedDayModal {...defaultProps} isOpen={false} />)
@@ -46,10 +57,10 @@ describe('PlannedDayModal', () => {
expect(screen.getByRole('dialog', { hidden: true })).toBeInTheDocument()
})
it('renders the default (planner-decides) controls when there is no existing day', () => {
render(<PlannedDayModal {...defaultProps} isOpen={true} />)
it('renders the default (planner-decides) controls when there is no existing day (couple)', () => {
render(<PlannedDayModal {...coupleProps} isOpen={true} />)
expect(screen.getByTestId('modal-title')).toBeInTheDocument()
expect(screen.getByTestId('advanced-toggle')).toBeInTheDocument()
expect(screen.getByTestId('advanced-toggle')).toHaveClass('btn--link')
expect(screen.getByTestId('cancel-button')).toBeInTheDocument()
expect(screen.getByTestId('save-button')).toBeInTheDocument()
// The pinned/exact-type controls stay hidden until Advanced is opened.
@@ -60,8 +71,75 @@ describe('PlannedDayModal', () => {
render(<PlannedDayModal {...defaultProps} isOpen={true} parents={[{ id: 'p1', name: 'Parent 1' }]} />)
expect(screen.queryByTestId('parent-mode-select')).not.toBeInTheDocument()
expect(screen.queryByTestId('wants-dubbeldag-checkbox')).not.toBeInTheDocument()
// The advanced escape hatch is still available regardless of household size.
expect(screen.getByTestId('advanced-toggle')).toBeInTheDocument()
expect(screen.queryByTestId('parent-select')).not.toBeInTheDocument()
})
})
describe('solo household — the type select is the only question, so it is shown directly', () => {
it('shows the type select with "let the planner choose" preselected and no Advanced toggle', () => {
render(<PlannedDayModal {...defaultProps} isOpen={true} />)
expect(screen.queryByTestId('advanced-toggle')).not.toBeInTheDocument()
const select = screen.getByTestId('day-type-select') as HTMLSelectElement
expect(select).toHaveValue('auto')
expect(Array.from(select.options).map((o) => o.value)).toEqual(['auto', 'vacation', 'sjukpenning', 'lagsta'])
expect(select.options[0]).toHaveTextContent('Låt planeraren välja')
// Nothing to pin yet, so the dubbeldag flag stays out of the way.
expect(screen.queryByTestId('dubbeldag-checkbox')).not.toBeInTheDocument()
})
it('saves as a planner-funded wish for "either" parent while the planner-decides choice is kept', async () => {
mockOnSaveWish.mockResolvedValue(undefined)
render(<PlannedDayModal {...defaultProps} isOpen={true} />)
await userEvent.click(screen.getByTestId('save-button'))
await waitFor(() =>
expect(mockOnSaveWish).toHaveBeenCalledWith({ parentMode: 'either', parentPersonalDataUuid: null, wantsDubbeldag: false }),
)
expect(mockOnSavePinned).not.toHaveBeenCalled()
})
it('picking a concrete type switches the save to the pinned path', async () => {
mockOnSavePinned.mockResolvedValue(undefined)
render(<PlannedDayModal {...defaultProps} isOpen={true} />)
await pickType('sjukpenning')
expect(screen.getByTestId('dubbeldag-checkbox')).toBeInTheDocument()
await userEvent.click(screen.getByTestId('save-button'))
await waitFor(() =>
expect(mockOnSavePinned).toHaveBeenCalledWith({ dayType: 'sjukpenning', parentId: 'p1', isDubbeldag: false }),
)
expect(mockOnSaveWish).not.toHaveBeenCalled()
})
it('going back to "let the planner choose" clears the dubbeldag flag and returns to the wish path', async () => {
mockOnSaveWish.mockResolvedValue(undefined)
render(<PlannedDayModal {...defaultProps} isOpen={true} />)
await pickType('lagsta')
await userEvent.click(screen.getByTestId('dubbeldag-checkbox'))
expect(screen.getByTestId('dubbeldag-checkbox')).toBeChecked()
await userEvent.selectOptions(screen.getByTestId('day-type-select'), 'auto')
expect(screen.queryByTestId('dubbeldag-checkbox')).not.toBeInTheDocument()
await userEvent.click(screen.getByTestId('save-button'))
await waitFor(() => expect(mockOnSaveWish).toHaveBeenCalled())
expect(mockOnSavePinned).not.toHaveBeenCalled()
})
it('ignores defaultDayType for a new solo day — the planner-decides choice wins', () => {
render(<PlannedDayModal {...defaultProps} isOpen={true} defaultDayType="sjukpenning" />)
expect(screen.getByTestId('day-type-select')).toHaveValue('auto')
})
it('editing an existing day offers only the three concrete types, without the planner option', () => {
const existingDay = makePlannedLeaveDay({ id: 'day-1', parentPersonalDataUuid: 'p1', dayType: 'lagsta' })
render(<PlannedDayModal {...defaultProps} isOpen={true} existingDay={existingDay} />)
const select = screen.getByTestId('day-type-select') as HTMLSelectElement
expect(Array.from(select.options).map((o) => o.value)).toEqual(['vacation', 'sjukpenning', 'lagsta'])
expect(select).toHaveValue('lagsta')
expect(screen.queryByTestId('advanced-toggle')).not.toBeInTheDocument()
})
it('a range selection keeps the select on the planner-decides choice', () => {
const existingDay = makePlannedLeaveDay({ id: 'day-1', parentPersonalDataUuid: 'p1' })
render(<PlannedDayModal {...defaultProps} isOpen={true} isRangeMode dayCount={3} existingDay={existingDay} />)
expect(screen.getByTestId('day-type-select')).toHaveValue('auto')
})
})
@@ -176,7 +254,7 @@ describe('PlannedDayModal', () => {
})
it('the "Advanced" toggle reveals the pinned/exact controls without submitting', async () => {
render(<PlannedDayModal {...defaultProps} isOpen={true} parents={twoParents} />)
render(<PlannedDayModal {...coupleProps} isOpen={true} parents={twoParents} />)
await openAdvanced()
expect(screen.getByTestId('day-type-select')).toBeInTheDocument()
expect(screen.getByTestId('parent-select')).toBeInTheDocument()
@@ -186,7 +264,7 @@ describe('PlannedDayModal', () => {
it('saving while Advanced is open calls onSavePinned, not onSaveWish', async () => {
mockOnSavePinned.mockResolvedValue(undefined)
render(<PlannedDayModal {...defaultProps} isOpen={true} parents={twoParents} />)
render(<PlannedDayModal {...coupleProps} isOpen={true} parents={twoParents} />)
await openAdvanced()
await userEvent.click(screen.getByTestId('save-button'))
await waitFor(() =>
@@ -207,13 +285,13 @@ describe('PlannedDayModal', () => {
it('a range selection never forces Advanced open, even if the first date happens to carry an existing day', () => {
const existingDay = makePlannedLeaveDay({ id: 'day-1', parentPersonalDataUuid: 'p1' })
render(<PlannedDayModal {...defaultProps} isOpen={true} isRangeMode dayCount={3} existingDay={existingDay} />)
render(<PlannedDayModal {...coupleProps} isOpen={true} isRangeMode dayCount={3} existingDay={existingDay} />)
expect(screen.queryByTestId('day-type-select')).not.toBeInTheDocument()
expect(screen.getByTestId('advanced-toggle')).toBeInTheDocument()
})
it('the advanced parent select is controlled by selectedParentId and reports changes via onParentChange', async () => {
render(<PlannedDayModal {...defaultProps} isOpen={true} parents={twoParents} selectedParentId="p1" />)
render(<PlannedDayModal {...coupleProps} isOpen={true} parents={twoParents} selectedParentId="p1" />)
await openAdvanced()
const select = screen.getByTestId('parent-select') as HTMLSelectElement
expect(select).toHaveValue('p1')
@@ -244,7 +322,7 @@ describe('PlannedDayModal', () => {
describe('day type selection (inside Advanced)', () => {
it('renders day type select with all options', async () => {
render(<PlannedDayModal {...defaultProps} isOpen={true} />)
render(<PlannedDayModal {...coupleProps} isOpen={true} />)
await openAdvanced()
const select = screen.getByTestId('day-type-select') as HTMLSelectElement
expect(select.options).toHaveLength(3)
@@ -254,19 +332,19 @@ describe('PlannedDayModal', () => {
})
it('defaults to vacation day type', async () => {
render(<PlannedDayModal {...defaultProps} isOpen={true} />)
render(<PlannedDayModal {...coupleProps} isOpen={true} />)
await openAdvanced()
expect(screen.getByTestId('day-type-select')).toHaveValue('vacation')
})
it('uses defaultDayType when provided and no existing day', async () => {
render(<PlannedDayModal {...defaultProps} isOpen={true} defaultDayType="sjukpenning" />)
render(<PlannedDayModal {...coupleProps} isOpen={true} defaultDayType="sjukpenning" />)
await openAdvanced()
expect(screen.getByTestId('day-type-select')).toHaveValue('sjukpenning')
})
it('allows changing day type', async () => {
render(<PlannedDayModal {...defaultProps} isOpen={true} />)
render(<PlannedDayModal {...coupleProps} isOpen={true} />)
await openAdvanced()
const select = screen.getByTestId('day-type-select') as HTMLSelectElement
await userEvent.selectOptions(select, 'sjukpenning')
@@ -274,9 +352,9 @@ describe('PlannedDayModal', () => {
})
it('disables day type select when isSaving', async () => {
const { rerender } = render(<PlannedDayModal {...defaultProps} isOpen={true} />)
const { rerender } = render(<PlannedDayModal {...coupleProps} isOpen={true} />)
await openAdvanced()
rerender(<PlannedDayModal {...defaultProps} isOpen={true} isSaving={true} />)
rerender(<PlannedDayModal {...coupleProps} isOpen={true} isSaving={true} />)
expect(screen.getByTestId('day-type-select')).toBeDisabled()
})
})
@@ -284,18 +362,18 @@ describe('PlannedDayModal', () => {
describe('advanced parent select', () => {
it('does not render parent select when only one parent', async () => {
render(<PlannedDayModal {...defaultProps} isOpen={true} parents={[{ id: 'p1', name: 'Parent 1' }]} />)
await openAdvanced()
await pickType('vacation')
expect(screen.queryByTestId('parent-select')).not.toBeInTheDocument()
})
it('renders parent select when multiple parents', async () => {
render(<PlannedDayModal {...defaultProps} isOpen={true} parents={twoParents} />)
render(<PlannedDayModal {...coupleProps} isOpen={true} parents={twoParents} />)
await openAdvanced()
expect(screen.getByTestId('parent-select')).toBeInTheDocument()
})
it('shows all parents in select options', async () => {
render(<PlannedDayModal {...defaultProps} isOpen={true} parents={[{ id: 'p1', name: 'Alice' }, { id: 'p2', name: 'Bob' }]} />)
render(<PlannedDayModal {...coupleProps} isOpen={true} parents={[{ id: 'p1', name: 'Alice' }, { id: 'p2', name: 'Bob' }]} />)
await openAdvanced()
const select = screen.getByTestId('parent-select') as HTMLSelectElement
expect(select.options).toHaveLength(2)
@@ -304,28 +382,28 @@ describe('PlannedDayModal', () => {
})
it('disables parent select when isSaving', async () => {
const { rerender } = render(<PlannedDayModal {...defaultProps} isOpen={true} parents={twoParents} />)
const { rerender } = render(<PlannedDayModal {...coupleProps} isOpen={true} parents={twoParents} />)
await openAdvanced()
rerender(<PlannedDayModal {...defaultProps} isOpen={true} isSaving={true} parents={twoParents} />)
rerender(<PlannedDayModal {...coupleProps} isOpen={true} isSaving={true} parents={twoParents} />)
expect(screen.getByTestId('parent-select')).toBeDisabled()
})
})
describe('dubbeldag checkbox (inside Advanced)', () => {
it('renders dubbeldag checkbox', async () => {
render(<PlannedDayModal {...defaultProps} isOpen={true} />)
render(<PlannedDayModal {...coupleProps} isOpen={true} />)
await openAdvanced()
expect(screen.getByTestId('dubbeldag-checkbox')).toBeInTheDocument()
})
it('defaults to unchecked', async () => {
render(<PlannedDayModal {...defaultProps} isOpen={true} />)
render(<PlannedDayModal {...coupleProps} isOpen={true} />)
await openAdvanced()
expect(screen.getByTestId('dubbeldag-checkbox')).not.toBeChecked()
})
it('can be checked for non-vacation day types', async () => {
render(<PlannedDayModal {...defaultProps} isOpen={true} />)
render(<PlannedDayModal {...coupleProps} isOpen={true} />)
await openAdvanced()
await userEvent.selectOptions(screen.getByTestId('day-type-select'), 'sjukpenning')
await userEvent.click(screen.getByTestId('dubbeldag-checkbox'))
@@ -333,26 +411,26 @@ describe('PlannedDayModal', () => {
})
it('is disabled for vacation day type', async () => {
render(<PlannedDayModal {...defaultProps} isOpen={true} />)
render(<PlannedDayModal {...coupleProps} isOpen={true} />)
await openAdvanced()
expect(screen.getByTestId('dubbeldag-checkbox')).toBeDisabled()
})
it('shows hint when dayType is vacation', async () => {
render(<PlannedDayModal {...defaultProps} isOpen={true} />)
render(<PlannedDayModal {...coupleProps} isOpen={true} />)
await openAdvanced()
expect(screen.getByText(/Dubbeldagar gäller inte för semester/)).toBeInTheDocument()
})
it('hides hint when dayType is not vacation', async () => {
render(<PlannedDayModal {...defaultProps} isOpen={true} />)
render(<PlannedDayModal {...coupleProps} isOpen={true} />)
await openAdvanced()
await userEvent.selectOptions(screen.getByTestId('day-type-select'), 'sjukpenning')
expect(screen.queryByText(/Dubbeldagar gäller inte för semester/)).not.toBeInTheDocument()
})
it('unchecks dubbeldag when changing to vacation type', async () => {
render(<PlannedDayModal {...defaultProps} isOpen={true} />)
render(<PlannedDayModal {...coupleProps} isOpen={true} />)
await openAdvanced()
await userEvent.selectOptions(screen.getByTestId('day-type-select'), 'sjukpenning')
await userEvent.click(screen.getByTestId('dubbeldag-checkbox'))
@@ -365,7 +443,7 @@ describe('PlannedDayModal', () => {
describe('save functionality (Advanced)', () => {
it('calls onSavePinned with correct payload when save is clicked', async () => {
mockOnSavePinned.mockResolvedValue(undefined)
render(<PlannedDayModal {...defaultProps} isOpen={true} />)
render(<PlannedDayModal {...coupleProps} isOpen={true} />)
await openAdvanced()
await userEvent.click(screen.getByTestId('save-button'))
await waitFor(() =>
@@ -375,7 +453,7 @@ describe('PlannedDayModal', () => {
it('calls onSavePinned with selected day type', async () => {
mockOnSavePinned.mockResolvedValue(undefined)
render(<PlannedDayModal {...defaultProps} isOpen={true} />)
render(<PlannedDayModal {...coupleProps} isOpen={true} />)
await openAdvanced()
await userEvent.selectOptions(screen.getByTestId('day-type-select'), 'sjukpenning')
await userEvent.click(screen.getByTestId('save-button'))
@@ -386,7 +464,7 @@ describe('PlannedDayModal', () => {
it('calls onSavePinned with the selectedParentId prop', async () => {
mockOnSavePinned.mockResolvedValue(undefined)
render(<PlannedDayModal {...defaultProps} isOpen={true} parents={twoParents} selectedParentId="p2" />)
render(<PlannedDayModal {...coupleProps} isOpen={true} parents={twoParents} selectedParentId="p2" />)
await openAdvanced()
await userEvent.click(screen.getByTestId('save-button'))
await waitFor(() =>
@@ -396,7 +474,7 @@ describe('PlannedDayModal', () => {
it('calls onSavePinned with isDubbeldag flag', async () => {
mockOnSavePinned.mockResolvedValue(undefined)
render(<PlannedDayModal {...defaultProps} isOpen={true} />)
render(<PlannedDayModal {...coupleProps} isOpen={true} />)
await openAdvanced()
await userEvent.selectOptions(screen.getByTestId('day-type-select'), 'lagsta')
await userEvent.click(screen.getByTestId('dubbeldag-checkbox'))
@@ -437,7 +515,7 @@ describe('PlannedDayModal', () => {
it('displays local error after a failed pinned save', async () => {
mockOnSavePinned.mockRejectedValue(new Error('Pinned save failed'))
render(<PlannedDayModal {...defaultProps} isOpen={true} />)
render(<PlannedDayModal {...coupleProps} isOpen={true} />)
await openAdvanced()
await userEvent.click(screen.getByTestId('save-button'))
await waitFor(() => expect(screen.getByRole('alert')).toHaveTextContent('Pinned save failed'))
@@ -617,7 +695,7 @@ describe('PlannedDayModal', () => {
})
it('does not show a pinned notice for a brand-new day', async () => {
render(<PlannedDayModal {...defaultProps} isOpen={true} />)
render(<PlannedDayModal {...coupleProps} isOpen={true} />)
await openAdvanced()
expect(screen.queryByTestId('pinned-notice')).not.toBeInTheDocument()
})
@@ -1,6 +1,7 @@
import { ReactNode, useEffect, useState } from 'react'
import type { PlannedLeaveDay } from '@/types'
import { useTranslation } from '@/i18n'
import { Icon } from '@/components/common/Icon'
import './planned-day-modal.css'
/** The default path — todo.md #10/#11: the planner decides which day type
@@ -18,11 +19,22 @@ export interface PlannedDayModalSaveWishPayload {
* an exact day type for an exact parent, writing `planned_leave_day` rows
* directly. */
export interface PlannedDayModalSavePinnedPayload {
dayType: 'sjukpenning' | 'lagsta' | 'vacation'
dayType: PinnedDayType
parentId: string
isDubbeldag: boolean
}
type PinnedDayType = 'sjukpenning' | 'lagsta' | 'vacation'
/** What the type select can hold: a concrete type to pin, or — for a solo
* household, where the select is the only question left — `'auto'`, the
* planner-decides default. */
type DayTypeChoice = PinnedDayType | 'auto'
function isPinnedDayType(value: string): value is PinnedDayType {
return value === 'sjukpenning' || value === 'lagsta' || value === 'vacation'
}
/** One day the user removed by hand, ready to be offered back. */
export interface PlannedDayExclusionEntry {
id: string
@@ -83,8 +95,11 @@ export function PlannedDayModal({
// Editing an existing pinned day always reopens the advanced/pinned path —
// it must never be silently reinterpreted as a new planner-funded wish.
const forcedAdvanced = !isRangeMode && !!existingDay
// A solo household has nobody to choose between, so the "Advanced"
// disclosure would hide exactly one control. The type select is shown
// directly instead, with the planner-decides choice as its default.
const soloDirect = !hasMultipleParents && !forcedAdvanced
const [isAdvancedOpen, setIsAdvancedOpen] = useState(forcedAdvanced)
const advancedOpen = forcedAdvanced || isAdvancedOpen
// --- Default ("planner decides") path state -----------------------------
const [parentMode, setParentMode] = useState<'either' | 'both' | 'specific'>(
@@ -94,9 +109,15 @@ export function PlannedDayModal({
const [wantsDubbeldag, setWantsDubbeldag] = useState(false)
// --- Advanced ("pin exactly") path state ---------------------------------
const [dayType, setDayType] = useState<'sjukpenning' | 'lagsta' | 'vacation'>('vacation')
const [dayType, setDayType] = useState<DayTypeChoice>(soloDirect ? 'auto' : 'vacation')
const [isDubbeldag, setIsDubbeldag] = useState(false)
// Whether saving pins an exact type (the escape hatch) or hands the dates
// to the planner as a wish. Couples opt in through the disclosure; a solo
// household opts in by picking a concrete type in the select.
const pinnedPath = forcedAdvanced || (soloDirect ? dayType !== 'auto' : isAdvancedOpen)
const showPinnedControls = forcedAdvanced || soloDirect || isAdvancedOpen
const [localError, setLocalError] = useState<string | null>(null)
const [isConfirmingDelete, setIsConfirmingDelete] = useState(false)
@@ -115,11 +136,13 @@ export function PlannedDayModal({
}, [isOpen, date, isRangeMode])
useEffect(() => {
if (existingDay) {
setDayType(existingDay.dayType as 'sjukpenning' | 'lagsta' | 'vacation')
// A range is never an edit of its first date's day, so that day's type
// must not leak into the select as if it were being edited.
if (existingDay && !isRangeMode) {
setDayType(isPinnedDayType(existingDay.dayType) ? existingDay.dayType : 'vacation')
setIsDubbeldag(existingDay.isDubbeldag)
} else {
setDayType(defaultDayType ?? 'vacation')
setDayType(soloDirect ? 'auto' : (defaultDayType ?? 'vacation'))
setIsDubbeldag(false)
}
// Deliberately NOT a separate "if dayType === 'vacation', clear
@@ -131,7 +154,7 @@ export function PlannedDayModal({
// vacation/dubbeldag rule is instead enforced right where the user
// actually changes the type — see the advanced day-type `<select>`'s
// `onChange` below.
}, [isOpen, existingDay, defaultDayType])
}, [isOpen, existingDay, defaultDayType, soloDirect, isRangeMode])
useEffect(() => {
if (parentMode !== 'both') {
@@ -142,7 +165,7 @@ export function PlannedDayModal({
const handleSave = async () => {
try {
setLocalError(null)
if (advancedOpen) {
if (pinnedPath && dayType !== 'auto') {
await onSavePinned({ dayType, parentId: selectedParentId, isDubbeldag })
} else {
await onSaveWish({
@@ -217,7 +240,7 @@ export function PlannedDayModal({
<div className="modal__header">
<h2 data-testid="modal-title">{modalTitle}</h2>
<button type="button" className="btn btn--icon" onClick={onClose} aria-label={t('plannedDayModal.closeAria')}>
×
<Icon name="close" size={20} />
</button>
</div>
@@ -297,10 +320,10 @@ export function PlannedDayModal({
</div>
)}
{!forcedAdvanced && (
{!forcedAdvanced && hasMultipleParents && (
<button
type="button"
className="btn btn--ghost planned-day-modal__advanced-toggle"
className="btn btn--link planned-day-modal__advanced-toggle"
onClick={() => setIsAdvancedOpen((open) => !open)}
disabled={isSaving}
data-testid="advanced-toggle"
@@ -310,7 +333,7 @@ export function PlannedDayModal({
</button>
)}
{advancedOpen && (
{showPinnedControls && (
<div className="planned-day-modal__advanced" data-testid="advanced-section">
<div className="field">
<label htmlFor="day-type" className="field__label">
@@ -320,14 +343,16 @@ export function PlannedDayModal({
id="day-type"
value={dayType}
onChange={(e) => {
const value = e.target.value as 'sjukpenning' | 'lagsta' | 'vacation'
setDayType(value)
if (value === 'vacation') setIsDubbeldag(false)
const value = e.target.value
const next: DayTypeChoice = isPinnedDayType(value) ? value : 'auto'
setDayType(next)
if (next === 'vacation' || next === 'auto') setIsDubbeldag(false)
}}
className="field__control"
disabled={isSaving}
data-testid="day-type-select"
>
{soloDirect && <option value="auto">{t('plannedDayModal.autoOption')}</option>}
<option value="vacation">{t('plannedDayModal.vacationOption')}</option>
<option value="sjukpenning">{t('plannedDayModal.sjukpenningOption')}</option>
<option value="lagsta">{t('plannedDayModal.lagstaOption')}</option>
@@ -356,27 +381,30 @@ export function PlannedDayModal({
</div>
)}
<div className="field">
<label className="planned-day-modal__checkbox-label">
<input
type="checkbox"
checked={isDubbeldag}
onChange={(e) => setIsDubbeldag(e.target.checked)}
disabled={isSaving || dayType === 'vacation'}
data-testid="dubbeldag-checkbox"
/>
{' '}{t('plannedDayModal.doubleDayLabel')}
</label>
{dayType === 'vacation' && (
<p className="planned-day-modal__hint">
{t('plannedDayModal.doubleDayHint')}
</p>
)}
</div>
{dayType !== 'auto' && (
<div className="field">
<label className="planned-day-modal__checkbox-label">
<input
type="checkbox"
checked={isDubbeldag}
onChange={(e) => setIsDubbeldag(e.target.checked)}
disabled={isSaving || dayType === 'vacation'}
data-testid="dubbeldag-checkbox"
/>
{' '}{t('plannedDayModal.doubleDayLabel')}
</label>
{dayType === 'vacation' && (
<p className="planned-day-modal__hint">
{t('plannedDayModal.doubleDayHint')}
</p>
)}
</div>
)}
{existingDay?.isPinned && (
<p className="planned-day-modal__pinned-notice" data-testid="pinned-notice">
📌 {t('plannedDayModal.pinnedNotice')}
<Icon name="pin" size={16} />
<span>{t('plannedDayModal.pinnedNotice')}</span>
</p>
)}
</div>
@@ -1,3 +1,6 @@
/* Planned-day modal — the modal shell, fields, buttons and the inline
* delete confirmation all come from primitives.css; this file only holds
* what is specific to planning a day. */
.planned-day-modal__date {
font-size: var(--fs-sm);
color: var(--c-text-muted);
@@ -9,8 +12,10 @@
display: flex;
align-items: center;
font-size: var(--fs-sm);
font-weight: 400;
cursor: pointer;
user-select: none;
margin-bottom: 0;
}
.planned-day-modal__checkbox-label input[type='checkbox'] {
@@ -24,16 +29,17 @@
.planned-day-modal__hint {
font-size: var(--fs-xs);
color: var(--c-text-faint);
margin-top: var(--sp-2);
margin-left: var(--sp-6);
font-style: italic;
color: var(--c-text-muted);
margin: var(--sp-1) 0 0 var(--sp-5);
}
.planned-day-modal__pinned-notice {
display: flex;
align-items: center;
gap: var(--sp-2);
font-size: var(--fs-xs);
color: var(--c-text-muted);
margin: calc(var(--sp-2) * -1) 0 var(--sp-4) 0;
margin: 0;
}
/* "You removed this day" + the way back. Wraps and sizes itself by content
@@ -44,7 +50,7 @@
flex-wrap: wrap;
align-items: center;
gap: var(--sp-2);
font-size: var(--fs-xs);
font-size: var(--fs-sm);
color: var(--c-text-muted);
margin-bottom: var(--sp-4);
}
@@ -54,29 +60,17 @@
min-width: 0;
}
.planned-day-modal__override-switch {
display: flex;
gap: var(--sp-4);
margin-top: var(--sp-2);
}
.planned-day-modal__override-switch .planned-day-modal__checkbox-label {
margin-bottom: 0;
}
/* The couple-mode "Advanced" disclosure reads as a plain link that may wrap
* — `.btn--link` gives it the underline; this only lets the label break. */
.planned-day-modal__advanced-toggle {
align-self: flex-start;
padding-left: 0;
white-space: normal;
text-align: left;
font-size: var(--fs-sm);
}
.planned-day-modal__advanced {
display: flex;
flex-direction: column;
gap: var(--sp-4);
padding-top: var(--sp-2);
border-top: 1px solid var(--c-border);
}
/* Footer, buttons and the inline delete-confirmation row are shared with
* the other modals via .modal__footer / .btn* / .modal__delete-confirm* in
* src/styles/primitives.css — see that file for the button formula. */
+23 -9
View File
@@ -4,14 +4,6 @@ export const calendar = {
'calendar.selectMonthAria': 'Select month',
'calendar.selectYearAria': 'Select year',
'calendar.deleteError': 'Failed to delete the planned day',
'calendar.legendDoubleDay': 'Double day',
'calendar.legendIncomeBased': 'Income-based rate (sjukpenningnivå)',
'calendar.legendKlamdag': 'Bridge day',
'calendar.legendMinimumRate': 'Minimum rate (lägstanivå)',
'calendar.legendOverride': 'Exception (working/day off)',
'calendar.legendRedDay': 'R = public holiday',
'calendar.legendTitle': 'Legend',
'calendar.legendVacation': 'Vacation',
'calendar.loading': 'Loading calendar...',
'calendar.nextMonthAria': 'Next month',
'calendar.overrideError': 'Failed to update the exception',
@@ -30,7 +22,6 @@ export const calendar = {
'calendar.weekdayTue': 'Tue',
'calendar.weekdayWed': 'Wed',
'calendar.you': 'You',
'calendarDay.ariaWithEvents': '{date} with {count} event(s)',
'calendarDay.excludedAria': 'removed day — the planner skips it',
'calendarDay.klamdagAria': 'bridge day',
'calendarDay.overrideFreeAria': 'marked as a day off (exception)',
@@ -69,6 +60,7 @@ export const calendar = {
'plannedDayModal.savingButton': 'Saving…',
'plannedDayModal.sjukpenningOption': 'Sjukpenningnivå (income-based, 390-day tier)',
'plannedDayModal.vacationOption': 'Vacation',
'plannedDayModal.autoOption': 'Let the planner choose',
'redDaysList.effectiveFree': 'Off',
'redDaysList.effectiveWorking': 'Working day',
'redDaysList.emptyYear': 'No public holidays or exceptions this year.',
@@ -101,9 +93,30 @@ export const calendar = {
'calendar.undoError': 'Failed to undo the change',
'calendar.dismissToastAria': 'Dismiss the message',
// The "?" beside the month title — replaces the legend that used to sit
// under the grid. One line per mark the calendar draws.
'calendar.helpAria': 'What the calendar marks mean',
'calendar.helpTitle': 'Days',
'calendar.helpBandsTitle': 'Periods',
'calendar.helpRedDay': 'Public holiday (R) — a red day or a Sunday',
'calendar.helpKlamdag': 'Bridge day (K)',
'calendar.helpSaturday': 'Saturday',
'calendar.helpToday': 'Today',
'calendar.helpOverride': 'Exception — marked as a working day or a day off',
'calendar.helpExcluded': 'Removed day — the planner skips it',
'calendar.helpVacation': 'Vacation',
'calendar.helpSjukpenning': 'Income-based rate (sjukpenningnivå)',
'calendar.helpLagsta': 'Minimum rate (lägstanivå)',
'calendar.helpSelf': 'Your leave',
'calendar.helpPartner': "Your partner's leave",
'calendar.helpDubbeldag': 'Double day — both parents on the same day',
'calendar.helpPinned': 'Pinned by you — the planner leaves the day alone',
'calendar.railHeading': 'Add leave',
'calendar.railHint': 'Click to add — the calendar jumps to wherever it fits best.',
'calendar.presetAddAria': 'Add {name}',
'calendar.presetChipsLabel': 'Add leave',
'calendar.presetWeeksShort': '{count} wk',
'calendar.periodPopoverTitle': 'Edit period',
'calendar.periodAdHocTitle': 'Period',
@@ -135,6 +148,7 @@ export const calendar = {
'calendar.periodDeleteAdHocButton': 'Delete these days',
'calendar.periodCancelButton': 'Cancel',
'calendar.periodCloseAria': 'Close',
'calendar.periodDeleteMenuButton': 'Remove…',
// todo.md #9 — three-way remove: this day / this period (this year) / this
// period every year.
+23 -9
View File
@@ -4,14 +4,6 @@ export const calendar = {
'calendar.selectMonthAria': 'Välj månad',
'calendar.selectYearAria': 'Välj år',
'calendar.deleteError': 'Det gick inte att ta bort den planerade dagen',
'calendar.legendDoubleDay': 'Dubbeldag',
'calendar.legendIncomeBased': 'Sjukpenningnivå',
'calendar.legendKlamdag': 'Klämdag',
'calendar.legendMinimumRate': 'Lägstanivå',
'calendar.legendOverride': 'Undantag (arbets-/ledig dag)',
'calendar.legendRedDay': 'R = röd dag',
'calendar.legendTitle': 'Teckenförklaring',
'calendar.legendVacation': 'Semester',
'calendar.loading': 'Laddar kalender...',
'calendar.nextMonthAria': 'Nästa månad',
'calendar.overrideError': 'Det gick inte att uppdatera undantaget',
@@ -30,7 +22,6 @@ export const calendar = {
'calendar.weekdayTue': 'Tis',
'calendar.weekdayWed': 'Ons',
'calendar.you': 'Du',
'calendarDay.ariaWithEvents': '{date} med {count} händelse(r)',
'calendarDay.excludedAria': 'borttagen dag – planeraren hoppar över den',
'calendarDay.klamdagAria': 'klämdag',
'calendarDay.overrideFreeAria': 'markerad som ledig dag (undantag)',
@@ -69,6 +60,7 @@ export const calendar = {
'plannedDayModal.savingButton': 'Sparar…',
'plannedDayModal.sjukpenningOption': 'Sjukpenningnivå (390-nivån)',
'plannedDayModal.vacationOption': 'Semester',
'plannedDayModal.autoOption': 'Låt planeraren välja',
'redDaysList.effectiveFree': 'Ledig',
'redDaysList.effectiveWorking': 'Arbetsdag',
'redDaysList.emptyYear': 'Inga röda dagar eller undantag detta år.',
@@ -101,9 +93,30 @@ export const calendar = {
'calendar.undoError': 'Det gick inte att ångra ändringen',
'calendar.dismissToastAria': 'Stäng meddelandet',
// The "?" beside the month title — replaces the legend that used to sit
// under the grid. One line per mark the calendar draws.
'calendar.helpAria': 'Förklaring av kalenderns markeringar',
'calendar.helpTitle': 'Dagar',
'calendar.helpBandsTitle': 'Perioder',
'calendar.helpRedDay': 'Röd dag (R) — helgdag eller söndag',
'calendar.helpKlamdag': 'Klämdag (K)',
'calendar.helpSaturday': 'Lördag',
'calendar.helpToday': 'Idag',
'calendar.helpOverride': 'Undantag — markerad som arbetsdag eller ledig dag',
'calendar.helpExcluded': 'Borttagen dag — planeraren hoppar över den',
'calendar.helpVacation': 'Semester',
'calendar.helpSjukpenning': 'Sjukpenningnivå',
'calendar.helpLagsta': 'Lägstanivå',
'calendar.helpSelf': 'Din ledighet',
'calendar.helpPartner': 'Partnerns ledighet',
'calendar.helpDubbeldag': 'Dubbeldag — båda föräldrarna samma dag',
'calendar.helpPinned': 'Låst av dig — planeraren ändrar inte dagen',
'calendar.railHeading': 'Lägg till ledighet',
'calendar.railHint': 'Klicka för att lägga till — kalendern hoppar till där den passar bäst.',
'calendar.presetAddAria': 'Lägg till {name}',
'calendar.presetChipsLabel': 'Lägg till ledighet',
'calendar.presetWeeksShort': '{count} v',
'calendar.periodPopoverTitle': 'Redigera period',
'calendar.periodAdHocTitle': 'Period',
@@ -136,6 +149,7 @@ export const calendar = {
'calendar.periodDeleteAdHocButton': 'Ta bort dessa dagar',
'calendar.periodCancelButton': 'Avbryt',
'calendar.periodCloseAria': 'Stäng',
'calendar.periodDeleteMenuButton': 'Ta bort…',
// todo.md #9 — three-way remove: this day / this period (this year) / this
// period every year.
+36 -35
View File
@@ -336,9 +336,8 @@ describe('Calendar', () => {
// week" logic would add a 6th row of 7 disabled June cells that serves
// no purpose.
render(<Calendar />)
const title = screen.getByRole('heading', { level: 2 })
const yearSelect = title.querySelector('.calendar__year-select') as HTMLSelectElement
const monthSelect = title.querySelector('.calendar__month-select') as HTMLSelectElement
const yearSelect = screen.getByLabelText('Välj år') as HTMLSelectElement
const monthSelect = screen.getByLabelText('Välj månad') as HTMLSelectElement
fireEvent.change(yearSelect, { target: { value: '2026' } })
fireEvent.change(monthSelect, { target: { value: '4' } })
@@ -374,10 +373,20 @@ describe('Calendar', () => {
}
})
it('displays current month title', () => {
it('displays the month and year as real title text, capitalised', () => {
render(<Calendar />)
const title = screen.getByRole('heading', { level: 2 })
expect(title.textContent).toMatch(/\d{4}/)
expect(title.textContent).toMatch(
/^(Januari|Februari|Mars|April|Maj|Juni|Juli|Augusti|September|Oktober|November|December) \d{4}$/,
)
// The title holds no controls: the month/year selects live beside it.
expect(title.querySelector('select')).toBeNull()
})
it('renders the help popover button in place of the old legend', () => {
render(<Calendar />)
expect(screen.getByTestId('calendar-help-button')).toBeInTheDocument()
expect(document.querySelector('.calendar__legend')).toBeNull()
})
it('handles next/prev month navigation', () => {
@@ -405,9 +414,8 @@ describe('Calendar', () => {
it('jumps forward/backward via month and year dropdowns', () => {
render(<Calendar />)
const title = screen.getByRole('heading', { level: 2 })
const monthSelect = title.querySelector('.calendar__month-select') as HTMLSelectElement
const yearSelect = title.querySelector('.calendar__year-select') as HTMLSelectElement
const monthSelect = screen.getByLabelText('Välj månad') as HTMLSelectElement
const yearSelect = screen.getByLabelText('Välj år') as HTMLSelectElement
fireEvent.change(monthSelect, { target: { value: '5' } })
expect(monthSelect.value).toBe('5')
@@ -418,9 +426,8 @@ describe('Calendar', () => {
it('Today button jumps back to the current month/year after navigating away', () => {
render(<Calendar />)
const title = screen.getByRole('heading', { level: 2 })
const monthSelect = title.querySelector('.calendar__month-select') as HTMLSelectElement
const yearSelect = title.querySelector('.calendar__year-select') as HTMLSelectElement
const monthSelect = screen.getByLabelText('Välj månad') as HTMLSelectElement
const yearSelect = screen.getByLabelText('Välj år') as HTMLSelectElement
fireEvent.change(monthSelect, { target: { value: '5' } })
fireEvent.change(yearSelect, { target: { value: '2035' } })
@@ -438,8 +445,7 @@ describe('Calendar', () => {
makeCalendarDaysResult({ data: makeCalendarWindow({ minYear: 2026, maxYear: 2055 }) }),
)
render(<Calendar />)
const title = screen.getByRole('heading', { level: 2 })
const yearSelect = title.querySelector('.calendar__year-select') as HTMLSelectElement
const yearSelect = screen.getByLabelText('Välj år') as HTMLSelectElement
const options = Array.from(yearSelect.options).map((opt) => parseInt(opt.value))
expect(Math.min(...options)).toBe(2026)
expect(Math.max(...options)).toBe(2055)
@@ -448,8 +454,7 @@ describe('Calendar', () => {
it('always includes the current year even before the calendar window has loaded', () => {
vi.mocked(useCalendarDays).mockReturnValue(makeCalendarDaysResult({ data: null, isLoading: true }))
render(<Calendar />)
const title = screen.getByRole('heading', { level: 2 })
const yearSelect = title.querySelector('.calendar__year-select') as HTMLSelectElement
const yearSelect = screen.getByLabelText('Välj år') as HTMLSelectElement
const options = Array.from(yearSelect.options).map((opt) => parseInt(opt.value))
expect(options).toContain(new Date().getFullYear())
})
@@ -462,10 +467,8 @@ describe('Calendar', () => {
])
render(<Calendar />)
const title = screen.getByRole('heading', { level: 2 })
const yearSelect = title.querySelector('.calendar__year-select') as HTMLSelectElement
const monthSelect = title.querySelector('.calendar__month-select') as HTMLSelectElement
const yearSelect = screen.getByLabelText('Välj år') as HTMLSelectElement
const monthSelect = screen.getByLabelText('Välj månad') as HTMLSelectElement
expect(yearSelect.value).toBe('2030')
expect(monthSelect.value).toBe('6') // July is month index 6
@@ -485,9 +488,8 @@ describe('Calendar', () => {
render(<Calendar />)
const today = new Date()
const title = screen.getByRole('heading', { level: 2 })
const yearSelect = title.querySelector('.calendar__year-select') as HTMLSelectElement
const monthSelect = title.querySelector('.calendar__month-select') as HTMLSelectElement
const yearSelect = screen.getByLabelText('Välj år') as HTMLSelectElement
const monthSelect = screen.getByLabelText('Välj månad') as HTMLSelectElement
expect(yearSelect.value).toBe(String(today.getFullYear()))
expect(monthSelect.value).toBe(String(today.getMonth()))
expect(screen.queryByTestId('modal')).not.toBeInTheDocument()
@@ -499,9 +501,8 @@ describe('Calendar', () => {
render(<Calendar />)
const today = new Date()
const title = screen.getByRole('heading', { level: 2 })
const yearSelect = title.querySelector('.calendar__year-select') as HTMLSelectElement
const monthSelect = title.querySelector('.calendar__month-select') as HTMLSelectElement
const yearSelect = screen.getByLabelText('Välj år') as HTMLSelectElement
const monthSelect = screen.getByLabelText('Välj månad') as HTMLSelectElement
expect(yearSelect.value).toBe(String(today.getFullYear()))
expect(monthSelect.value).toBe(String(today.getMonth()))
})
@@ -686,9 +687,8 @@ describe('Calendar', () => {
it('a leading week\'s in-month days are draggable (regression: the week used to be gated by weekDates[0]\'s month, not the displayed month)', () => {
render(<Calendar />)
const title = screen.getByRole('heading', { level: 2 })
const yearSelect = title.querySelector('.calendar__year-select') as HTMLSelectElement
const monthSelect = title.querySelector('.calendar__month-select') as HTMLSelectElement
const yearSelect = screen.getByLabelText('Välj år') as HTMLSelectElement
const monthSelect = screen.getByLabelText('Välj månad') as HTMLSelectElement
// March 2026 starts on a Sunday, so its first calendar row is a
// leading week made up of six February days plus March 1st.
fireEvent.change(yearSelect, { target: { value: '2026' } })
@@ -1037,8 +1037,7 @@ describe('Calendar', () => {
fireEvent.click(screen.getByTestId('preset-click'))
await waitFor(() => {
const title = screen.getByRole('heading', { level: 2 })
const yearSelect = title.querySelector('.calendar__year-select') as HTMLSelectElement
const yearSelect = screen.getByLabelText('Välj år') as HTMLSelectElement
expect(yearSelect.value).toBe('2030')
})
})
@@ -1448,9 +1447,11 @@ describe('Calendar', () => {
<Calendar />
</LanguageProvider>,
)
const title = screen.getByRole('heading', { level: 2 })
const monthSelect = title.querySelector('.calendar__month-select') as HTMLSelectElement
expect(monthSelect.getAttribute('aria-label')).toBe('Select month')
expect(screen.getByLabelText('Select month')).toBeInTheDocument()
// The title is real text now, in the English month name.
expect(screen.getByRole('heading', { level: 2 }).textContent).toMatch(
/^(January|February|March|April|May|June|July|August|September|October|November|December) \d{4}$/,
)
} finally {
Object.defineProperty(window.navigator, 'language', { value: 'sv-SE', configurable: true })
vi.unstubAllGlobals()
@@ -1624,12 +1625,12 @@ describe('Calendar', () => {
render(<Calendar />)
await waitFor(() => expect(sync).toHaveBeenCalledTimes(1))
const yearBefore = (screen.getByRole('heading', { level: 2 }).querySelector('.calendar__year-select') as HTMLSelectElement).value
const yearBefore = (screen.getByLabelText('Välj år') as HTMLSelectElement).value
fireEvent.click(screen.getByTestId('preset-click'))
await waitFor(() => expect(screen.getByTestId('calendar-toast')).toHaveTextContent('sync failed'))
const yearAfter = (screen.getByRole('heading', { level: 2 }).querySelector('.calendar__year-select') as HTMLSelectElement).value
const yearAfter = (screen.getByLabelText('Välj år') as HTMLSelectElement).value
expect(yearAfter).toBe(yearBefore)
})
+79 -104
View File
@@ -30,7 +30,8 @@ import { CalendarWeek, computeWeekLaneCount, type BandClickInfo } from '@/compon
import { PlannedDayModal } from '@/components/modals/PlannedDayModal'
import { PeriodPopover, type PeriodPopoverSavePayload, type PeriodDeleteScope } from '@/components/calendar/PeriodPopover'
import { PresetRail, type SeasonPreset } from '@/components/calendar/PresetRail'
import { getColorForType, getTypeModifier } from '@/utils/leaveTypes'
import { CalendarHelpPopover } from '@/components/calendar/CalendarHelpPopover'
import { Icon } from '@/components/common/Icon'
import { resolveWishName, resolveWishNameFromData } from '@/utils/wishName'
import { datesInRange, formatDateForDisplay, formatDateLocalISO, isValidDate, parseInputDate } from '@/utils/dates'
import { renderRejection, type Translate } from '@/utils/rejectionMessages'
@@ -1076,41 +1077,58 @@ export function Calendar(): ReactNode {
? wishesHook.data.find((w) => w.id === activeBand.wishId) ?? null
: null
// The month title is real text ("September 2026"); the two selects beside
// it are the way to jump further than one month at a time.
const intlLocale = locale === 'sv' ? 'sv-SE' : 'en-US'
const monthName = (m: number): string => {
const raw = new Date(year, m, 1).toLocaleDateString(intlLocale, { month: 'long' })
return raw.charAt(0).toLocaleUpperCase(intlLocale) + raw.slice(1)
}
return (
<Layout>
<div data-testid="calendar" className="calendar">
<div className="calendar__header">
<button
type="button"
className="btn btn--icon"
onClick={handlePrevMonth}
aria-label={t('calendar.prevMonthAria')}
>
←
</button>
<h2 className="calendar__title">
<div className="calendar__nav">
<button
type="button"
className="btn btn--ghost calendar__today-button"
className="btn btn--icon"
onClick={handlePrevMonth}
aria-label={t('calendar.prevMonthAria')}
>
<Icon name="chevron-left" size={20} />
</button>
<h2 className="calendar__title">
{monthName(month)} {year}
</h2>
<button
type="button"
className="btn btn--icon"
onClick={handleNextMonth}
aria-label={t('calendar.nextMonthAria')}
>
<Icon name="chevron-right" size={20} />
</button>
<button
type="button"
className="btn btn--ghost btn--sm calendar__today-button"
onClick={() => setCurrentMonth(new Date())}
>
{t('calendar.today')}
</button>
</div>
<div className="calendar__jump">
<select
value={month}
onChange={handleMonthChange}
className="calendar__month-select"
aria-label={t('calendar.selectMonthAria')}
>
{[0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11].map((m) => {
const date = new Date(year, m, 1)
const monthName = date.toLocaleDateString(locale === 'sv' ? 'sv-SE' : 'en-US', { month: 'long' })
return (
<option key={m} value={m}>
{monthName}
</option>
)
})}
{[0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11].map((m) => (
<option key={m} value={m}>
{monthName(m)}
</option>
))}
</select>
<select
value={year}
@@ -1124,15 +1142,8 @@ export function Calendar(): ReactNode {
</option>
))}
</select>
</h2>
<button
type="button"
className="btn btn--icon"
onClick={handleNextMonth}
aria-label={t('calendar.nextMonthAria')}
>
→
</button>
<CalendarHelpPopover />
</div>
</div>
{(syncHook.isSyncing || isCreatingPeriod) && (
@@ -1147,7 +1158,7 @@ export function Calendar(): ReactNode {
{toast.undo && (
<button
type="button"
className="calendar__toast-undo"
className="btn btn--link calendar__toast-undo"
onClick={() => void handleUndoClick()}
disabled={isUndoing}
data-testid="calendar-toast-undo"
@@ -1157,92 +1168,56 @@ export function Calendar(): ReactNode {
)}
<button
type="button"
className="calendar__toast-dismiss"
className="btn btn--icon btn--sm calendar__toast-dismiss"
aria-label={t('calendar.dismissToastAria')}
onClick={() => setToast(null)}
data-testid="calendar-toast-dismiss"
>
×
<Icon name="close" size={16} />
</button>
</div>
)}
<div className="calendar__layout">
<div className="calendar__main">
<div className={`calendar__grid${showWeekNumbers ? ' calendar__grid--with-week-numbers' : ''}`}>
{showWeekNumbers && <div className="calendar__week-number-header" />}
{WEEKDAY_KEYS.map((key) => (
<div key={key} className="calendar__weekday">
{t(key)}
</div>
))}
</div>
<div className="calendar__main">
<PresetRail isBusy={isCreatingPeriod || syncHook.isSyncing} onSelectPreset={handleSelectPreset} />
{/* Each week is its own small CSS Grid (see CalendarWeek.tsx/
calendar.css) so a multi-day band can span several day columns as
one element — that only works if each week is a separate grid,
not a flat row of the header grid above. */}
<div className="calendar__weeks">
{weeks.map((week, weekIdx) => (
<CalendarWeek
key={weekIdx}
weekDates={week}
plannedDays={plannedDaysHook.data ?? []}
selectedDates={highlightedDates}
parentName={parentName ?? t('calendar.you')}
personalDataUuid={personalDataUuid ?? ''}
parentNames={parentNamesByUuid}
displayedYear={year}
displayedMonth={month}
showWeekNumbers={showWeekNumbers}
onDayClick={handleDayClick}
onPointerDown={handlePointerDown}
onPointerEnter={handlePointerEnter}
calendarDaysByDate={calendarDaysByDate}
excludedDates={excludedDates}
onBandClick={handleBandClick}
flashWishId={flashWishId}
monthMaxLaneCount={monthMaxLaneCount}
/>
))}
</div>
<div className="calendar__legend">
<h3>{t('calendar.legendTitle')}</h3>
<div className="calendar__legend-items">
<div className="calendar__legend-item">
<span className="calendar__legend-swatch" style={{ backgroundColor: getColorForType('vacation') }} />
<span>{t('calendar.legendVacation')}</span>
</div>
<div className="calendar__legend-item">
<span className="calendar__legend-swatch" style={{ backgroundColor: getColorForType('sjukpenning') }} />
<span>{t('calendar.legendIncomeBased')}</span>
</div>
<div className="calendar__legend-item">
<span className={`calendar__legend-swatch calendar__legend-swatch--${getTypeModifier('lagsta')}`} style={{ backgroundColor: getColorForType('lagsta') }} />
<span>{t('calendar.legendMinimumRate')}</span>
</div>
<div className="calendar__legend-item">
<span className="calendar__legend-swatch calendar__legend-swatch--red-day" />
<span>{t('calendar.legendRedDay')}</span>
</div>
<div className="calendar__legend-item">
<span className="calendar__legend-swatch calendar__legend-swatch--klamdag" />
<span>{t('calendar.legendKlamdag')}</span>
</div>
<div className="calendar__legend-item">
<span className="calendar__legend-swatch calendar__legend-swatch--override" />
<span>{t('calendar.legendOverride')}</span>
</div>
<div className="calendar__legend-item">
<span className="calendar__legend-swatch calendar__legend-swatch--dubbeldag">2×</span>
<span>{t('calendar.legendDoubleDay')}</span>
</div>
<div className={`calendar__grid${showWeekNumbers ? ' calendar__grid--with-week-numbers' : ''}`}>
{showWeekNumbers && <div className="calendar__week-number-header" />}
{WEEKDAY_KEYS.map((key) => (
<div key={key} className="calendar__weekday">
{t(key)}
</div>
</div>
))}
</div>
<PresetRail isBusy={isCreatingPeriod || syncHook.isSyncing} onSelectPreset={handleSelectPreset} />
{/* Each week is its own small CSS Grid (see CalendarWeek.tsx/
calendar.css) so a multi-day band can span several day columns as
one element — that only works if each week is a separate grid,
not a flat row of the header grid above. */}
<div className="calendar__weeks">
{weeks.map((week, weekIdx) => (
<CalendarWeek
key={weekIdx}
weekDates={week}
plannedDays={plannedDaysHook.data ?? []}
selectedDates={highlightedDates}
parentName={parentName ?? t('calendar.you')}
personalDataUuid={personalDataUuid ?? ''}
parentNames={parentNamesByUuid}
displayedYear={year}
displayedMonth={month}
showWeekNumbers={showWeekNumbers}
onDayClick={handleDayClick}
onPointerDown={handlePointerDown}
onPointerEnter={handlePointerEnter}
calendarDaysByDate={calendarDaysByDate}
excludedDates={excludedDates}
onBandClick={handleBandClick}
flashWishId={flashWishId}
monthMaxLaneCount={monthMaxLaneCount}
/>
))}
</div>
</div>
{firstSelectedDate && (
File diff suppressed because it is too large Load Diff
+24 -37
View File
@@ -147,6 +147,13 @@
min-width: 0;
}
/* The footer stacks bottom-up here (column-reverse above), so the
* revealed delete scopes sort FIRST to land at the bottom, directly under
* the "Ta bort…" button that opened them. */
.period-popover__delete-group {
order: -10;
}
/* Lists */
.dashboard__break-item {
flex-direction: column;
@@ -160,56 +167,36 @@
min-height: 200px;
}
/* Calendar responsiveness */
.calendar__grid {
font-size: var(--fs-sm);
/* Calendar — cells are ~48px wide at 390px, so the padding tightens and
* the numeral keeps its 24px box flush to the top-left. */
.calendar__weekday {
padding: 0 var(--sp-1) var(--sp-2);
}
.calendar__weekday,
.calendar-day {
padding: var(--sp-2);
min-height: 64px;
padding: var(--sp-1) var(--sp-1) calc(var(--sp-1) + var(--band-reserved-height, 0px));
}
.calendar-day__number {
font-size: var(--fs-sm);
.calendar__week-number {
padding-top: var(--sp-1);
}
/* At 390px the month/year selects plus the two prev/next icon buttons are
* tight against the header's title size; one step down keeps the whole
* row inside the viewport. */
.calendar__month-select,
.calendar__year-select {
font-size: var(--fs-lg);
.calendar-band__label {
padding: 0 var(--sp-1);
}
/* The fixed-width preset rail sits beside the month grid on desktop; on
* mobile the layout stacks and the rail goes full-width below the grid.
* `align-items: stretch` overrides the base rule's `flex-start` — without
* it a column-direction flex item is sized by its own shrink-to-fit
* content width, which is what once let `.calendar__main` grow past the
* viewport. */
.calendar__layout {
flex-direction: column;
align-items: stretch;
}
.preset-rail {
width: 100%;
}
/* A horizontally-scrollable row of presets, not a stack — keeps the rail
* from pushing the month grid far down the page on a short viewport. */
.preset-rail__buttons {
flex-direction: row;
/* The preset chips scroll sideways in one row instead of wrapping, so the
* grid is never pushed down the page. */
.preset-chips__row {
flex-wrap: nowrap;
overflow-x: auto;
scroll-snap-type: x mandatory;
scrollbar-width: none;
padding-bottom: var(--sp-1);
}
.preset-rail__button {
flex: 0 0 auto;
min-width: 65vw;
scroll-snap-align: start;
.preset-chips__row::-webkit-scrollbar {
display: none;
}
/* Footer — same side gutter as .layout__main at this width. */
+13 -1
View File
@@ -1,5 +1,5 @@
import { describe, it, expect } from 'vitest'
import { getColorForType, getInkForType, getLabelForType, getTypeModifier } from './leaveTypes'
import { getColorForType, getInkForType, getLabelForType, getTypeKey, getTypeModifier } from './leaveTypes'
import { translate } from '@/i18n'
describe('getColorForType', () => {
@@ -32,6 +32,18 @@ describe('getInkForType', () => {
})
})
describe('getTypeKey', () => {
it('returns the bare type word for each known type', () => {
expect(getTypeKey('vacation')).toBe('vacation')
expect(getTypeKey('sjukpenning')).toBe('sjukpenning')
expect(getTypeKey('lagsta')).toBe('lagsta')
})
it('returns unknown for an unknown type', () => {
expect(getTypeKey('something-else')).toBe('unknown')
})
})
describe('getTypeModifier', () => {
it('returns a type-* modifier suffix for each known type', () => {
expect(getTypeModifier('vacation')).toBe('type-vacation')
+15 -4
View File
@@ -25,13 +25,24 @@ export function getInkForType(type: string): string {
return isKnownType(type) ? `var(--c-leave-${type}-ink)` : 'var(--c-surface)'
}
export type LeaveTypeKey = KnownType | 'unknown'
/**
* BEM modifier suffix for a leave type, for callers that need a class hook
* (lägsta's outline/hatch can't be expressed as a single inline colour):
* `` `calendar-band--${getTypeModifier(type)}` ``.
* The leave type as a bare BEM modifier word (`vacation` / `sjukpenning` /
* `lagsta`, or `unknown`) — the calendar's bands and ticks use it directly:
* `` `calendar-band--${getTypeKey(type)}` ``.
*/
export function getTypeKey(type: string): LeaveTypeKey {
return isKnownType(type) ? type : 'unknown'
}
/**
* The older `type-*` prefixed modifier, still used by the dashboard's year
* timeline (`year-timeline__band--type-lagsta`); the calendar has moved to
* `getTypeKey`.
*/
export function getTypeModifier(type: string): string {
return isKnownType(type) ? `type-${type}` : 'type-unknown'
return `type-${getTypeKey(type)}`
}
/**