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:
@@ -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>
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -692,6 +692,10 @@ export function Settings(): ReactNode {
|
||||
error={calendarError}
|
||||
onPrevWindow={handleRedDaysPrevWindow}
|
||||
onNextWindow={handleRedDaysNextWindow}
|
||||
overrides={dayOverrides}
|
||||
workspaceId={workspace?.id ?? ''}
|
||||
onUpsertOverride={upsertDayOverride}
|
||||
onRemoveOverride={removeDayOverride}
|
||||
/>
|
||||
</section>
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user