)
diff --git a/frontend/src/i18n/en/calendar.ts b/frontend/src/i18n/en/calendar.ts
index 569453d..434c084 100644
--- a/frontend/src/i18n/en/calendar.ts
+++ b/frontend/src/i18n/en/calendar.ts
@@ -33,6 +33,8 @@ export const calendar = {
'calendarDay.klamdagAria': 'bridge day',
'calendarDay.overrideFreeAria': 'marked as a day off (exception)',
'calendarDay.overrideWorkingAria': 'marked as a working day (exception)',
+ 'calendarDay.pinnedAria': 'pinned by you — the planner will not retype this day',
+ 'calendarDay.sundayAria': 'Sunday',
'calendarEvent.dismissAria': 'Dismiss {leaveType}',
'plannedDayModal.cancelButton': 'Cancel',
'plannedDayModal.closeAria': 'Close',
diff --git a/frontend/src/i18n/sv/calendar.ts b/frontend/src/i18n/sv/calendar.ts
index 42786ae..d92331a 100644
--- a/frontend/src/i18n/sv/calendar.ts
+++ b/frontend/src/i18n/sv/calendar.ts
@@ -33,6 +33,8 @@ export const calendar = {
'calendarDay.klamdagAria': 'klämdag',
'calendarDay.overrideFreeAria': 'markerad som ledig dag (undantag)',
'calendarDay.overrideWorkingAria': 'markerad som arbetsdag (undantag)',
+ 'calendarDay.pinnedAria': 'låst av dig – planeraren ändrar inte denna dag',
+ 'calendarDay.sundayAria': 'söndag',
'calendarEvent.dismissAria': 'Avfärda {leaveType}',
'plannedDayModal.cancelButton': 'Avbryt',
'plannedDayModal.closeAria': 'Stäng',
diff --git a/frontend/src/pages/Calendar.test.tsx b/frontend/src/pages/Calendar.test.tsx
index e4de4d4..4681cac 100644
--- a/frontend/src/pages/Calendar.test.tsx
+++ b/frontend/src/pages/Calendar.test.tsx
@@ -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()
@@ -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()
+
+ 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()
+
+ 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)
diff --git a/frontend/src/pages/Calendar.tsx b/frontend/src/pages/Calendar.tsx
index 235dc60..72e6cd6 100644
--- a/frontend/src/pages/Calendar.tsx
+++ b/frontend/src/pages/Calendar.tsx
@@ -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
))}
+
+ {/* 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. */}
+
{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}
/>
)
})}
diff --git a/frontend/src/pages/calendar.css b/frontend/src/pages/calendar.css
index e08f5bc..e4cd33c 100644
--- a/frontend/src/pages/calendar.css
+++ b/frontend/src/pages/calendar.css
@@ -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 {
diff --git a/frontend/src/services/api.ts b/frontend/src/services/api.ts
index af80f04..e6dbd54 100644
--- a/frontend/src/services/api.ts
+++ b/frontend/src/services/api.ts
@@ -183,6 +183,8 @@ interface RawPlannedLeaveDay {
date: string
day_type: 'sjukpenning' | 'lagsta' | 'vacation'
is_dubbeldag: boolean
+ wish_id: string | null
+ is_pinned: boolean
created_at: string
updated_at: string
}
@@ -196,6 +198,8 @@ function toPlannedLeaveDay(raw: RawPlannedLeaveDay): PlannedLeaveDay {
date: raw.date,
dayType: raw.day_type,
isDubbeldag: raw.is_dubbeldag,
+ wishId: raw.wish_id,
+ isPinned: raw.is_pinned,
createdAt: raw.created_at,
updatedAt: raw.updated_at,
}
diff --git a/frontend/src/types/entities.ts b/frontend/src/types/entities.ts
index 6cac6f0..07a66ee 100644
--- a/frontend/src/types/entities.ts
+++ b/frontend/src/types/entities.ts
@@ -104,6 +104,14 @@ export interface PlannedLeaveDay {
date: string // ISO-8601 date
dayType: 'sjukpenning' | 'lagsta' | 'vacation'
isDubbeldag: boolean
+ // Which `LeaveWish` produced this day; null/omitted means ad-hoc (not tied to a wish).
+ // Optional (rather than required) so existing object literals elsewhere in the
+ // codebase that predate this field keep type-checking; `toPlannedLeaveDay` in
+ // services/api.ts always populates it from the wire response.
+ wishId?: string | null
+ // True when the user hand-edited this day's type — the planner must not retype it.
+ // Optional for the same reason as `wishId` above.
+ isPinned?: boolean
createdAt: string
updatedAt: string
}
diff --git a/frontend/tests/fixtures/mockData.ts b/frontend/tests/fixtures/mockData.ts
index e58883b..f00c2f3 100644
--- a/frontend/tests/fixtures/mockData.ts
+++ b/frontend/tests/fixtures/mockData.ts
@@ -166,6 +166,8 @@ export function makePlannedLeaveDay(overrides: Partial = {}): P
date: '2026-06-15',
dayType: 'vacation',
isDubbeldag: false,
+ wishId: null,
+ isPinned: false,
createdAt: '2026-01-01T00:00:00',
updatedAt: '2026-01-01T00:00:00',
...overrides,