diff --git a/frontend/src/components/calendar/RedDaysList.test.tsx b/frontend/src/components/calendar/RedDaysList.test.tsx
index e59a73f..0d65866 100644
--- a/frontend/src/components/calendar/RedDaysList.test.tsx
+++ b/frontend/src/components/calendar/RedDaysList.test.tsx
@@ -1,7 +1,7 @@
import { describe, it, expect, vi } from 'vitest'
-import { render, screen } from '@testing-library/react'
+import { render, screen, waitFor, fireEvent } from '@testing-library/react'
import { RedDaysList } from './RedDaysList'
-import type { CalendarDayEntry } from '@/types'
+import type { CalendarDayEntry, DayOverride } from '@/types'
const baseDays: CalendarDayEntry[] = [
{
@@ -46,6 +46,18 @@ const baseDays: CalendarDayEntry[] = [
},
]
+const baseOverrides: DayOverride[] = [
+ {
+ id: 'do-2026-12-24',
+ workspaceId: 'ws-1',
+ date: '2026-12-24',
+ isWorkingDay: false,
+ note: null,
+ createdAt: '2026-01-01T00:00:00',
+ updatedAt: '2026-01-01T00:00:00',
+ },
+]
+
describe('RedDaysList', () => {
const defaultProps = {
days: baseDays,
@@ -55,6 +67,10 @@ describe('RedDaysList', () => {
maxYear: 2035,
onPrevWindow: vi.fn(),
onNextWindow: vi.fn(),
+ overrides: baseOverrides,
+ workspaceId: 'ws-1',
+ onUpsertOverride: vi.fn().mockResolvedValue(undefined),
+ onRemoveOverride: vi.fn().mockResolvedValue(undefined),
}
it('renders one year group per year in the window, including years with no matches', () => {
@@ -199,4 +215,114 @@ describe('RedDaysList', () => {
expect(screen.getByRole('alert')).toHaveTextContent('Something broke')
expect(screen.queryByTestId('red-days-year-2026')).not.toBeInTheDocument()
})
+
+ describe('toggling a day (§Task8)', () => {
+ it('clicking a statutory red day upserts an override making that exact date a working day', async () => {
+ const onUpsertOverride = vi.fn().mockResolvedValue(undefined)
+ render()
+
+ fireEvent.click(screen.getByTestId('red-days-toggle-2026-01-01'))
+
+ await waitFor(() => {
+ expect(onUpsertOverride).toHaveBeenCalledWith({
+ workspaceId: 'ws-1',
+ date: '2026-01-01',
+ isWorkingDay: true,
+ note: null,
+ })
+ })
+ })
+
+ it('clicking an overridden day back to its statutory value removes the override by id', async () => {
+ const onRemoveOverride = vi.fn().mockResolvedValue(undefined)
+ render()
+
+ // 2026-12-24 carries the override from `baseOverrides` (id: do-2026-12-24).
+ fireEvent.click(screen.getByTestId('red-days-toggle-2026-12-24'))
+
+ await waitFor(() => {
+ expect(onRemoveOverride).toHaveBeenCalledWith('do-2026-12-24')
+ })
+ })
+
+ it('only touches the exact date clicked, not the same month/day in another year', async () => {
+ const sameMonthDayAcrossYears: CalendarDayEntry[] = [
+ {
+ date: '2026-01-01',
+ name: 'Nyårsdagen',
+ dayType: 'red_day',
+ isOverride: false,
+ overrideIsWorkingDay: null,
+ isFree: true,
+ },
+ {
+ date: '2027-01-01',
+ name: 'Nyårsdagen',
+ dayType: 'red_day',
+ isOverride: false,
+ overrideIsWorkingDay: null,
+ isFree: true,
+ },
+ ]
+ const onUpsertOverride = vi.fn().mockResolvedValue(undefined)
+ render(
+ ,
+ )
+
+ fireEvent.click(screen.getByTestId('red-days-toggle-2026-01-01'))
+
+ await waitFor(() => {
+ expect(onUpsertOverride).toHaveBeenCalledTimes(1)
+ })
+ expect(onUpsertOverride).toHaveBeenCalledWith({
+ workspaceId: 'ws-1',
+ date: '2026-01-01',
+ isWorkingDay: true,
+ note: null,
+ })
+ // The 2027 New Year's Day button was never touched by the 2026 click.
+ expect(screen.getByTestId('red-days-toggle-2027-01-01')).toHaveTextContent('Ledig')
+ })
+
+ it('disables the clicked button while the request is in flight', async () => {
+ let resolveUpsert: () => void = () => {}
+ const onUpsertOverride = vi.fn(
+ () =>
+ new Promise((resolve) => {
+ resolveUpsert = resolve
+ }),
+ )
+ render()
+
+ const button = screen.getByTestId('red-days-toggle-2026-01-01')
+ fireEvent.click(button)
+ expect(button).toBeDisabled()
+
+ resolveUpsert()
+ await waitFor(() => expect(button).not.toBeDisabled())
+ })
+
+ it('surfaces a failed toggle instead of swallowing it', async () => {
+ const onUpsertOverride = vi.fn().mockRejectedValue(new Error('network blew up'))
+ render()
+
+ fireEvent.click(screen.getByTestId('red-days-toggle-2026-01-01'))
+
+ await waitFor(() => {
+ expect(screen.getByTestId('red-days-toggle-error')).toHaveTextContent('network blew up')
+ })
+ })
+
+ it('is keyboard accessible with an aria-label naming the date and action', () => {
+ render()
+ const button = screen.getByTestId('red-days-toggle-2026-01-01')
+ expect(button.tagName).toBe('BUTTON')
+ expect(button).toHaveAccessibleName(/2026|januari/i)
+ })
+ })
})
diff --git a/frontend/src/components/calendar/RedDaysList.tsx b/frontend/src/components/calendar/RedDaysList.tsx
index 4f9ee3e..cbb8441 100644
--- a/frontend/src/components/calendar/RedDaysList.tsx
+++ b/frontend/src/components/calendar/RedDaysList.tsx
@@ -7,8 +7,8 @@
* day overrides (worked/off) — not just the statutory calendar — so the
* list matches what the app actually treats as free.
*/
-import { ReactNode, useMemo } from 'react'
-import type { CalendarDayEntry } from '@/types'
+import { ReactNode, useMemo, useState } from 'react'
+import type { CalendarDayEntry, DayOverride, DayOverrideCreateRequest } from '@/types'
import { formatDateForDisplay, formatHolidayName } from '@/utils/dates'
import { useTranslation } from '@/i18n'
@@ -34,6 +34,13 @@ export interface RedDaysListProps {
error?: string | null
onPrevWindow: () => void
onNextWindow: () => void
+ /** The workspace's own day overrides — used to find an existing override's id so a click back to the statutory value can `remove` it rather than leave a redundant row. Not scoped to the visible window: dates are absolute, so this can be the full workspace list. */
+ overrides: DayOverride[]
+ workspaceId: string
+ /** Creates/replaces the override for one absolute date — never affects the same month/day in another year. */
+ onUpsertOverride: (req: DayOverrideCreateRequest) => Promise
+ /** Deletes an existing override by id, reverting that one date to its statutory value. */
+ onRemoveOverride: (id: string) => Promise
}
export function RedDaysList({
@@ -47,9 +54,49 @@ export function RedDaysList({
error = null,
onPrevWindow,
onNextWindow,
+ overrides,
+ workspaceId,
+ onUpsertOverride,
+ onRemoveOverride,
}: RedDaysListProps): ReactNode {
const { t } = useTranslation()
const endYear = endYearProp ?? startYear + years - 1
+ const [pendingDate, setPendingDate] = useState(null)
+ const [toggleError, setToggleError] = useState(null)
+
+ const overrideIdByDate = useMemo(() => {
+ const map = new Map()
+ for (const override of overrides) {
+ map.set(override.date, override.id)
+ }
+ return map
+ }, [overrides])
+
+ const handleToggle = async (day: CalendarDayEntry) => {
+ setToggleError(null)
+ setPendingDate(day.date)
+ try {
+ if (day.isOverride) {
+ // Any existing override, in either direction, reverts to the
+ // statutory value on click — there is no third state.
+ const overrideId = overrideIdByDate.get(day.date)
+ if (!overrideId) {
+ setToggleError(t('redDaysList.toggleError'))
+ return
+ }
+ await onRemoveOverride(overrideId)
+ } else {
+ // Only statutory red days (always effectively free) reach this
+ // branch — see the year-group filter below — so the flip is always
+ // "make this one date a working day".
+ await onUpsertOverride({ workspaceId, date: day.date, isWorkingDay: true, note: null })
+ }
+ } catch (err) {
+ setToggleError(err instanceof Error ? err.message : t('redDaysList.toggleError'))
+ } finally {
+ setPendingDate(null)
+ }
+ }
const byYear = useMemo(() => {
const map = new Map()
@@ -103,6 +150,11 @@ export function RedDaysList({
{error}
)}
+ {toggleError && (
+
+ {toggleError}
+
+ )}
{!isLoading &&
!error &&
@@ -127,11 +179,20 @@ export function RedDaysList({
{day.overrideIsWorkingDay ? t('redDaysList.overrideWorking') : t('redDaysList.overrideFree')}
)}
- handleToggle(day)}
+ disabled={pendingDate === day.date}
+ aria-label={
+ day.isFree
+ ? t('redDaysList.toggleToWorkingAria', { date: formatDateForDisplay(day.date) })
+ : t('redDaysList.toggleToFreeAria', { date: formatDateForDisplay(day.date) })
+ }
+ data-testid={`red-days-toggle-${day.date}`}
>
{day.isFree ? t('redDaysList.effectiveFree') : t('redDaysList.effectiveWorking')}
-
+
))}
diff --git a/frontend/src/i18n/en/calendar.ts b/frontend/src/i18n/en/calendar.ts
index 44b4315..0227aed 100644
--- a/frontend/src/i18n/en/calendar.ts
+++ b/frontend/src/i18n/en/calendar.ts
@@ -67,6 +67,9 @@ export const calendar = {
'redDaysList.overrideFree': 'Exception: day off',
'redDaysList.overrideWorking': 'Exception: working day',
'redDaysList.prevWindow': 'Previous 4 years',
+ 'redDaysList.toggleError': 'Failed to update this day. Try again.',
+ 'redDaysList.toggleToFreeAria': 'Mark {date} as a day off',
+ 'redDaysList.toggleToWorkingAria': 'Mark {date} as a working day',
// §Task7 — the calendar becomes the plan: drag-select, period popover,
// preset rail, and the sync toast/undo.
diff --git a/frontend/src/i18n/sv/calendar.ts b/frontend/src/i18n/sv/calendar.ts
index 65fdd6f..111b648 100644
--- a/frontend/src/i18n/sv/calendar.ts
+++ b/frontend/src/i18n/sv/calendar.ts
@@ -67,6 +67,9 @@ export const calendar = {
'redDaysList.overrideFree': 'Undantag: ledig dag',
'redDaysList.overrideWorking': 'Undantag: arbetsdag',
'redDaysList.prevWindow': 'Föregående 4 år',
+ 'redDaysList.toggleError': 'Det gick inte att uppdatera dagen. Försök igen.',
+ 'redDaysList.toggleToFreeAria': 'Markera {date} som ledig dag',
+ 'redDaysList.toggleToWorkingAria': 'Markera {date} som arbetsdag',
// §Task7 — the calendar becomes the plan: drag-select, period popover,
// preset rail, and the sync toast/undo.
diff --git a/frontend/src/pages/Settings.test.tsx b/frontend/src/pages/Settings.test.tsx
index 8b800ff..6d17718 100644
--- a/frontend/src/pages/Settings.test.tsx
+++ b/frontend/src/pages/Settings.test.tsx
@@ -839,5 +839,38 @@ describe('Settings', () => {
expect(hooks.useCalendarDays).toHaveBeenLastCalledWith('ws-1', CURRENT_YEAR - 4, 4)
})
+
+ it('wires the toggle button through to the shared day-overrides hook', async () => {
+ vi.mocked(hooks.useCalendarDays).mockReturnValue(
+ makeCalendarDaysResult({
+ data: {
+ ...mockCalendarWindow,
+ days: [
+ {
+ date: `${CURRENT_YEAR}-01-01`,
+ name: 'Nyårsdagen',
+ dayType: 'red_day',
+ isOverride: false,
+ overrideIsWorkingDay: null,
+ isFree: true,
+ },
+ ],
+ },
+ })
+ )
+
+ renderSettings()
+
+ fireEvent.click(screen.getByTestId(`red-days-toggle-${CURRENT_YEAR}-01-01`))
+
+ await waitFor(() => {
+ expect(mockUpsertDayOverride).toHaveBeenCalledWith({
+ workspaceId: 'ws-1',
+ date: `${CURRENT_YEAR}-01-01`,
+ isWorkingDay: true,
+ note: null,
+ })
+ })
+ })
})
})
diff --git a/frontend/src/pages/Settings.tsx b/frontend/src/pages/Settings.tsx
index 38a480f..9e73d79 100644
--- a/frontend/src/pages/Settings.tsx
+++ b/frontend/src/pages/Settings.tsx
@@ -692,6 +692,10 @@ export function Settings(): ReactNode {
error={calendarError}
onPrevWindow={handleRedDaysPrevWindow}
onNextWindow={handleRedDaysNextWindow}
+ overrides={dayOverrides}
+ workspaceId={workspace?.id ?? ''}
+ onUpsertOverride={upsertDayOverride}
+ onRemoveOverride={removeDayOverride}
/>
diff --git a/frontend/src/styles/components.css b/frontend/src/styles/components.css
index 2d44cb5..ddc6f1d 100644
--- a/frontend/src/styles/components.css
+++ b/frontend/src/styles/components.css
@@ -818,18 +818,58 @@ legend {
color: var(--c-text);
}
+/* `.red-days-list__effective` is a real `