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 `