diff --git a/backend/app/dependencies.py b/backend/app/dependencies.py index b7a12c2..0868df6 100644 --- a/backend/app/dependencies.py +++ b/backend/app/dependencies.py @@ -210,8 +210,11 @@ def get_vacation_days_service(repo: VacationDaysRepositoryDep) -> VacationDaysSe VacationDaysServiceDep = Annotated[VacationDaysService, Depends(get_vacation_days_service)] -def get_leave_wish_service(repo: LeaveWishRepositoryDep) -> LeaveWishService: - return LeaveWishService(repo) +def get_leave_wish_service( + repo: LeaveWishRepositoryDep, + planned_leave_repo: PlannedLeaveRepositoryDep, +) -> LeaveWishService: + return LeaveWishService(repo, planned_leave_repo) LeaveWishServiceDep = Annotated[LeaveWishService, Depends(get_leave_wish_service)] diff --git a/backend/app/repositories/planned_leave_repository.py b/backend/app/repositories/planned_leave_repository.py index 54744ca..f11747f 100644 --- a/backend/app/repositories/planned_leave_repository.py +++ b/backend/app/repositories/planned_leave_repository.py @@ -33,3 +33,13 @@ class PlannedLeaveRepository(Repository[PlannedLeaveDay]): @abstractmethod async def count_dubbeldagar(self, child_id: UUID, from_date: date | None = None) -> int: """Count dubbeldagar already planned for a child (after from_date).""" + + @abstractmethod + async def delete_by_wish(self, wish_id: UUID) -> int: + """Delete every row attributed to this wish (pinned or not) and return the count deleted. + + See `LeaveWishService.delete()`: called before the `leave_wish` row is + removed so the FK's `ON DELETE SET NULL` never fires — by the time + that DELETE runs, no `planned_leave_day` row still references this + wish, so there is nothing left to orphan. + """ diff --git a/backend/app/repositories_impl/sqlite/planned_leave_repository.py b/backend/app/repositories_impl/sqlite/planned_leave_repository.py index c902f59..faf715c 100644 --- a/backend/app/repositories_impl/sqlite/planned_leave_repository.py +++ b/backend/app/repositories_impl/sqlite/planned_leave_repository.py @@ -172,3 +172,10 @@ class SQLitePlannedLeaveRepository(PlannedLeaveRepository): ) row = await cursor.fetchone() return int(row[0]) if row else 0 + + async def delete_by_wish(self, wish_id: UUID) -> int: + cursor = await self._conn.execute( + "DELETE FROM planned_leave_day WHERE wish_id = ?", (str(wish_id),) + ) + await self._conn.commit() + return cursor.rowcount diff --git a/backend/app/services/leave_wish_service.py b/backend/app/services/leave_wish_service.py index eac86bf..919f02b 100644 --- a/backend/app/services/leave_wish_service.py +++ b/backend/app/services/leave_wish_service.py @@ -10,6 +10,7 @@ from uuid import UUID, uuid4 from app.entities.leave_wish import LeaveWish from app.repositories.leave_wish_repository import LeaveWishRepository +from app.repositories.planned_leave_repository import PlannedLeaveRepository from app.utils.errors import NotFoundError, ValidationError _VALID_RECURRENCE = {"yearly", "once"} @@ -21,8 +22,13 @@ _LEAP_REFERENCE_YEAR = 2028 class LeaveWishService: - def __init__(self, repo: LeaveWishRepository) -> None: + def __init__( + self, + repo: LeaveWishRepository, + planned_leave_repo: PlannedLeaveRepository, + ) -> None: self._repo = repo + self._planned_leave_repo = planned_leave_repo async def create( self, @@ -197,7 +203,29 @@ class LeaveWishService: return await self._repo.update(leave_wish_id, entity) async def delete(self, leave_wish_id: UUID) -> None: + """Delete a wish and every `planned_leave_day` row it produced. + + Under the calendar-direct-manipulation UX a period *is* its wish: the + band the user drew, the days the allocator filled it with, and any + of those days the user later hand-edited are all one thing. Deleting + the period must remove all of it — including pinned days. + `wish_id` is nullable and `ON DELETE SET NULL` at the schema level + (so a row can still exist detached/ad-hoc), but that "orphan, don't + delete" behavior is wrong for this call path: the rows are deleted + explicitly, *before* the wish row goes, so the FK's SET NULL never + gets a row left to act on. See `PlannedLeaveRepository.delete_by_wish`. + + A pinned day is deleted too rather than surviving as ad-hoc. Pinning + only protects a day from being silently retyped/reassigned by a + later `sync_plan` re-plan (see that method's docstring) — it is not + a signal that the day should outlive the period a user explicitly + chose to delete. This also matches the ad-hoc-period delete path + (`Calendar.handleDeletePeriod`'s no-`wishId` branch), which already + deletes every day in the band regardless of `is_pinned` with no + special-casing. + """ await self.get(leave_wish_id) # raises NotFoundError if missing + await self._planned_leave_repo.delete_by_wish(leave_wish_id) await self._repo.delete(leave_wish_id) diff --git a/backend/scripts/seed_demo.py b/backend/scripts/seed_demo.py index a6fb7de..6d44967 100644 --- a/backend/scripts/seed_demo.py +++ b/backend/scripts/seed_demo.py @@ -135,7 +135,7 @@ def _build_services(conn: aiosqlite.Connection) -> DemoServices: child=ChildService(children), vacation=VacationDaysService(vacation_repo), day_override=DayOverrideService(day_overrides), - leave_wish=LeaveWishService(leave_wishes), + leave_wish=LeaveWishService(leave_wishes, planned_leave), manual_fetch=ManualFetchService(parental_leave), planning=PlanningService(planned_leave, analytics, parental_leave, vacation_repo), users=users, diff --git a/backend/tests/conftest.py b/backend/tests/conftest.py index 93092bd..d8c67e7 100644 --- a/backend/tests/conftest.py +++ b/backend/tests/conftest.py @@ -222,8 +222,9 @@ async def leave_wish_repo( @pytest_asyncio.fixture async def leave_wish_service( leave_wish_repo: SQLiteLeaveWishRepository, + planned_leave_repo: SQLitePlannedLeaveRepository, ) -> LeaveWishService: - return LeaveWishService(leave_wish_repo) + return LeaveWishService(leave_wish_repo, planned_leave_repo) # --------------------------------------------------------------------------- diff --git a/backend/tests/unit/test_leave_wish_repository.py b/backend/tests/unit/test_leave_wish_repository.py index 7f4868a..01927b1 100644 --- a/backend/tests/unit/test_leave_wish_repository.py +++ b/backend/tests/unit/test_leave_wish_repository.py @@ -5,13 +5,15 @@ Aim for >=90% coverage on new code (ARCHITECTURE.md §10). """ from datetime import date, datetime -from uuid import uuid4 +from uuid import UUID, uuid4 import pytest from app.entities.leave_wish import LeaveWish +from app.entities.planned_leave import PlannedLeaveDay from app.entities.workspace import Workspace from app.repositories_impl.sqlite.leave_wish_repository import SQLiteLeaveWishRepository +from app.repositories_impl.sqlite.planned_leave_repository import SQLitePlannedLeaveRepository from app.repositories_impl.sqlite.workspace_repository import SQLiteWorkspaceRepository from app.services.leave_wish_service import LeaveWishService from app.utils.errors import NotFoundError, ValidationError @@ -1091,3 +1093,121 @@ class TestLeaveWishService: ) -> None: with pytest.raises(NotFoundError): await leave_wish_service.delete(uuid4()) + + +class TestLeaveWishServiceDeleteCascade: + """`LeaveWishService.delete()` must remove the `planned_leave_day` rows + + the wish produced, not just null their `wish_id` out from under them + (the schema's `ON DELETE SET NULL` would otherwise leave them behind as + permanent, unattributable ad-hoc rows — see the docstring on + `LeaveWishService.delete()` and `PlannedLeaveRepository.delete_by_wish`). + """ + + def _make_day( + self, + seeded_ids: SeededIds, + *, + wish_id: UUID | None = None, + is_pinned: bool = False, + day: str = "2027-02-03", + ) -> PlannedLeaveDay: + return PlannedLeaveDay( + id=uuid4(), + workspace_id=seeded_ids.workspace_id, + child_id=seeded_ids.child_id, + parent_personal_data_uuid=seeded_ids.personal_data_uuid, + date=date.fromisoformat(day), + day_type="sjukpenning", + is_dubbeldag=False, + wish_id=wish_id, + is_pinned=is_pinned, + created_at=datetime.now(), + updated_at=datetime.now(), + ) + + async def test_delete_removes_the_planned_leave_days_it_produced( + self, + leave_wish_service: LeaveWishService, + leave_wish_repo: SQLiteLeaveWishRepository, + planned_leave_repo: SQLitePlannedLeaveRepository, + seeded_ids: SeededIds, + ) -> None: + wish = await leave_wish_repo.create(_make(workspace_id=seeded_ids.workspace_id)) + day1 = await planned_leave_repo.create( + self._make_day(seeded_ids, wish_id=wish.id, day="2027-02-03") + ) + day2 = await planned_leave_repo.create( + self._make_day(seeded_ids, wish_id=wish.id, day="2027-02-04") + ) + + await leave_wish_service.delete(wish.id) + + assert await planned_leave_repo.get_by_id(day1.id) is None + assert await planned_leave_repo.get_by_id(day2.id) is None + + async def test_delete_removes_a_pinned_day_too( + self, + leave_wish_service: LeaveWishService, + leave_wish_repo: SQLiteLeaveWishRepository, + planned_leave_repo: SQLitePlannedLeaveRepository, + seeded_ids: SeededIds, + ) -> None: + """A pinned day dies with its wish rather than surviving as ad-hoc. + + Pinning only protects a day from being silently retyped/reassigned + by a later `sync_plan` re-plan — it is not a signal that the day + should outlive a period the user explicitly chose to delete. This + also matches the ad-hoc-period delete path in `Calendar.tsx` + (`handleDeletePeriod`'s no-`wishId` branch), which already deletes + every day in the band regardless of `is_pinned`. + """ + wish = await leave_wish_repo.create(_make(workspace_id=seeded_ids.workspace_id)) + pinned = await planned_leave_repo.create( + self._make_day(seeded_ids, wish_id=wish.id, is_pinned=True) + ) + + await leave_wish_service.delete(wish.id) + + assert await planned_leave_repo.get_by_id(pinned.id) is None + + async def test_delete_leaves_a_different_wishs_days_untouched( + self, + leave_wish_service: LeaveWishService, + leave_wish_repo: SQLiteLeaveWishRepository, + planned_leave_repo: SQLitePlannedLeaveRepository, + seeded_ids: SeededIds, + ) -> None: + wish_a = await leave_wish_repo.create(_make(workspace_id=seeded_ids.workspace_id, name="A")) + wish_b = await leave_wish_repo.create(_make(workspace_id=seeded_ids.workspace_id, name="B")) + day_a = await planned_leave_repo.create( + self._make_day(seeded_ids, wish_id=wish_a.id, day="2027-02-03") + ) + day_b = await planned_leave_repo.create( + self._make_day(seeded_ids, wish_id=wish_b.id, day="2027-02-04") + ) + + await leave_wish_service.delete(wish_a.id) + + assert await planned_leave_repo.get_by_id(day_a.id) is None + survivor = await planned_leave_repo.get_by_id(day_b.id) + assert survivor is not None + assert survivor.wish_id == wish_b.id + + async def test_delete_leaves_genuine_ad_hoc_days_untouched( + self, + leave_wish_service: LeaveWishService, + leave_wish_repo: SQLiteLeaveWishRepository, + planned_leave_repo: SQLitePlannedLeaveRepository, + seeded_ids: SeededIds, + ) -> None: + wish = await leave_wish_repo.create(_make(workspace_id=seeded_ids.workspace_id)) + ad_hoc = await planned_leave_repo.create( + self._make_day(seeded_ids, wish_id=None, day="2027-02-03") + ) + + await leave_wish_service.delete(wish.id) + + survivor = await planned_leave_repo.get_by_id(ad_hoc.id) + assert survivor is not None + assert survivor.wish_id is None diff --git a/frontend/src/hooks/usePlannedDays.ts b/frontend/src/hooks/usePlannedDays.ts index 430759d..e4fa1f1 100644 --- a/frontend/src/hooks/usePlannedDays.ts +++ b/frontend/src/hooks/usePlannedDays.ts @@ -6,7 +6,11 @@ interface UsePlannedDaysResult { data: PlannedLeaveDay[] | null isLoading: boolean error: string | null - refresh: () => Promise + /** Refetches and returns the fresh list — for a caller that needs the + * just-fetched data synchronously rather than `data`, which is whatever + * this hook's `data` was at the closure's own render, frozen for that + * closure's lifetime, not a live view. */ + refresh: () => Promise add: (payload: api.CreatePlannedDayPayload) => Promise update: (id: string, payload: api.UpdatePlannedDayPayload) => Promise remove: (id: string) => Promise @@ -24,16 +28,18 @@ export function usePlannedDays(childId?: string): UsePlannedDaysResult { // planned days rendered until a new childId arrives. setData(null) setError(null) - return + return null } setIsLoading(true) setError(null) try { const response = await api.getPlannedDays(childId) setData(response) + return response } catch (err) { setError(err instanceof Error ? err.message : 'Failed to fetch planned days') setData(null) + return null } finally { setIsLoading(false) } diff --git a/frontend/src/pages/Calendar.test.tsx b/frontend/src/pages/Calendar.test.tsx index eed8720..51ec5a6 100644 --- a/frontend/src/pages/Calendar.test.tsx +++ b/frontend/src/pages/Calendar.test.tsx @@ -847,6 +847,51 @@ describe('Calendar', () => { expect(toast).toHaveTextContent('10') }) + it('reports a calendar-drawn period\'s shortfall in days, not weeks, since it carries an exact targetDays', async () => { + // Regression: a drag-created wish sets `targetDays` (an exact day + // count); `targetWeeks` on that same wish is just a rough + // `Math.ceil(days / 7)` leftover the API still requires — never what + // the user actually asked for. The toast must report the days + // figure, not `weeksWished`, once `targetDays` is set. `weeksWished` + // is deliberately set to a nonsense value here (3) to prove the toast + // isn't reading it at all, not just that it happens to agree. + const created = makeLeaveWish({ id: 'lw-new', name: 'Ny period', targetDays: 7, targetWeeks: 1 }) + const add = vi.fn().mockResolvedValue(created) + const sync = vi.fn().mockResolvedValue( + makePlanSyncResponse({ + changes: [], + proposal: makePlanProposal({ + outcomes: [ + makeWindowOutcome({ + wishId: 'lw-new', + startDate: '2026-09-17', + endDate: '2026-09-23', + weeksWished: 3, + weeksFunded: 0, + shortfallDays: 5, + data: { wish_name: 'Ny period', wish_name_message_key: null }, + }), + ], + }), + }), + ) + vi.mocked(useLeaveWishes).mockReturnValue(makeLeaveWishesResult({ add, data: [] })) + vi.mocked(usePlanSync).mockReturnValue(makePlanSyncResult({ sync })) + + render() + const calendarDays = screen.getAllByTestId('calendar-day') + const currentMonthDays = calendarDays.filter((el) => !el.classList.contains('calendar-day--disabled')) + fireEvent.pointerDown(currentMonthDays[0] as HTMLElement) + fireEvent.pointerEnter(currentMonthDays[6] as HTMLElement) + fireEvent.pointerUp(window) + + const toast = await screen.findByTestId('calendar-toast') + expect(toast).toHaveTextContent('7') + expect(toast).toHaveTextContent('dagarna') + expect(toast).not.toHaveTextContent('3 vecko') + expect(toast).not.toHaveTextContent('veckorna') + }) + it('shows no toast when a sync with no changes also has no outcome for the touched wish (genuine no-op)', async () => { const created = makeLeaveWish({ id: 'lw-new' }) const add = vi.fn().mockResolvedValue(created) @@ -999,7 +1044,7 @@ describe('Calendar', () => { expect(screen.queryByTestId('calendar-toast')).not.toBeInTheDocument() }) - it('undo after creating a period from a drag removes the wish and re-syncs', async () => { + it('undo after creating a period from a drag deletes the wish and re-syncs, leaving day cleanup to the backend', async () => { const created = makeLeaveWish({ id: 'lw-new' }) const add = vi.fn().mockResolvedValue(created) const remove = vi.fn().mockResolvedValue(undefined) @@ -1007,6 +1052,14 @@ describe('Calendar', () => { vi.mocked(useLeaveWishes).mockReturnValue(makeLeaveWishesResult({ add, remove, data: [created] })) vi.mocked(usePlanSync).mockReturnValue(makePlanSyncResult({ sync })) + // `LeaveWishService.delete()` deletes every `planned_leave_day` row + // the wish produced as part of deleting the wish itself — the + // frontend no longer snapshots or deletes days individually. The + // outcome under test is therefore the delegation: undo removes the + // wish and re-syncs, and never touches `plannedDaysHook.remove` at all. + const removeDay = vi.fn().mockResolvedValue(undefined) + vi.mocked(usePlannedDays).mockReturnValue(makePlannedDaysResult({ remove: removeDay })) + render() const calendarDays = screen.getAllByTestId('calendar-day') const currentMonthDays = calendarDays.filter((el) => !el.classList.contains('calendar-day--disabled')) @@ -1020,9 +1073,10 @@ describe('Calendar', () => { await waitFor(() => expect(remove).toHaveBeenCalledWith('lw-new')) await waitFor(() => expect(sync.mock.calls.length).toBeGreaterThan(syncCallsBeforeUndo)) + expect(removeDay).not.toHaveBeenCalled() }) - it('undo after selecting a preset removes the wish and re-syncs', async () => { + it('undo after selecting a preset deletes the wish and re-syncs, leaving day cleanup to the backend', async () => { const created = makeLeaveWish({ id: 'lw-preset' }) const add = vi.fn().mockResolvedValue(created) const remove = vi.fn().mockResolvedValue(undefined) @@ -1030,6 +1084,9 @@ describe('Calendar', () => { vi.mocked(useLeaveWishes).mockReturnValue(makeLeaveWishesResult({ add, remove, data: [created] })) vi.mocked(usePlanSync).mockReturnValue(makePlanSyncResult({ sync })) + const removeDay = vi.fn().mockResolvedValue(undefined) + vi.mocked(usePlannedDays).mockReturnValue(makePlannedDaysResult({ remove: removeDay })) + render() fireEvent.click(screen.getByTestId('preset-click')) @@ -1039,6 +1096,7 @@ describe('Calendar', () => { await waitFor(() => expect(remove).toHaveBeenCalledWith('lw-preset')) await waitFor(() => expect(sync.mock.calls.length).toBeGreaterThan(syncCallsBeforeUndo)) + expect(removeDay).not.toHaveBeenCalled() }) it('undo after deleting an ad-hoc period recreates the deleted days', async () => { diff --git a/frontend/src/pages/Calendar.tsx b/frontend/src/pages/Calendar.tsx index e3bb20d..2c65bfb 100644 --- a/frontend/src/pages/Calendar.tsx +++ b/frontend/src/pages/Calendar.tsx @@ -127,16 +127,43 @@ function summarizeChanges(changes: PlanChange[], wishes: LeaveWish[], t: Transla * carrying `wish_name`/`wish_name_message_key`) — falling back to the plain * `shortfallDays` count when there's nothing richer to say. `funded` * distinguishes "nothing could be placed" from "only part of it fit", the - * two phrasings the "sync that changes nothing must say why" fix needs. */ -function summarizeOutcomeShortfall(outcome: WindowOutcome, funded: boolean, t: Translate): string { + * two phrasings the "sync that changes nothing must say why" fix needs. + * + * `wish` is the touched wish itself (not just the outcome), because the + * outcome only ever carries `weeksWished`/`weeksFunded` — the backend's + * week-rounded view. A calendar-drawn period sets `targetDays` (an exact + * day count); `targetWeeks` on that same wish is just `Math.ceil(days / 7)`, + * a leftover the API still requires, never what the user actually asked + * for. Reporting `weeksWished` for those wishes says "3 week(s)" for a + * 7-day drag. When `targetDays` is set, report days instead — the figure + * the user actually asked for — and only fall back to weeks for wishes that + * never had a day-granular target (e.g. the preset rail, which only ever + * sends `targetWeeks`). */ +function summarizeOutcomeShortfall( + outcome: WindowOutcome, + funded: boolean, + wish: LeaveWish | undefined, + t: Translate, +): string { const name = resolveWishNameFromData(outcome.data, t) || t('calendar.toastGenericPeriod') - const params = { + const base = { name, start: formatDateForDisplay(outcome.startDate), end: formatDateForDisplay(outcome.endDate), + shortfallDays: outcome.shortfallDays, + } + if (wish?.targetDays != null) { + const params = { + ...base, + daysWished: wish.targetDays, + daysFunded: wish.targetDays - outcome.shortfallDays, + } + return t(funded ? 'calendar.toastShortfallPartialDays' : 'calendar.toastShortfallNoneDays', params) + } + const params = { + ...base, weeksWished: outcome.weeksWished, weeksFunded: outcome.weeksFunded, - shortfallDays: outcome.shortfallDays, } return t(funded ? 'calendar.toastShortfallPartial' : 'calendar.toastShortfallNone', params) } @@ -144,13 +171,22 @@ function summarizeOutcomeShortfall(outcome: WindowOutcome, funded: boolean, t: T /** Builds the full sync toast message: what changed (existing summary), why * the wish just touched (if any) fell short of what was wished, and any * rejections the apply step returned — a genuine no-op sync (nothing to - * report on any of the three) still renders `null`, so it stays silent. */ + * report on any of the three) still renders `null`, so it stays silent. + * + * `watchWish`, when given, is used in place of looking `watchWishId` up in + * `wishes` — needed right after creating a wish, where `wishes` is whatever + * `wishesHook.data` was as of this closure's own render (captured before + * the create's background refetch lands) and so never actually contains a + * wish created moments ago. Passing the just-created wish object directly + * sidesteps that; every other caller (editing/re-syncing an *existing* + * wish) can leave it out and rely on the `wishes` lookup as before. */ function buildSyncToastMessage( result: PlanSyncResponse, wishes: LeaveWish[], members: PersonalData[], watchWishId: string | undefined, t: Translate, + watchWish?: LeaveWish, ): string | null { const parts: string[] = [] @@ -161,7 +197,8 @@ function buildSyncToastMessage( const outcome = result.proposal.outcomes.find((o) => o.wishId === watchWishId) if (outcome && outcome.shortfallDays > 0) { const funded = result.changes.some((c) => c.newWishId === watchWishId || c.oldWishId === watchWishId) - parts.push(summarizeOutcomeShortfall(outcome, funded, t)) + const wish = watchWish ?? wishes.find((w) => w.id === watchWishId) + parts.push(summarizeOutcomeShortfall(outcome, funded, wish, t)) } } @@ -282,6 +319,46 @@ export function Calendar(): ReactNode { void runSync(workspace.id, { silent: true }) }, [workspace?.id, runSync]) + // Runs the sync that persists a just-created wish, then reports it and + // wires up undo — split out from a plain `runSync(..., { undo })` call + // because undo here needs to pass the just-created wish's own fields + // (`created`) to `buildSyncToastMessage` rather than looking `wishId` up + // in `wishesHook.data`, which is whatever it was as of this closure's own + // render (captured before the create's background refetch lands) and so + // never actually contains a wish created moments ago. + // + // Undo itself is just "delete the wish, re-sync": the backend deletes + // every `planned_leave_day` row the wish produced as part of deleting the + // wish (`LeaveWishService.delete()`), so there is nothing left here for + // the frontend to individually track or clean up. + const runSyncAfterWishCreate = useCallback( + async (workspaceId: string, created: LeaveWish): Promise => { + const wishId = created.id + const result = await runSync(workspaceId, { silent: true }) + if (!result) return result + + const message = buildSyncToastMessage( + result, + wishesHook.data, + membersHook.data, + wishId, + t, + created, + ) + if (message) { + showToast(message, { + label: t('calendar.undoButton'), + run: async () => { + await wishesHook.remove(wishId) + await runSync(workspaceId, { silent: true }) + }, + }) + } + return result + }, + [runSync, wishesHook, membersHook.data, t, showToast], + ) + const handleCreatePeriodFromDrag = useCallback( async (dates: string[]) => { setSelectedDates(new Set()) @@ -316,23 +393,22 @@ export function Calendar(): ReactNode { parentPersonalDataUuid: personalDataUuid, windowMode: 'fixed', }) - await runSync(workspace.id, { - watchWishId: created.id, - undo: { - label: t('calendar.undoButton'), - run: async () => { - await wishesHook.remove(created.id) - await runSync(workspace.id, { silent: true }) - }, - }, - }) + await runSyncAfterWishCreate(workspace.id, created) } catch (err) { showToast(err instanceof Error ? err.message : t('calendar.periodCreateError'), null) } finally { setIsCreatingPeriod(false) } }, - [workspace?.id, selectedChildId, personalDataUuid, wishesHook, t, runSync, showToast], + [ + workspace?.id, + selectedChildId, + personalDataUuid, + wishesHook, + t, + runSyncAfterWishCreate, + showToast, + ], ) useEffect(() => { @@ -688,16 +764,7 @@ export function Calendar(): ReactNode { windowMode: 'flexible', }) - const result = await runSync(workspace.id, { - watchWishId: created.id, - undo: { - label: t('calendar.undoButton'), - run: async () => { - await wishesHook.remove(created.id) - await runSync(workspace.id, { silent: true }) - }, - }, - }) + const result = await runSyncAfterWishCreate(workspace.id, created) if (result) { const placedDates = result.changes