Flip a single holiday to a working day by clicking it

The status on the right of each public holiday is now a button. Clicking
it writes an override for that one date, and clicking it back removes the
override rather than storing a row that agrees with the almanac. Because
an override is keyed on an absolute date, the same holiday in other years
is left alone — which is what was asked for and needed no schema change.

The separate badge saying an override exists stays, so the fact that a
day has been changed is not carried by the button alone.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ei7gCZzdd751uZtPzb1Pep
This commit is contained in:
2026-09-19 14:38:45 +02:00
parent b87ad0e7ad
commit 663d5fcd92
7 changed files with 277 additions and 7 deletions
@@ -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(<RedDaysList {...defaultProps} onUpsertOverride={onUpsertOverride} />)
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(<RedDaysList {...defaultProps} onRemoveOverride={onRemoveOverride} />)
// 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(
<RedDaysList
{...defaultProps}
days={sameMonthDayAcrossYears}
overrides={[]}
onUpsertOverride={onUpsertOverride}
/>,
)
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<void>((resolve) => {
resolveUpsert = resolve
}),
)
render(<RedDaysList {...defaultProps} onUpsertOverride={onUpsertOverride} />)
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(<RedDaysList {...defaultProps} onUpsertOverride={onUpsertOverride} />)
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(<RedDaysList {...defaultProps} />)
const button = screen.getByTestId('red-days-toggle-2026-01-01')
expect(button.tagName).toBe('BUTTON')
expect(button).toHaveAccessibleName(/2026|januari/i)
})
})
})
@@ -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<void>
/** Deletes an existing override by id, reverting that one date to its statutory value. */
onRemoveOverride: (id: string) => Promise<void>
}
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<string | null>(null)
const [toggleError, setToggleError] = useState<string | null>(null)
const overrideIdByDate = useMemo(() => {
const map = new Map<string, string>()
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<number, CalendarDayEntry[]>()
@@ -103,6 +150,11 @@ export function RedDaysList({
{error}
</p>
)}
{toggleError && (
<p className="red-days-list__toggle-error" role="alert" data-testid="red-days-toggle-error">
{toggleError}
</p>
)}
{!isLoading &&
!error &&
@@ -127,11 +179,20 @@ export function RedDaysList({
{day.overrideIsWorkingDay ? t('redDaysList.overrideWorking') : t('redDaysList.overrideFree')}
</span>
)}
<span
<button
type="button"
className={`red-days-list__effective red-days-list__effective--${day.isFree ? 'free' : 'working'}`}
onClick={() => 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')}
</span>
</button>
</li>
))}
</ul>
+3
View File
@@ -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.
+3
View File
@@ -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.
+33
View File
@@ -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,
})
})
})
})
})
+4
View File
@@ -692,6 +692,10 @@ export function Settings(): ReactNode {
error={calendarError}
onPrevWindow={handleRedDaysPrevWindow}
onNextWindow={handleRedDaysNextWindow}
overrides={dayOverrides}
workspaceId={workspace?.id ?? ''}
onUpsertOverride={upsertDayOverride}
onRemoveOverride={removeDayOverride}
/>
</section>
+41 -1
View File
@@ -818,18 +818,58 @@ legend {
color: var(--c-text);
}
/* `.red-days-list__effective` is a real `<button>` — clicking it toggles
* that one absolute date between its statutory value and the opposite
* override (§Task8). Sized by its own (bilingual) content, not a fixed
* width, since the Swedish labels run longer than the English ones. */
.red-days-list__effective {
margin-left: auto;
font-size: var(--fs-sm);
font-weight: 500;
color: var(--c-text-muted);
background-color: var(--c-bg);
border: 1px solid var(--c-border);
border-radius: var(--radius-full);
padding: 2px var(--sp-3);
cursor: pointer;
white-space: nowrap;
transition: background-color 0.2s, border-color 0.2s, color 0.2s;
}
.red-days-list__effective:hover:not(:disabled) {
background-color: var(--c-accent-weak);
border-color: var(--c-accent);
color: var(--c-text);
}
.red-days-list__effective:focus-visible {
outline: 2px solid var(--c-accent);
outline-offset: 2px;
}
.red-days-list__effective:disabled {
opacity: 0.55;
cursor: not-allowed;
}
.red-days-list__effective--working {
background-color: var(--c-danger-weak);
color: var(--c-danger);
border-color: var(--c-danger-weak);
}
.red-days-list__loading,
.red-days-list__error {
.red-days-list__error,
.red-days-list__toggle-error {
color: var(--c-text-muted);
font-size: var(--fs-sm);
}
.red-days-list__toggle-error {
color: var(--c-danger);
margin: var(--sp-2) 0 0;
}
/* Tip Card (Optimization Tips, Översikt) — previously had no CSS at all,
* so every tip fell back to a plain `<button>` for its dismiss control
* (a solid accent-green square) and no visual grouping. The severity