Let a day removed from a period stay removed

Taking one day out of a period the planner placed did nothing that
lasted: the period still asked for a full week, so the next sync put the
day straight back. The option was disabled rather than lying about it.

A removed day is now recorded as its own thing — no leave for this
parent, this child, this date — and the planner honours it on every
later sync. It is kept apart from the working-day overrides, which
answer a different question about the calendar rather than about the
plan.

The day belongs to the parent whose band was clicked, so removing one
parent's day leaves their partner's alone; where both parents were to be
off on one funding arrangement, the partner is simply funded on their
own instead. A removed day shortens what the period is asking for rather
than counting as a day the plan failed to find, so the advice about
closing a shortfall does not start arguing for a day nobody wants.

Nothing is removed without a way back: the toast undoes it, and the day
itself is marked in the calendar and offers to restore.

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 16:05:17 +02:00
parent 6d92b87bf3
commit 8cfb9c82c6
39 changed files with 2362 additions and 31 deletions
+57 -5
View File
@@ -176,13 +176,14 @@ test('a drag saved without opening Advanced creates a planner-funded wish, and t
await expect(wishBand.first()).toBeVisible()
// Open the wish-linked band. Its own three-way remove: "this day" cannot
// be expressed for a wish (it would just reappear on the next sync) and
// is disabled; "this period" on a standalone (non-repeating) wish — one
// row, no series — removes the whole thing, same as "every year".
// Open the wish-linked band. Its own three-way remove: "this day" now
// works on a planner-placed day too (the removal is recorded as a
// `PlannedDayExclusion` — see the next test); "this period" on a
// standalone (non-repeating) wish — one row, no series — removes the whole
// thing, same as "every year".
await wishBand.first().click()
await expect(page.getByTestId('period-popover__content')).toBeVisible()
await expect(page.getByTestId('period-delete-day-button')).toBeDisabled()
await expect(page.getByTestId('period-delete-day-button')).toBeEnabled()
await page.getByTestId('period-delete-year-button').click()
await expect(page.getByTestId('period-delete-confirm')).toBeVisible()
@@ -193,3 +194,54 @@ test('a drag saved without opening Advanced creates a planner-funded wish, and t
await goToCalendarMonth(page, month)
await expect(page.locator('[data-testid^="calendar-band-wish:"]')).toHaveCount(0)
})
test('removing a single day out of a planner-placed period keeps it removed across syncs', async ({
page,
}) => {
// The user's requirement: "removing a single day should stay permanent.
// Even if it's an auto added period day." The wish behind the band still
// asks for the whole window, so without a stored `PlannedDayExclusion` the
// next `POST /api/plan/sync` — which the calendar runs on every load —
// would simply put the day back.
await registerAndReachOnboarding(page, uniqueEmail('remove-day'), 'Robin Testsson')
await fillChildStep(page, 'Nova')
await continueSoloAndSkipBalances(page)
const { month, dates } = targetRange()
await goToCalendarMonth(page, month)
const wishBand = page.locator('[data-testid^="calendar-band-wish:"]')
await expect(wishBand).toHaveCount(0)
await dragSelectDates(page, dates)
await expect(page.getByTestId('modal-title')).toHaveText('Planera 3 dagar')
await page.getByTestId('save-button').click()
await expect(page.getByTestId('planned-day-modal__content')).not.toBeVisible()
await expect(wishBand.first()).toBeVisible()
// Clicking the band itself targets its first placed date; clicking one of
// its day slots would target that date instead (see CalendarWeek's
// `clickedDate`). Either way the popover's "this day" removes exactly one.
await wishBand.first().click()
await expect(page.getByTestId('period-popover__content')).toBeVisible()
await page.getByTestId('period-delete-day-button').click()
await expect(page.getByTestId('period-delete-confirm')).toBeVisible()
await page.getByTestId('period-delete-confirm-button').click()
await expect(page.getByTestId('period-popover__overlay')).not.toBeVisible()
// There is a way back, offered immediately.
await expect(page.getByTestId('calendar-toast-undo')).toBeVisible()
// The removed date is marked rather than silently empty — a hole in an
// otherwise-funded week would read as a planner bug.
await expect(page.getByTestId('calendar-day-excluded-badge')).toHaveCount(1)
// The rest of the period survives: this removed one day, not the wish.
await expect(wishBand.first()).toBeVisible()
// The real assertion: a reload re-syncs the plan from the wish, and the
// day must still be gone afterwards.
await page.reload()
await goToCalendarMonth(page, month)
await expect(page.getByTestId('calendar-day-excluded-badge')).toHaveCount(1)
await expect(page.locator('[data-testid^="calendar-band-wish:"]').first()).toBeVisible()
})
@@ -388,17 +388,52 @@ describe('PeriodPopover', () => {
})
})
it('disables "this day" and shows why, for a wish-linked band (would just reappear on the next sync)', () => {
// Intent changed (was: '"this day" is disabled for a wish-linked band').
// A single day removed from a planner-placed period is now recorded as a
// `PlannedDayExclusion`, so it no longer reappears on the next sync and
// the option is live — see Calendar.tsx's `excludeDay`.
it('enables "this day" for a wish-linked band and explains what it does', () => {
render(<PeriodPopover {...defaultProps} wish={wish} />)
expect(screen.getByTestId('period-delete-day-button')).toBeDisabled()
expect(screen.getByTestId('period-delete-day-button')).not.toBeDisabled()
expect(screen.getByTestId('period-delete-day-button')).toHaveAttribute(
'title',
"Går inte att ta bort en enskild dag från en återkommande plan — planeraren lägger tillbaka den vid nästa synk. Ta bort perioden istället.",
'Bara den dagen tas bort. Resten av perioden ligger kvar, och planeraren lägger inte tillbaka dagen.',
)
expect(screen.getByTestId('period-delete-year-button')).not.toBeDisabled()
expect(screen.getByTestId('period-delete-all-years-button')).not.toBeDisabled()
})
it('calls onDelete with the "day" scope for a wish-linked band after confirmation', async () => {
mockOnDelete.mockResolvedValue(undefined)
render(<PeriodPopover {...defaultProps} wish={wish} />)
fireEvent.click(screen.getByTestId('period-delete-day-button'))
fireEvent.click(screen.getByTestId('period-delete-confirm-button'))
await waitFor(() => expect(mockOnDelete).toHaveBeenCalledWith('day'))
})
it('names the date and the parent in the "this day" confirmation', () => {
render(
<PeriodPopover
{...defaultProps}
wish={wish}
removeDayLabel="17 juni 2028"
removeDayParentName="Alex"
/>,
)
fireEvent.click(screen.getByTestId('period-delete-day-button'))
expect(screen.getByTestId('period-delete-confirm')).toHaveTextContent(
'Ta bort 17 juni 2028 för Alex?',
)
})
it('falls back to the generic confirmation when the parent is unknown', () => {
render(<PeriodPopover {...defaultProps} wish={wish} removeDayLabel="17 juni 2028" />)
fireEvent.click(screen.getByTestId('period-delete-day-button'))
expect(screen.getByTestId('period-delete-confirm')).toHaveTextContent(
'Är du säker på att du vill ta bort den här dagen?',
)
})
it('displays a generic local error when onDelete rejects with a non-Error value', async () => {
mockOnDelete.mockRejectedValue('boom')
render(<PeriodPopover {...defaultProps} wish={wish} />)
@@ -30,12 +30,12 @@ export interface PeriodPopoverSavePayload {
wantsDubbeldag: boolean
}
/** todo.md #9 — the three-way remove. `'day'` removes just the clicked
* occurrence's first date (ad-hoc bands only — see Calendar.tsx's
* `handleDeletePeriod`); `'year'` removes this occurrence/year only (for a
* yearly wish, narrows or splits `firstYear`/`lastYear`; for an ad-hoc band,
* removes the whole contiguous range, same as today); `'allYears'` removes
* every occurrence (today's only wish-branch behaviour, unchanged). */
/** todo.md #9 — the three-way remove. `'day'` removes one date out of the
* clicked band — for an ad-hoc band by deleting the row, for a planner-placed
* one by recording a `PlannedDayExclusion` so the next sync leaves it alone
* (see Calendar.tsx's `handleDeletePeriod`); `'year'` removes this
* occurrence/year only (for an ad-hoc band, the whole contiguous range);
* `'allYears'` removes every occurrence of the wish. */
export type PeriodDeleteScope = 'day' | 'year' | 'allYears'
export interface PeriodPopoverProps {
@@ -48,6 +48,16 @@ export interface PeriodPopoverProps {
* source of truth for an ad-hoc band. */
startDate: string
endDate: string
/** The single date "this day" would remove, already formatted for display
* (the day the user clicked inside the band, falling back to the band's
* first date). Shown in the confirm so it can never be ambiguous which day
* is about to go. */
removeDayLabel?: string | undefined
/** Whose day "this day" would remove. A removal is per parent — the
* partner's day on the same date stays — so the confirm says the name out
* loud rather than leaving the user to guess. `null` when the band's parent
* isn't among the known members (stale data, removed partner). */
removeDayParentName?: string | null | undefined
parents: Array<{ id: string; name: string }>
isSaving?: boolean | undefined
error?: string | null | undefined
@@ -132,6 +142,8 @@ export function PeriodPopover({
wish,
startDate,
endDate,
removeDayLabel,
removeDayParentName = null,
parents,
isSaving = false,
error = null,
@@ -208,7 +220,13 @@ export function PeriodPopover({
}
const deleteConfirmMessage: Record<PeriodDeleteScope, string> = {
day: t('calendar.periodDeleteConfirmDay'),
day:
removeDayLabel && removeDayParentName
? t('calendar.periodDeleteConfirmDayNamed', {
date: removeDayLabel,
parent: removeDayParentName,
})
: t('calendar.periodDeleteConfirmDay'),
year: t('calendar.periodDeleteConfirmYear'),
allYears: t('calendar.periodDeleteConfirmAllYears'),
}
@@ -483,8 +501,8 @@ export function PeriodPopover({
type="button"
className="btn btn--danger"
onClick={() => handleDeleteClick('day')}
disabled={isSaving || wish !== null}
title={wish !== null ? t('calendar.periodDeleteDayDisabledHint') : undefined}
disabled={isSaving}
title={t('calendar.periodDeleteDayHint')}
data-testid="period-delete-day-button"
>
{t('calendar.periodDeleteDayButton')}
@@ -270,4 +270,36 @@ describe('CalendarDay', () => {
expect(day.getAttribute('aria-label')).toContain('arbetsdag')
})
})
describe('user-removed days (PlannedDayExclusion)', () => {
it('marks a removed day with its own class, badge and aria descriptor', () => {
const { getByTestId } = render(<CalendarDay date="2026-06-15" isExcluded={true} />)
const day = getByTestId('calendar-day')
expect(day).toHaveClass('calendar-day--excluded')
expect(getByTestId('calendar-day-excluded-badge')).toBeInTheDocument()
expect(day.getAttribute('aria-label')).toContain('borttagen dag')
})
it('leaves an ordinary day unmarked', () => {
const { getByTestId, queryByTestId } = render(<CalendarDay date="2026-06-15" />)
expect(getByTestId('calendar-day')).not.toHaveClass('calendar-day--excluded')
expect(queryByTestId('calendar-day-excluded-badge')).not.toBeInTheDocument()
})
it('can carry both an override and a removal on the same date', () => {
const { getByTestId } = render(
<CalendarDay
date="2026-06-15"
isOverride={true}
overrideIsWorkingDay={false}
isExcluded={true}
/>,
)
const day = getByTestId('calendar-day')
expect(day).toHaveClass('calendar-day--override')
expect(day).toHaveClass('calendar-day--excluded')
expect(getByTestId('calendar-day-override-badge')).toBeInTheDocument()
expect(getByTestId('calendar-day-excluded-badge')).toBeInTheDocument()
})
})
})
@@ -21,6 +21,11 @@ export interface CalendarDayProps {
isOverride?: boolean | undefined
/** The override's value when `isOverride` is true (true = marked as a working day). */
overrideIsWorkingDay?: boolean | null | undefined
/** True iff the user removed this date from the plan by hand (see
* `PlannedDayExclusion`). Without a mark, a day the planner deliberately
* skipped inside an otherwise-funded period just looks like a hole in the
* week. */
isExcluded?: boolean | undefined
}
export function CalendarDay({
@@ -37,6 +42,7 @@ export function CalendarDay({
holidayName,
isOverride = false,
overrideIsWorkingDay = null,
isExcluded = false,
}: CalendarDayProps): ReactNode {
const { t } = useTranslation()
const dateObj = new Date(date + 'T00:00:00Z')
@@ -82,6 +88,9 @@ export function CalendarDay({
overrideIsWorkingDay ? t('calendarDay.overrideWorkingAria') : t('calendarDay.overrideFreeAria'),
)
}
if (isExcluded) {
descriptors.push(t('calendarDay.excludedAria'))
}
const baseLabel = events.length > 0 ? t('calendarDay.ariaWithEvents', { date, count: events.length }) : date
const ariaLabel = descriptors.length > 0 ? `${baseLabel}, ${descriptors.join(', ')}` : baseLabel
@@ -97,6 +106,7 @@ export function CalendarDay({
dayType === 'klamdag' ? 'calendar-day--klamdag' : '',
isPlainSaturday ? 'calendar-day--weekend' : '',
isOverride ? 'calendar-day--override' : '',
isExcluded ? 'calendar-day--excluded' : '',
]
.filter(Boolean)
.join(' ')
@@ -143,6 +153,15 @@ export function CalendarDay({
{overrideIsWorkingDay ? '⚒' : '✓'}
</span>
)}
{isExcluded && (
<span
className="calendar-day__excluded-badge"
data-testid="calendar-day-excluded-badge"
aria-hidden="true"
>
⊘
</span>
)}
{events.length > 0 && (
<div className="calendar-day__events" data-testid="calendar-day-events">
{events.map((event, idx) => (
@@ -34,6 +34,7 @@ vi.mock('./CalendarDay', () => ({
holidayName,
isOverride,
overrideIsWorkingDay,
isExcluded,
}: CalendarDayProps) => (
<div
data-testid={`calendar-day-${date}`}
@@ -42,6 +43,7 @@ vi.mock('./CalendarDay', () => ({
data-holiday-name={holidayName ?? ''}
data-is-override={isOverride ? 'true' : 'false'}
data-override-is-working-day={overrideIsWorkingDay === null || overrideIsWorkingDay === undefined ? '' : String(overrideIsWorkingDay)}
data-is-excluded={isExcluded ? 'true' : 'false'}
onClick={onClick}
onPointerDown={() => onPointerDown?.(date)}
onPointerEnter={() => onPointerEnter?.(date)}
@@ -481,9 +483,41 @@ describe('CalendarWeek', () => {
parentPersonalDataUuid: 'parent-1',
startDate: '2026-01-05',
endDate: '2026-01-05',
// Clicking the band itself (not one of its day slots) reports the
// band's first date, which is what "this day" then removes.
clickedDate: '2026-01-05',
})
})
it('reports the clicked day slot as clickedDate, not the band start', () => {
const onBandClick: (info: BandClickInfo) => void = vi.fn()
const plannedDays = [
pd({ id: '1', date: '2026-01-05', wishId: 'wish-1', parentPersonalDataUuid: 'parent-1' }),
pd({ id: '2', date: '2026-01-06', wishId: 'wish-1', parentPersonalDataUuid: 'parent-1' }),
pd({ id: '3', date: '2026-01-07', wishId: 'wish-1', parentPersonalDataUuid: 'parent-1' }),
]
const { container } = render(
<CalendarWeek {...defaultProps} plannedDays={plannedDays} onBandClick={onBandClick} />,
)
const daySlots = container.querySelectorAll('.calendar-band__day')
fireEvent.click(daySlots[1]!)
expect(onBandClick).toHaveBeenCalledWith(
expect.objectContaining({ clickedDate: '2026-01-06', startDate: '2026-01-05' }),
)
})
it('marks a user-removed date in the grid so the hole is explained', () => {
render(<CalendarWeek {...defaultProps} excludedDates={new Set(['2026-01-06'])} />)
expect(screen.getByTestId('calendar-day-2026-01-06')).toHaveAttribute(
'data-is-excluded',
'true',
)
expect(screen.getByTestId('calendar-day-2026-01-05')).toHaveAttribute(
'data-is-excluded',
'false',
)
})
it('calls onBandClick with a null wishId for an ad-hoc (non-wish) band', () => {
const onBandClick: (info: BandClickInfo) => void = vi.fn()
const plannedDays = [pd({ id: '1', date: '2026-01-05', wishId: null })]
@@ -708,6 +742,7 @@ describe('CalendarWeek', () => {
parentPersonalDataUuid: 'parent-1',
startDate: '2026-01-05',
endDate: '2026-01-05',
clickedDate: '2026-01-05',
})
// Clicking the band must not also trigger the day cell's own click.
expect(onDayClick).not.toHaveBeenCalled()
@@ -14,6 +14,11 @@ export interface BandClickInfo {
parentPersonalDataUuid: string
startDate: string
endDate: string
/** The single date the user actually clicked inside the band, which is what
* the popover's "this day" removes. Clicking the band anywhere else (or
* activating it from the keyboard) reports the band's own start date, so
* this is never empty. */
clickedDate: string
}
export interface CalendarWeekProps {
@@ -42,6 +47,11 @@ export interface CalendarWeekProps {
onPointerEnter: (date: string) => void
/** §3.4/§3.6 — calendar metadata (röda dagar/klämdagar/overrides) keyed by ISO date. */
calendarDaysByDate?: Map<string, CalendarDayEntry> | undefined
/** Dates the user has removed from the plan by hand (see
* `PlannedDayExclusion`). A date the planner deliberately skipped inside an
* otherwise-funded period is invisible otherwise, and the hole in the week
* looks like a bug. */
excludedDates?: Set<string> | undefined
/** Opens the period editor for the clicked band's underlying period. */
onBandClick?: ((info: BandClickInfo) => void) | undefined
/** §Task7 — the wishId of a just-created/just-edited period to briefly
@@ -283,6 +293,7 @@ export function CalendarWeek({
onPointerDown,
onPointerEnter,
calendarDaysByDate,
excludedDates,
onBandClick,
flashWishId,
monthMaxLaneCount,
@@ -352,6 +363,7 @@ export function CalendarWeek({
holidayName={calendarInfo?.name ?? null}
isOverride={calendarInfo?.isOverride ?? false}
overrideIsWorkingDay={calendarInfo?.overrideIsWorkingDay ?? null}
isExcluded={excludedDates?.has(date) ?? false}
// Every cell is clickable/drag-selectable regardless of which
// month it visually belongs to — `dayIsCurrentMonth` only
// controls the dimmed `calendar-day--disabled` styling above.
@@ -394,12 +406,15 @@ export function CalendarWeek({
.filter(Boolean)
.join(' ')
const fireClick = () =>
const fireClick = (clickedDate?: string) =>
onBandClick?.({
wishId: segment.wishId,
parentPersonalDataUuid: segment.parentUuid,
startDate: segment.overallStart,
endDate: segment.overallEnd,
// Falls back to the band's first date when the click didn't land
// on a specific day slot (the band's padding, or the keyboard).
clickedDate: clickedDate ?? segment.overallStart,
})
return (
@@ -417,7 +432,7 @@ export function CalendarWeek({
aria-label={`${label} · ${typeLabel}, ${segment.overallStart} – ${segment.overallEnd}`}
role={onBandClick ? 'button' : undefined}
tabIndex={onBandClick ? 0 : undefined}
onClick={onBandClick ? fireClick : undefined}
onClick={onBandClick ? () => fireClick() : undefined}
onKeyDown={
onBandClick
? (e) => {
@@ -443,6 +458,18 @@ export function CalendarWeek({
style={info.isGap ? undefined : { backgroundColor: getColorForType(info.dayType ?? 'vacation') }}
title={info.isPinned ? t('calendarDay.pinnedAria') : undefined}
data-testid={info.isPinned ? 'calendar-band-day-pinned' : undefined}
// Clicking one day slot opens the same popover as clicking
// the band, but tells it WHICH date was clicked — "this
// day" must be able to remove a day in the middle of a
// period, not only its first.
onClick={
onBandClick
? (e) => {
e.stopPropagation()
fireClick(info.date)
}
: undefined
}
/>
))}
</div>
@@ -622,4 +622,58 @@ describe('PlannedDayModal', () => {
expect(screen.queryByTestId('pinned-notice')).not.toBeInTheDocument()
})
})
describe('removed days (PlannedDayExclusion) — the way back', () => {
it('names the parent whose day was removed and offers to restore it', async () => {
const onRestoreExcluded = vi.fn().mockResolvedValue(undefined)
render(
<PlannedDayModal
{...defaultProps}
isOpen={true}
excludedEntries={[{ id: 'ex-1', parentName: 'Alex' }]}
onRestoreExcluded={onRestoreExcluded}
/>,
)
expect(screen.getByTestId('excluded-notice')).toHaveTextContent(
'Du har tagit bort den här dagen för Alex.',
)
await userEvent.click(screen.getByTestId('restore-excluded-ex-1'))
await waitFor(() => expect(onRestoreExcluded).toHaveBeenCalledWith('ex-1'))
})
it('lists one notice per parent who lost that date', () => {
render(
<PlannedDayModal
{...defaultProps}
isOpen={true}
excludedEntries={[
{ id: 'ex-1', parentName: 'Alex' },
{ id: 'ex-2', parentName: 'Robin' },
]}
onRestoreExcluded={vi.fn()}
/>,
)
expect(screen.getAllByTestId('excluded-notice')).toHaveLength(2)
})
it('shows nothing when the date has no removals', () => {
render(<PlannedDayModal {...defaultProps} isOpen={true} />)
expect(screen.queryByTestId('excluded-notice')).not.toBeInTheDocument()
})
it('hides the notice in range mode, where it would be ambiguous which date it means', () => {
render(
<PlannedDayModal
{...defaultProps}
isOpen={true}
isRangeMode={true}
dayCount={3}
excludedEntries={[{ id: 'ex-1', parentName: 'Alex' }]}
onRestoreExcluded={vi.fn()}
/>,
)
expect(screen.queryByTestId('excluded-notice')).not.toBeInTheDocument()
})
})
})
@@ -23,6 +23,12 @@ export interface PlannedDayModalSavePinnedPayload {
isDubbeldag: boolean
}
/** One day the user removed by hand, ready to be offered back. */
export interface PlannedDayExclusionEntry {
id: string
parentName: string
}
export interface PlannedDayModalProps {
date: string
isOpen: boolean
@@ -39,6 +45,11 @@ export interface PlannedDayModalProps {
onSaveWish: (payload: PlannedDayModalSaveWishPayload) => Promise<void>
onSavePinned: (payload: PlannedDayModalSavePinnedPayload) => Promise<void>
onDelete?: (() => Promise<void>) | undefined
/** Removals recorded for THIS date (see `PlannedDayExclusion`), one per
* parent. Shown with a way back: a day the user can't restore is a trap,
* and the undo toast is gone as soon as it times out. */
excludedEntries?: PlannedDayExclusionEntry[] | undefined
onRestoreExcluded?: ((exclusionId: string) => Promise<void>) | undefined
onClose: () => void
isSaving?: boolean | undefined
error?: string | null | undefined
@@ -57,6 +68,8 @@ export function PlannedDayModal({
onSaveWish,
onSavePinned,
onDelete,
excludedEntries = [],
onRestoreExcluded,
onClose,
isSaving = false,
error = null,
@@ -217,6 +230,28 @@ export function PlannedDayModal({
</div>
)}
{!isRangeMode &&
excludedEntries.map((entry) => (
<div
key={entry.id}
className="planned-day-modal__excluded-notice"
data-testid="excluded-notice"
>
<span>{t('plannedDayModal.excludedNotice', { parent: entry.parentName })}</span>
{onRestoreExcluded && (
<button
type="button"
className="btn btn--secondary btn--sm"
onClick={() => void onRestoreExcluded(entry.id)}
disabled={isSaving}
data-testid={`restore-excluded-${entry.id}`}
>
{t('plannedDayModal.restoreExcludedButton')}
</button>
)}
</div>
))}
<div className="stack">
{!forcedAdvanced && hasMultipleParents && (
<div className="field">
@@ -36,6 +36,24 @@
margin: calc(var(--sp-2) * -1) 0 var(--sp-4) 0;
}
/* "You removed this day" + the way back. Wraps and sizes itself by content
* so both the Swedish and the English string fit without clipping the
* button. */
.planned-day-modal__excluded-notice {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: var(--sp-2);
font-size: var(--fs-xs);
color: var(--c-text-muted);
margin-bottom: var(--sp-4);
}
.planned-day-modal__excluded-notice > span {
flex: 1 1 12rem;
min-width: 0;
}
.planned-day-modal__override-switch {
display: flex;
gap: var(--sp-4);
+1
View File
@@ -11,5 +11,6 @@ export { useLatestBalance } from './useLatestBalance'
export { usePlannedDays } from './usePlannedDays'
export { useVacationDays } from './useVacationDays'
export { useDayOverrides } from './useDayOverrides'
export { usePlannedDayExclusions } from './usePlannedDayExclusions'
export { useCalendarDays } from './useCalendarDays'
export { useScraperFetch } from './useScraperFetch'
@@ -0,0 +1,98 @@
import { describe, it, expect, beforeEach, vi } from 'vitest'
import { renderHook, waitFor } from '@testing-library/react'
import { usePlannedDayExclusions } from './usePlannedDayExclusions'
import * as api from '@/services/api'
import { useWorkspace } from '@/context'
import { makePlannedDayExclusion, makeWorkspaceContextValue } from '../../tests/fixtures'
vi.mock('@/services/api')
vi.mock('@/context')
describe('usePlannedDayExclusions', () => {
const mockExclusions = [
makePlannedDayExclusion({ id: 'ex-1', date: '2027-02-03' }),
makePlannedDayExclusion({ id: 'ex-2', date: '2027-02-04', parentPersonalDataUuid: 'parent-2' }),
]
let bumpPlanVersion: ReturnType<typeof vi.fn>
beforeEach(() => {
vi.clearAllMocks()
bumpPlanVersion = vi.fn()
vi.mocked(useWorkspace).mockReturnValue(
makeWorkspaceContextValue({ planVersion: 0, bumpPlanVersion }),
)
vi.mocked(api.listPlannedDayExclusionsByWorkspace).mockResolvedValue(mockExclusions)
vi.mocked(api.createPlannedDayExclusion).mockResolvedValue(mockExclusions[0]!)
vi.mocked(api.deletePlannedDayExclusion).mockResolvedValue(undefined)
})
it('fetches the removed days on mount', async () => {
const { result } = renderHook(() => usePlannedDayExclusions('ws-1'))
expect(result.current.isLoading).toBe(true)
await waitFor(() => expect(result.current.isLoading).toBe(false))
expect(result.current.data).toEqual(mockExclusions)
expect(api.listPlannedDayExclusionsByWorkspace).toHaveBeenCalledWith('ws-1')
})
it('does not fetch without a workspace', async () => {
renderHook(() => usePlannedDayExclusions(null))
await waitFor(() => {
expect(api.listPlannedDayExclusionsByWorkspace).not.toHaveBeenCalled()
})
})
it('excludes a day, refetches, and returns the created row so undo can restore it', async () => {
const { result } = renderHook(() => usePlannedDayExclusions('ws-1'))
await waitFor(() => expect(result.current.isLoading).toBe(false))
const created = await result.current.exclude({
workspaceId: 'ws-1',
childId: 'child-1',
parentPersonalDataUuid: 'parent-1',
date: '2027-02-03',
})
expect(api.createPlannedDayExclusion).toHaveBeenCalledWith({
workspaceId: 'ws-1',
childId: 'child-1',
parentPersonalDataUuid: 'parent-1',
date: '2027-02-03',
})
expect(created.id).toBe('ex-1')
expect(api.listPlannedDayExclusionsByWorkspace).toHaveBeenCalledTimes(2)
expect(bumpPlanVersion).toHaveBeenCalledTimes(1)
})
it('restores a removed day and refetches', async () => {
const { result } = renderHook(() => usePlannedDayExclusions('ws-1'))
await waitFor(() => expect(result.current.isLoading).toBe(false))
await result.current.restore('ex-1')
expect(api.deletePlannedDayExclusion).toHaveBeenCalledWith('ex-1')
expect(api.listPlannedDayExclusionsByWorkspace).toHaveBeenCalledTimes(2)
expect(bumpPlanVersion).toHaveBeenCalledTimes(1)
})
it('sets an error when the fetch fails', async () => {
vi.mocked(api.listPlannedDayExclusionsByWorkspace).mockRejectedValueOnce(
new Error('Network error'),
)
const { result } = renderHook(() => usePlannedDayExclusions('ws-1'))
await waitFor(() => expect(result.current.isLoading).toBe(false))
expect(result.current.error).toBe('Network error')
expect(result.current.data).toEqual([])
})
it('falls back to a generic message when a non-Error value is thrown', async () => {
vi.mocked(api.listPlannedDayExclusionsByWorkspace).mockRejectedValueOnce('boom')
const { result } = renderHook(() => usePlannedDayExclusions('ws-1'))
await waitFor(() => expect(result.current.isLoading).toBe(false))
expect(result.current.error).toBe('Failed to load removed days')
})
})
@@ -0,0 +1,75 @@
import { useCallback, useEffect, useState } from 'react'
import * as api from '@/services/api'
import { useLatestRequest } from './useLatestRequest'
import { useWorkspace } from '@/context'
import type { PlannedDayExclusion, PlannedDayExclusionCreateRequest } from '@/types'
interface UsePlannedDayExclusionsResult {
data: PlannedDayExclusion[]
isLoading: boolean
error: string | null
/** Remove one day permanently: the planner stops placing leave here. */
exclude: (req: PlannedDayExclusionCreateRequest) => Promise<PlannedDayExclusion>
/** The way back — what the undo toast and the day modal's restore call. */
restore: (id: string) => Promise<void>
}
/** The days the user removed by hand (see `PlannedDayExclusion`).
*
* Shaped after `useDayOverrides`: fetch on workspace change, refetch after
* every write, and bump the shared plan version so other hooks reading
* plan-derived data refetch too. `exclude` returns the created row so the
* caller can build an undo that restores exactly it.
*/
export function usePlannedDayExclusions(workspaceId: string | null): UsePlannedDayExclusionsResult {
const [data, setData] = useState<PlannedDayExclusion[]>([])
const [isLoading, setIsLoading] = useState(false)
const [error, setError] = useState<string | null>(null)
const { start, isCurrent } = useLatestRequest()
const { bumpPlanVersion } = useWorkspace()
const fetch = useCallback(async () => {
const requestId = start()
if (!workspaceId) {
setData([])
return
}
setIsLoading(true)
setError(null)
try {
const result = await api.listPlannedDayExclusionsByWorkspace(workspaceId)
if (!isCurrent(requestId)) return
setData(result)
} catch (err: unknown) {
if (!isCurrent(requestId)) return
setError(err instanceof Error ? err.message : 'Failed to load removed days')
} finally {
if (isCurrent(requestId)) setIsLoading(false)
}
}, [workspaceId, start, isCurrent])
useEffect(() => {
void fetch()
}, [fetch])
const exclude = useCallback(
async (req: PlannedDayExclusionCreateRequest) => {
const created = await api.createPlannedDayExclusion(req)
await fetch()
bumpPlanVersion()
return created
},
[fetch, bumpPlanVersion],
)
const restore = useCallback(
async (id: string) => {
await api.deletePlannedDayExclusion(id)
await fetch()
bumpPlanVersion()
},
[fetch, bumpPlanVersion],
)
return { data, isLoading, error, exclude, restore }
}
+13 -2
View File
@@ -31,6 +31,7 @@ export const calendar = {
'calendar.weekdayWed': 'Wed',
'calendar.you': 'You',
'calendarDay.ariaWithEvents': '{date} with {count} event(s)',
'calendarDay.excludedAria': 'removed day — the planner skips it',
'calendarDay.klamdagAria': 'bridge day',
'calendarDay.overrideFreeAria': 'marked as a day off (exception)',
'calendarDay.overrideWorkingAria': 'marked as a working day (exception)',
@@ -52,6 +53,8 @@ export const calendar = {
'plannedDayModal.newTitle': 'Plan a Day',
'plannedDayModal.parentLabel': 'Parent',
'plannedDayModal.pinnedNotice': 'This day is pinned by you — the planner will not change it automatically.',
'plannedDayModal.excludedNotice': 'You removed this day for {parent}.',
'plannedDayModal.restoreExcludedButton': 'Put the day back',
'plannedDayModal.dayStatusLabel': 'This day is:',
'plannedDayModal.rangeTitle': 'Plan {count} days',
'plannedDayModal.parentModeLabel': 'Who is this for?',
@@ -138,10 +141,18 @@ export const calendar = {
'calendar.periodDeleteDayButton': 'This day',
'calendar.periodDeleteYearButton': 'This period',
'calendar.periodDeleteAllYearsButton': 'This period, every year',
'calendar.periodDeleteDayDisabledHint':
"Can't remove a single day from a recurring plan — the planner will add it back on the next sync. Delete the period instead.",
// "This day" now works on planner-placed days too: the removal is stored
// as a `PlannedDayExclusion` and survives every subsequent sync.
'calendar.periodDeleteDayHint':
'Only that day is removed. The rest of the period stays, and the planner will not add the day back.',
'calendar.periodDeleteAllYearsDisabledHint': 'This period is not linked to a recurring plan.',
'calendar.periodDeleteConfirmDay': 'Are you sure you want to remove this day?',
// Names both the date and the parent: a day is removed for ONE parent, and
// the partner's day on the same date stays (see PlannedDayExclusionService).
'calendar.periodDeleteConfirmDayNamed':
'Remove {date} for {parent}? The day stays removed until you restore it.',
'calendar.toastDayExcluded': '{date} removed for {parent}',
'calendar.toastDayRestored': '{date} restored for {parent}',
'calendar.periodDeleteConfirmYear': 'Are you sure you want to remove this period (this year only)?',
'calendar.periodDeleteConfirmAllYears': 'Are you sure you want to remove the period — every year?',
'calendar.wantsDubbeldagLabel': 'Allow double days (dubbeldagar)',
+13 -2
View File
@@ -31,6 +31,7 @@ export const calendar = {
'calendar.weekdayWed': 'Ons',
'calendar.you': 'Du',
'calendarDay.ariaWithEvents': '{date} med {count} händelse(r)',
'calendarDay.excludedAria': 'borttagen dag – planeraren hoppar över den',
'calendarDay.klamdagAria': 'klämdag',
'calendarDay.overrideFreeAria': 'markerad som ledig dag (undantag)',
'calendarDay.overrideWorkingAria': 'markerad som arbetsdag (undantag)',
@@ -52,6 +53,8 @@ export const calendar = {
'plannedDayModal.newTitle': 'Planera en dag',
'plannedDayModal.parentLabel': 'Förälder',
'plannedDayModal.pinnedNotice': 'Den här dagen är låst av dig — planeraren ändrar den inte automatiskt.',
'plannedDayModal.excludedNotice': 'Du har tagit bort den här dagen för {parent}.',
'plannedDayModal.restoreExcludedButton': 'Lägg tillbaka dagen',
'plannedDayModal.dayStatusLabel': 'Den här dagen är:',
'plannedDayModal.rangeTitle': 'Planera {count} dagar',
'plannedDayModal.parentModeLabel': 'Vem gäller det?',
@@ -139,10 +142,18 @@ export const calendar = {
'calendar.periodDeleteDayButton': 'Den här dagen',
'calendar.periodDeleteYearButton': 'Den här perioden',
'calendar.periodDeleteAllYearsButton': 'Den här perioden, varje år',
'calendar.periodDeleteDayDisabledHint':
'Går inte att ta bort en enskild dag från en återkommande plan — planeraren lägger tillbaka den vid nästa synk. Ta bort perioden istället.',
// "Den här dagen" gäller numera även planerarens egna dagar: borttagningen
// sparas som en `PlannedDayExclusion` och överlever varje ny synk.
'calendar.periodDeleteDayHint':
'Bara den dagen tas bort. Resten av perioden ligger kvar, och planeraren lägger inte tillbaka dagen.',
'calendar.periodDeleteAllYearsDisabledHint': 'Den här perioden är inte kopplad till en återkommande plan.',
'calendar.periodDeleteConfirmDay': 'Är du säker på att du vill ta bort den här dagen?',
// Namnger både datumet och föräldern: en dag tas bort för EN förälder, och
// partnerns dag samma datum ligger kvar (se PlannedDayExclusionService).
'calendar.periodDeleteConfirmDayNamed':
'Ta bort {date} för {parent}? Dagen stannar borta tills du återställer den.',
'calendar.toastDayExcluded': '{date} togs bort för {parent}',
'calendar.toastDayRestored': '{date} lades tillbaka för {parent}',
'calendar.periodDeleteConfirmYear': 'Är du säker på att du vill ta bort den här perioden (bara det här året)?',
'calendar.periodDeleteConfirmAllYears': 'Är du säker på att du vill ta bort perioden — alla år?',
'calendar.wantsDubbeldagLabel': 'Tillåt dubbeldagar',
+144 -3
View File
@@ -13,6 +13,7 @@ import {
useLeaveWishes,
useWorkspaceMembers,
usePlanSync,
usePlannedDayExclusions,
} from '@/hooks'
import { LanguageProvider } from '@/i18n'
import type { PlannedDayModalProps } from '@/components/modals/PlannedDayModal'
@@ -35,6 +36,8 @@ import {
makeRejection,
makePlanProposal,
makePlanApplyResponse,
makePlannedDayExclusionsResult,
makePlannedDayExclusion,
} from '../../tests/fixtures'
import { makeCalendarDayEntry, makeCalendarWindow } from '../../tests/fixtures/calendar'
@@ -61,6 +64,7 @@ vi.mock('@/hooks', () => ({
useLeaveWishes: vi.fn(),
useWorkspaceMembers: vi.fn(),
usePlanSync: vi.fn(),
usePlannedDayExclusions: vi.fn(),
}))
vi.mock('@/components/layout/Layout', () => ({
@@ -149,6 +153,16 @@ vi.mock('@/components/modals/PlannedDayModal', () => ({
delete
</button>
)}
{(props.excludedEntries ?? []).map((entry) => (
<button
key={entry.id}
data-testid={`modal-restore-excluded-${entry.id}`}
data-parent-name={entry.parentName}
onClick={() => void props.onRestoreExcluded?.(entry.id)}
>
restore {entry.parentName}
</button>
))}
<button data-testid="modal-close" onClick={props.onClose}>
close
</button>
@@ -164,6 +178,8 @@ vi.mock('@/components/calendar/PeriodPopover', () => ({
data-wish-id={props.wish?.id ?? 'null'}
data-start={props.startDate}
data-end={props.endDate}
data-remove-day-label={props.removeDayLabel ?? ''}
data-remove-day-parent={props.removeDayParentName ?? ''}
data-error={props.error ?? ''}
>
<button
@@ -272,12 +288,14 @@ describe('Calendar', () => {
const mockLeaveWishes = makeLeaveWishesResult()
const mockMembers = makeWorkspaceMembersResult()
const mockSync = makePlanSyncResult()
const mockExclusions = makePlannedDayExclusionsResult()
beforeEach(() => {
vi.mocked(useCalendarDays).mockReturnValue(mockCalendarWindow)
vi.mocked(useLeaveWishes).mockReturnValue(mockLeaveWishes)
vi.mocked(useWorkspaceMembers).mockReturnValue(mockMembers)
vi.mocked(usePlanSync).mockReturnValue(mockSync)
vi.mocked(usePlannedDayExclusions).mockReturnValue(mockExclusions)
vi.mocked(useWorkspace).mockReturnValue(mockWorkspace)
vi.mocked(usePlannedDays).mockReturnValue(mockPlannedDays)
vi.mocked(useSearchParams).mockReturnValue([new URLSearchParams(), mockSetSearchParams])
@@ -1947,15 +1965,31 @@ describe('Calendar', () => {
})
describe('todo.md #9 — the three-way remove', () => {
it('"day" scope is a no-op for a wish-linked band (disabled in the UI; would just reappear on the next sync)', async () => {
// Intent changed (was: '"day" scope is a no-op for a wish-linked band').
// The user asked for it to work: a single day removed from a
// planner-placed period is now recorded as a `PlannedDayExclusion`, which
// is what stops the next sync re-deriving it.
it('"day" scope records an exclusion for a wish-linked band, keyed on the band\'s parent', async () => {
const today = todayDateString()
const wish = makeLeaveWish({ id: 'lw-1' })
const remove = vi.fn().mockResolvedValue(undefined)
const update = vi.fn().mockResolvedValue(wish)
const exclude = vi.fn().mockResolvedValue(makePlannedDayExclusion({ id: 'ex-1' }))
vi.mocked(useLeaveWishes).mockReturnValue(makeLeaveWishesResult({ data: [wish], remove, update }))
vi.mocked(usePlannedDayExclusions).mockReturnValue(
makePlannedDayExclusionsResult({ exclude }),
)
vi.mocked(usePlannedDays).mockReturnValue(
makePlannedDaysResult({
data: [makePlannedLeaveDay({ id: 'pd-1', date: today, wishId: 'lw-1', parentPersonalDataUuid: 'person-1' })],
data: [
makePlannedLeaveDay({
id: 'pd-1',
date: today,
wishId: 'lw-1',
parentPersonalDataUuid: 'person-1',
childId: 'child-1',
}),
],
}),
)
@@ -1963,9 +1997,116 @@ describe('Calendar', () => {
fireEvent.click(screen.getAllByTestId(/^calendar-band-/)[0]!)
fireEvent.click(screen.getByTestId('period-delete-day'))
await waitFor(() => expect(screen.queryByTestId('period-popover')).not.toBeInTheDocument())
await waitFor(() =>
expect(exclude).toHaveBeenCalledWith({
workspaceId: 'ws-1',
childId: 'child-1',
parentPersonalDataUuid: 'person-1',
date: today,
}),
)
// The wish itself is untouched — only this one date goes.
expect(remove).not.toHaveBeenCalled()
expect(update).not.toHaveBeenCalled()
await waitFor(() => expect(screen.queryByTestId('period-popover')).not.toBeInTheDocument())
})
it('offers an undo that restores the removed day', async () => {
const today = todayDateString()
const wish = makeLeaveWish({ id: 'lw-1' })
const exclude = vi.fn().mockResolvedValue(makePlannedDayExclusion({ id: 'ex-9' }))
const restore = vi.fn().mockResolvedValue(undefined)
vi.mocked(useLeaveWishes).mockReturnValue(makeLeaveWishesResult({ data: [wish] }))
vi.mocked(usePlannedDayExclusions).mockReturnValue(
makePlannedDayExclusionsResult({ exclude, restore }),
)
vi.mocked(usePlannedDays).mockReturnValue(
makePlannedDaysResult({
data: [
makePlannedLeaveDay({
id: 'pd-1',
date: today,
wishId: 'lw-1',
parentPersonalDataUuid: 'person-1',
childId: 'child-1',
}),
],
}),
)
render(<Calendar />)
fireEvent.click(screen.getAllByTestId(/^calendar-band-/)[0]!)
fireEvent.click(screen.getByTestId('period-delete-day'))
await waitFor(() => expect(screen.getByTestId('calendar-toast-undo')).toBeInTheDocument())
fireEvent.click(screen.getByTestId('calendar-toast-undo'))
await waitFor(() => expect(restore).toHaveBeenCalledWith('ex-9'))
})
it('offers the removed day back from the day popover, long after the toast is gone', async () => {
const today = todayDateString()
const restore = vi.fn().mockResolvedValue(undefined)
vi.mocked(useWorkspaceMembers).mockReturnValue(
makeWorkspaceMembersResult({
data: [makePersonalData({ uuid: 'person-1', name: 'Alex' })],
}),
)
vi.mocked(usePlannedDayExclusions).mockReturnValue(
makePlannedDayExclusionsResult({
restore,
data: [
makePlannedDayExclusion({
id: 'ex-7',
date: today,
parentPersonalDataUuid: 'person-1',
}),
],
}),
)
render(<Calendar />)
const todayCell = screen
.getAllByTestId('calendar-day')
.find((el) => el.getAttribute('aria-label')?.startsWith(today))
fireEvent.click(todayCell!)
const restoreButton = await screen.findByTestId('modal-restore-excluded-ex-7')
expect(restoreButton).toHaveAttribute('data-parent-name', 'Alex')
fireEvent.click(restoreButton)
await waitFor(() => expect(restore).toHaveBeenCalledWith('ex-7'))
})
it('tells the popover which date and which parent "this day" would remove', async () => {
const today = todayDateString()
const wish = makeLeaveWish({ id: 'lw-1' })
vi.mocked(useLeaveWishes).mockReturnValue(makeLeaveWishesResult({ data: [wish] }))
vi.mocked(useWorkspaceMembers).mockReturnValue(
makeWorkspaceMembersResult({
data: [makePersonalData({ uuid: 'person-1', name: 'Alex' })],
}),
)
vi.mocked(usePlannedDays).mockReturnValue(
makePlannedDaysResult({
data: [
makePlannedLeaveDay({
id: 'pd-1',
date: today,
wishId: 'lw-1',
parentPersonalDataUuid: 'person-1',
childId: 'child-1',
}),
],
}),
)
render(<Calendar />)
fireEvent.click(screen.getAllByTestId(/^calendar-band-/)[0]!)
const popover = screen.getByTestId('period-popover')
expect(popover).toHaveAttribute('data-remove-day-parent', 'Alex')
expect(popover.getAttribute('data-remove-day-label')).toBeTruthy()
})
it('"every year" scope is a no-op for an ad-hoc band (disabled in the UI; nothing recurring to remove)', async () => {
+105 -3
View File
@@ -10,6 +10,7 @@ import {
useLeaveWishes,
useWorkspaceMembers,
usePlanSync,
usePlannedDayExclusions,
} from '@/hooks'
import type {
PlannedLeaveDay,
@@ -308,6 +309,9 @@ export function Calendar(): ReactNode {
const wishesHook = useLeaveWishes(workspace?.id ?? null)
const membersHook = useWorkspaceMembers(workspace?.id ?? null)
const syncHook = usePlanSync()
// The days the user removed by hand. Read for the calendar's badges, and
// written by the popover's "this day" remove — see `excludeDay`.
const exclusionsHook = usePlannedDayExclusions(workspace?.id ?? null)
const calendarDaysByDate = useMemo(() => {
const map = new Map<string, CalendarDayEntry>()
@@ -317,6 +321,22 @@ export function Calendar(): ReactNode {
return map
}, [calendarHook.data])
// Any parent's removal marks the date: the mark says "someone deliberately
// took a day out here", which is what stops the hole reading as a bug. Whose
// day it was is shown in the day popover, where there is room to say it.
const excludedDates = useMemo(
() => new Set(exclusionsHook.data.map((e) => e.date)),
[exclusionsHook.data],
)
/** The display name for a band's owner. Used wherever the UI has to say
* WHOSE day something is — above all the "this day" remove, which takes one
* parent's day and leaves the partner's. Falls back the same way
* CalendarWeek's band labels do. */
const resolveParentName = (uuid: string): string =>
membersHook.data.find((m) => m.uuid === uuid)?.name ??
(uuid === personalDataUuid ? (parentName ?? t('calendar.you')) : t('calendar.partner'))
const showToast = useCallback((message: string, undo: CalendarToast['undo']) => {
setToast({ message, undo })
}, [])
@@ -836,6 +856,72 @@ export function Calendar(): ReactNode {
})
}
/** §"removing a single day should stay permanent" — the planner-placed
* counterpart of `deleteAdhocRange`. An auto-placed day cannot simply be
* deleted: the wish behind it still asks for the whole window, so the next
* sync would re-derive it. Recording a `PlannedDayExclusion` is what makes
* the removal outlive the sync.
*
* Keyed on the band's own parent, so a both-parents period loses only the
* band that was clicked and the partner keeps their day — the toast names
* the parent so that choice is never a silent one. Undo deletes the
* exclusion and re-syncs, which puts the day back where it was.
*/
const excludeDay = async (
workspaceId: string,
band: BandClickInfo,
parentLabel: string,
) => {
const childId =
(plannedDaysHook.data ?? []).find(
(pd) => pd.date === band.clickedDate && pd.parentPersonalDataUuid === band.parentPersonalDataUuid,
)?.childId ?? selectedChildId
if (!childId) {
closePeriodPopover()
return
}
const created = await exclusionsHook.exclude({
workspaceId,
childId,
parentPersonalDataUuid: band.parentPersonalDataUuid,
date: band.clickedDate,
})
closePeriodPopover()
await runSync(workspaceId, { silent: true })
const dateLabel = formatDateForDisplay(band.clickedDate, locale)
showToast(t('calendar.toastDayExcluded', { date: dateLabel, parent: parentLabel }), {
label: t('calendar.undoButton'),
run: async () => {
await exclusionsHook.restore(created.id)
await runSync(workspaceId, { silent: true })
showToast(t('calendar.toastDayRestored', { date: dateLabel, parent: parentLabel }), null)
},
})
}
/** The day cell's way back, for when the undo toast is long gone: the day
* popover lists this date's removals (one per parent) with a restore
* button. See `excludeDay` for the other direction. */
const handleRestoreExcluded = async (exclusionId: string) => {
if (!workspace?.id) return
const entry = exclusionsHook.data.find((e) => e.id === exclusionId)
await exclusionsHook.restore(exclusionId)
setIsModalOpen(false)
setSelectedDates(new Set())
await runSync(workspace.id, { silent: true })
if (entry) {
showToast(
t('calendar.toastDayRestored', {
date: formatDateForDisplay(entry.date, locale),
parent: resolveParentName(entry.parentPersonalDataUuid),
}),
null,
)
}
}
const handleDeletePeriod = async (scope: PeriodDeleteScope) => {
if (!workspace?.id || !activeBand) return
@@ -851,11 +937,20 @@ export function Calendar(): ReactNode {
if (scope === 'day') {
if (activeBand.wishId) {
// Disabled in the UI (would just reappear on the next sync).
closePeriodPopover()
// A planner-placed day: deleting the row would not survive the next
// sync, so record the removal instead.
await excludeDay(
workspace.id,
activeBand,
resolveParentName(activeBand.parentPersonalDataUuid),
)
return
}
await deleteAdhocRange(activeBand.startDate, activeBand.startDate, activeBand.parentPersonalDataUuid)
await deleteAdhocRange(
activeBand.clickedDate,
activeBand.clickedDate,
activeBand.parentPersonalDataUuid,
)
return
}
@@ -1100,6 +1195,7 @@ export function Calendar(): ReactNode {
onPointerDown={handlePointerDown}
onPointerEnter={handlePointerEnter}
calendarDaysByDate={calendarDaysByDate}
excludedDates={excludedDates}
onBandClick={handleBandClick}
flashWishId={flashWishId}
monthMaxLaneCount={monthMaxLaneCount}
@@ -1156,6 +1252,10 @@ export function Calendar(): ReactNode {
onSaveWish={handleSaveWish}
onSavePinned={handleSavePinnedDay}
onDelete={getExistingDay(firstSelectedDate, advancedParentId) ? handleDeletePlannedDay : undefined}
excludedEntries={exclusionsHook.data
.filter((e) => e.date === firstSelectedDate)
.map((e) => ({ id: e.id, parentName: resolveParentName(e.parentPersonalDataUuid) }))}
onRestoreExcluded={handleRestoreExcluded}
onClose={() => {
setIsModalOpen(false)
setSelectedDates(new Set())
@@ -1174,6 +1274,8 @@ export function Calendar(): ReactNode {
wish={activeWish}
startDate={activeBand.startDate}
endDate={activeBand.endDate}
removeDayLabel={formatDateForDisplay(activeBand.clickedDate, locale)}
removeDayParentName={resolveParentName(activeBand.parentPersonalDataUuid)}
parents={parentsForModals}
isSaving={isPeriodSaving}
error={periodError}
+19
View File
@@ -273,8 +273,18 @@
* glyph and the dubbeldag "2×" chip on a band label (`.calendar-event__badge`
* below). Each keeps its own position/color/background, since those differ
* by what it's marking. */
/* A day the user removed from the plan by hand (see PlannedDayExclusion).
* Marked, not hidden: an empty day in the middle of an otherwise-funded week
* would otherwise read as a planner bug. */
.calendar-day--excluded {
border-style: dashed;
border-width: 2px;
border-color: var(--c-danger);
}
.calendar-day__type-badge,
.calendar-day__override-badge,
.calendar-day__excluded-badge,
.calendar-event__badge {
font-size: var(--fs-4xs);
font-weight: 700;
@@ -297,6 +307,15 @@
left: 4px;
}
/* Sits below the override glyph rather than beside it: a date can carry both
* (an employer klämdag the user also removed from the plan). */
.calendar-day__excluded-badge {
position: absolute;
bottom: 4px;
left: 4px;
color: var(--c-danger);
}
.calendar-day__events {
display: flex;
flex-wrap: wrap;
+79
View File
@@ -574,6 +574,85 @@ describe('API response mappers (snake_case → camelCase)', () => {
expect(result[0]!.isWorkingDay).toBe(false)
})
})
describe('planned-day exclusions', () => {
it('posts snake_case and maps the created exclusion back to camelCase', async () => {
fetchMock.mockResolvedValue({
ok: true,
status: 201,
json: async () => ({
id: 'ex-1',
workspace_id: 'ws1',
child_id: 'child-1',
parent_personal_data_uuid: 'parent-1',
date: '2027-02-03',
created_at: '2027-01-01T00:00:00',
updated_at: '2027-01-01T00:00:00',
}),
})
const result = await api.createPlannedDayExclusion({
workspaceId: 'ws1',
childId: 'child-1',
parentPersonalDataUuid: 'parent-1',
date: '2027-02-03',
})
const [url, options] = fetchMock.mock.calls[0] as [string, RequestInit]
expect(url).toContain('/planned-day-exclusions')
expect(options.method).toBe('POST')
expect(JSON.parse(options.body as string)).toEqual({
workspace_id: 'ws1',
child_id: 'child-1',
parent_personal_data_uuid: 'parent-1',
date: '2027-02-03',
})
expect(result).toEqual({
id: 'ex-1',
workspaceId: 'ws1',
childId: 'child-1',
parentPersonalDataUuid: 'parent-1',
date: '2027-02-03',
createdAt: '2027-01-01T00:00:00',
updatedAt: '2027-01-01T00:00:00',
})
})
it('maps a listed exclusion to camelCase', async () => {
fetchMock.mockResolvedValue({
ok: true,
status: 200,
json: async () => [
{
id: 'ex-2',
workspace_id: 'ws1',
child_id: 'child-1',
parent_personal_data_uuid: 'parent-2',
date: '2027-02-04',
created_at: null,
updated_at: null,
},
],
})
const result = await api.listPlannedDayExclusionsByWorkspace('ws1')
const [url] = fetchMock.mock.calls[0] as [string, RequestInit]
expect(url).toContain('/planned-day-exclusions/workspace/ws1')
expect(result[0]!.parentPersonalDataUuid).toBe('parent-2')
expect(result[0]!.createdAt).toBeNull()
})
it('deletes an exclusion by id (the way back)', async () => {
fetchMock.mockResolvedValue({ ok: true, status: 204, json: async () => null })
await api.deletePlannedDayExclusion('ex-1')
const [url, options] = fetchMock.mock.calls[0] as [string, RequestInit]
expect(url).toContain('/planned-day-exclusions/ex-1')
expect(options.method).toBe('DELETE')
})
})
})
describe('apiFetch (shared request/response handling)', () => {
+57
View File
@@ -517,6 +517,30 @@ function toDayOverride(raw: RawDayOverride): import('@/types').DayOverride {
}
}
interface RawPlannedDayExclusion {
id: string
workspace_id: string
child_id: string
parent_personal_data_uuid: string
date: string
created_at: string | null
updated_at: string | null
}
function toPlannedDayExclusion(
raw: RawPlannedDayExclusion,
): import('@/types').PlannedDayExclusion {
return {
id: raw.id,
workspaceId: raw.workspace_id,
childId: raw.child_id,
parentPersonalDataUuid: raw.parent_personal_data_uuid,
date: raw.date,
createdAt: raw.created_at,
updatedAt: raw.updated_at,
}
}
let _accessToken: string | null = null
export function setAccessToken(token: string | null): void {
@@ -1169,6 +1193,39 @@ export async function deleteDayOverride(id: string): Promise<void> {
return apiFetch(`/day-overrides/${id}`, { method: 'DELETE' })
}
// ---------------------------------------------------------------------------
// Planned-day exclusions — the days the user removed by hand and the planner
// must therefore leave empty on every subsequent sync.
// ---------------------------------------------------------------------------
export async function createPlannedDayExclusion(
req: import('@/types').PlannedDayExclusionCreateRequest,
): Promise<import('@/types').PlannedDayExclusion> {
const raw = (await apiFetch('/planned-day-exclusions', {
method: 'POST',
body: JSON.stringify({
workspace_id: req.workspaceId,
child_id: req.childId,
parent_personal_data_uuid: req.parentPersonalDataUuid,
date: req.date,
}),
})) as RawPlannedDayExclusion
return toPlannedDayExclusion(raw)
}
export async function listPlannedDayExclusionsByWorkspace(
workspaceId: string,
): Promise<import('@/types').PlannedDayExclusion[]> {
const raw = (await apiFetch(
`/planned-day-exclusions/workspace/${workspaceId}`,
)) as RawPlannedDayExclusion[]
return raw.map(toPlannedDayExclusion)
}
export async function deletePlannedDayExclusion(id: string): Promise<void> {
return apiFetch(`/planned-day-exclusions/${id}`, { method: 'DELETE' })
}
// ---------------------------------------------------------------------------
// Calendar (röda dagar / klämdagar) — GET /api/calendar/days, backs §3.4
// (Kalender shading), §3.6 (click-to-override) and §3.8 (Settings red-days
+7
View File
@@ -196,6 +196,13 @@ export interface PlanSyncResponse {
}
// Day overrides (calendar exceptions)
export interface PlannedDayExclusionCreateRequest {
workspaceId: string
childId: string
parentPersonalDataUuid: string
date: string // ISO-8601 date
}
export interface DayOverrideCreateRequest {
workspaceId: string
date: string // ISO-8601 date
+16
View File
@@ -179,6 +179,22 @@ export interface DayOverride {
updatedAt: string | null
}
// One date the planner may not place leave on, for one parent and one child
// — POST /api/planned-day-exclusions. Written when the user removes a single
// day out of a planner-placed period ("this day" in the calendar's three-way
// remove); without it the next plan sync would simply re-derive the day from
// the wish. Deliberately separate from `DayOverride`, which answers the
// unrelated *calendar* question "is this a working day for this workspace".
export interface PlannedDayExclusion {
id: string
workspaceId: string
childId: string
parentPersonalDataUuid: string
date: string // ISO-8601 date
createdAt: string | null
updatedAt: string | null
}
// Calendar metadata (röda dagar / klämdagar) — GET /api/calendar/days,
// backing §3.4 (Kalender shading), §3.6 (click-to-override) and §3.8
// (Settings red-days list). Named `CalendarDayEntry` (not `CalendarDay`) to
+14
View File
@@ -18,6 +18,7 @@ import type { usePlannedDays } from '@/hooks/usePlannedDays'
import type { useScraperFetch } from '@/hooks/useScraperFetch'
import type { useWorkspaceMembers } from '@/hooks/useWorkspaceMembers'
import type { useOptimizationTips } from '@/hooks/useOptimizationTips'
import type { usePlannedDayExclusions } from '@/hooks/usePlannedDayExclusions'
import type { useDayOverrides } from '@/hooks/useDayOverrides'
import type { useLeaveWishes } from '@/hooks/useLeaveWishes'
import type { usePlanProposal } from '@/hooks/usePlanProposal'
@@ -172,6 +173,19 @@ export function makeDayOverridesResult(
}
}
export function makePlannedDayExclusionsResult(
overrides: Partial<ReturnType<typeof usePlannedDayExclusions>> = {},
): ReturnType<typeof usePlannedDayExclusions> {
return {
data: [],
isLoading: false,
error: null,
exclude: vi.fn(),
restore: vi.fn(),
...overrides,
}
}
export function makeLeaveWishesResult(
overrides: Partial<ReturnType<typeof useLeaveWishes>> = {},
): ReturnType<typeof useLeaveWishes> {
+16
View File
@@ -19,6 +19,7 @@ import type {
Tip,
VacationDays,
Workspace,
PlannedDayExclusion,
} from '@/types'
// ---------------------------------------------------------------------------
@@ -195,6 +196,21 @@ export function makeDayOverride(overrides: Partial<DayOverride> = {}): DayOverri
}
}
export function makePlannedDayExclusion(
overrides: Partial<PlannedDayExclusion> = {},
): PlannedDayExclusion {
return {
id: 'ex-1',
workspaceId: 'ws-1',
childId: 'child-1',
parentPersonalDataUuid: 'parent-1',
date: '2027-02-03',
createdAt: '2027-01-01T00:00:00',
updatedAt: '2027-01-01T00:00:00',
...overrides,
}
}
// ---------------------------------------------------------------------------
// Tips
// ---------------------------------------------------------------------------