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:
@@ -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);
|
||||
|
||||
@@ -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 }
|
||||
}
|
||||
@@ -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)',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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)', () => {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
Vendored
+14
@@ -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> {
|
||||
|
||||
Vendored
+16
@@ -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
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
Reference in New Issue
Block a user