Draw a leave period as one band, and make red days look red

A six-week summer used to render as a row of disconnected bars, because bars
were derived per week row and grouped by parent and day type. They carried no
identity and ignored pointer events, so there was nothing to click. Bands now
group by the wish that produced them, bridge the weekends and red days inside
a period, and round only the ends that are genuinely the first and last day.
Each day still shows what pays for it, and a hand-edited day is marked as one
the planner will leave alone.

Sundays and public holidays now render red and Saturdays a neutral grey,
following the printed almanacka people compare against, and a holiday's name
is visible in the cell rather than hidden in a tooltip. This also explains the
report that months looked unshaded: weekends had no styling at all, since the
day component handled only red days and klämdagar.

The year selector follows the range the calendar API actually serves instead
of a hardcoded span.
This commit is contained in:
2026-09-17 18:28:16 +02:00
parent d06c8c8449
commit 6a7c0ef255
12 changed files with 826 additions and 744 deletions
+39 -2
View File
@@ -264,9 +264,14 @@ describe('Calendar', () => {
expect(yearSelect.value).toBe('2026')
})
it('year dropdown includes all supported years from 2026 to 2055', () => {
it('year dropdown is derived from the calendar window\'s minYear/maxYear, not a hardcoded literal', () => {
vi.mocked(useWorkspace).mockReturnValue(mockWorkspace)
vi.mocked(usePlannedDays).mockReturnValue(mockPlannedDays)
vi.mocked(useCalendarDays).mockReturnValue(
makeCalendarDaysResult({
data: makeCalendarWindow({ minYear: 2026, maxYear: 2055 }),
}),
)
render(<Calendar />)
@@ -275,12 +280,44 @@ describe('Calendar', () => {
const options = Array.from(yearSelect.options).map((opt) => parseInt(opt.value))
// Verify the range is correct
// Verify the range comes from the API's reported bounds
expect(Math.min(...options)).toBe(2026)
expect(Math.max(...options)).toBe(2055)
expect(options).toHaveLength(30) // 2026 to 2055 inclusive is 30 years
})
it('year dropdown reflects a narrower calendar window when the API reports one', () => {
vi.mocked(useWorkspace).mockReturnValue(mockWorkspace)
vi.mocked(usePlannedDays).mockReturnValue(mockPlannedDays)
// Default fixture window: minYear 2026, maxYear 2035 (see makeCalendarWindow).
render(<Calendar />)
const title = screen.getByRole('heading', { level: 2 })
const yearSelect = title.querySelector('.calendar__year-select') as HTMLSelectElement
const options = Array.from(yearSelect.options).map((opt) => parseInt(opt.value))
expect(Math.min(...options)).toBe(2026)
expect(Math.max(...options)).toBe(2035)
expect(options).toHaveLength(10)
})
it('always includes the current year in the dropdown even before the calendar window has loaded', () => {
vi.mocked(useWorkspace).mockReturnValue(mockWorkspace)
vi.mocked(usePlannedDays).mockReturnValue(mockPlannedDays)
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 options = Array.from(yearSelect.options).map((opt) => parseInt(opt.value))
expect(options).toContain(new Date().getFullYear())
})
it('month and year selects have proper accessibility labels', () => {
vi.mocked(useWorkspace).mockReturnValue(mockWorkspace)
vi.mocked(usePlannedDays).mockReturnValue(mockPlannedDays)
+27 -2
View File
@@ -11,7 +11,7 @@ import {
import type { PlannedLeaveDay, CalendarDayEntry } from '@/types'
import { Layout } from '@/components/layout/Layout'
import { LoadingSpinner } from '@/components/common/LoadingSpinner'
import { CalendarWeek } from '@/components/common/CalendarWeek'
import { CalendarWeek, type BandClickInfo } from '@/components/common/CalendarWeek'
import { PlannedDayModal } from '@/components/modals/PlannedDayModal'
import { getColorForType } from '@/utils/leaveTypes'
import { datesInRange } from '@/utils/dates'
@@ -102,6 +102,17 @@ export function Calendar(): ReactNode {
const year = currentMonth.getFullYear()
const month = currentMonth.getMonth()
// The year <select> must cover the API's real supported range (from
// `CalendarWindow.minYear`/`maxYear`, not a hardcoded literal), while
// always including the currently-selected year even before the window has
// loaded (or if it somehow falls outside the reported bounds).
const yearRangeMin = Math.min(calendarWindow?.minYear ?? year, year)
const yearRangeMax = Math.max(calendarWindow?.maxYear ?? year, year)
const yearOptions = Array.from(
{ length: yearRangeMax - yearRangeMin + 1 },
(_, i) => yearRangeMin + i,
)
const firstDay = new Date(year, month, 1)
const lastDay = new Date(year, month + 1, 0)
const daysInMonth = lastDay.getDate()
@@ -283,6 +294,13 @@ export function Calendar(): ReactNode {
}
}
// Exposed cleanly for a later task: clicking a band (a planned period,
// possibly spanning several weeks) will open a period editor. That editor
// doesn't exist yet, so this is intentionally a no-op for now.
const handleBandClick = (_info: BandClickInfo): void => {
// TODO: open a period editor for this band once it exists.
}
const defaultLeaveType = getLastLeaveType() ?? 'vacation'
const firstSelectedDate = Array.from(selectedDates).sort()[0]
@@ -323,7 +341,7 @@ export function Calendar(): ReactNode {
className="calendar__year-select"
aria-label={t('calendar.selectYearAria')}
>
{Array.from({ length: 2055 - 2026 + 1 }, (_, i) => 2026 + i).map((y) => (
{yearOptions.map((y) => (
<option key={y} value={y}>
{y}
</option>
@@ -364,7 +382,13 @@ export function Calendar(): ReactNode {
{t(key)}
</div>
))}
</div>
{/* 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) => {
const firstDate = week[0]
const weekMonth = firstDate ? new Date(firstDate + 'T00:00:00Z').getMonth() : month
@@ -382,6 +406,7 @@ export function Calendar(): ReactNode {
onPointerDown={handlePointerDown}
onPointerEnter={handlePointerEnter}
calendarDaysByDate={calendarDaysByDate}
onBandClick={handleBandClick}
/>
)
})}
+120 -18
View File
@@ -81,6 +81,19 @@
display: grid;
grid-template-columns: repeat(7, 1fr);
gap: 8px;
margin-bottom: 8px;
}
/* The weeks themselves now live in a separate flex column below the header
grid (see .calendar-week below) so each week can be its own small CSS
Grid — that lets a multi-day band render as ONE element spanning several
day columns within a week row, with rounded caps only at its true start/
end, instead of a duplicated chip re-rendered inside every day cell it
passes through. */
.calendar__weeks {
display: flex;
flex-direction: column;
gap: 8px;
margin-bottom: 24px;
}
@@ -190,6 +203,28 @@
background-color: #fcf0c8;
}
/* Plain Saturday — neutral grey, deliberately NOT red. Matches the Swedish
almanacka: only Sunday and public/de-facto holidays are red; Saturday is
an ordinary rest day. See CalendarDay.tsx for the weekday-based split
(the backend collapses both into a single "weekend" day_type). */
.calendar-day--weekend {
background-color: #f0f0f0;
border-color: #cfcfcf;
}
.calendar-day--weekend:hover {
background-color: #e8e8e8;
}
.calendar-day__holiday-name {
font-size: 10px;
color: #a8453d;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
margin-top: 2px;
}
/* §3.6 — a workspace day override must look distinct from a plain statutory
day (and from a statutory red day/klämdag it overrides), regardless of
color, hence the dashed border and the separate badge below. */
@@ -233,38 +268,105 @@
flex-shrink: 0;
}
/* Each week is its own small CSS Grid (not a `display: contents` pass-
through) so a band segment can be placed with explicit grid-column/
grid-row spanning several day columns as ONE element. Row 1 holds the 7
(or 8, with week numbers) day cells; row 2+ are "lanes" for band
segments, sized via the `gridTemplateRows` computed in CalendarWeek.tsx
from the max number of concurrent lanes that week. */
.calendar-week {
position: relative;
display: grid;
grid-template-columns: repeat(7, 1fr);
column-gap: 8px;
row-gap: 3px;
}
.calendar-week--with-week-numbers {
grid-template-columns: 50px repeat(7, 1fr);
}
.calendar-week__day-cell {
display: flex;
flex-direction: column;
position: relative;
}
.calendar-week__days-grid {
display: contents;
}
.calendar-week__day-bars {
/* One continuous band segment for this week row. Square by default —
.calendar-band--start/--end round only the true first/last row of the
whole (possibly multi-week) band, so a period crossing a week boundary
reads as one connected object, not a chain of separate chips. */
.calendar-band {
position: relative;
display: flex;
flex-direction: column;
gap: 2px;
padding: 0 2px;
justify-content: center;
overflow: hidden;
cursor: pointer;
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.1);
min-width: 0;
}
.calendar-bar {
.calendar-band--start {
border-top-left-radius: 4px;
border-bottom-left-radius: 4px;
}
.calendar-band--end {
border-top-right-radius: 4px;
border-bottom-right-radius: 4px;
}
.calendar-band:focus-visible {
outline: 2px solid #0066cc;
outline-offset: 1px;
}
.calendar-band__label {
position: absolute;
inset: 0;
display: flex;
align-items: center;
padding: 4px 8px;
border-radius: 3px;
font-size: 12px;
padding: 0 6px;
font-size: 11px;
color: #fff;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.1);
text-shadow: 0 1px 1px rgba(0, 0, 0, 0.35);
pointer-events: none;
z-index: 1;
}
/* The per-day funding-type strip: a period is ONE band, but each day still
shows what pays for it (vacation/sjukpenning/lägsta) via a colored tick,
so the mix inside a period stays visible even though it renders as one
object. */
.calendar-band__days {
display: flex;
height: 100%;
width: 100%;
}
.calendar-band__day {
flex: 1 1 0;
min-width: 0;
height: 100%;
}
/* A bridged gap day (weekend/red day the band passes through without a
funded planned_leave_day row) — no funding color, just a light neutral
fill so the band still reads as continuous. */
.calendar-band__day--gap {
background-color: rgba(255, 255, 255, 0.35);
}
/* §5/§6 — hand-edited ("pinned") day: the planner must not retype it. */
.calendar-band__day--pinned {
position: relative;
}
.calendar-band__day--pinned::after {
content: '📌';
position: absolute;
bottom: 1px;
right: 1px;
font-size: 8px;
line-height: 1;
}
.calendar-event {