From ac3b6a41d76f741f29c1bf53cd397d8013253956 Mon Sep 17 00:00:00 2001 From: Jacob Hagstedt Date: Fri, 18 Sep 2026 00:23:34 +0200 Subject: [PATCH] Remove a period's days along with the period MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Deleting a period left every day it had funded behind. The link from a day back to its period was set to null the instant the period was deleted, so the days became untraceable before anything could clean them up, and the next recalculation had no way to know they no longer belonged to anything. Undo had the same fault for the same reason: the period went, the days stayed, and a reload showed them still there. The days are now removed with the period, before the link can be broken. A day pinned by hand goes too — pinning keeps the planner from quietly retyping a day, not from being deleted when you delete the period it belongs to, which is already how an unplanned band behaves. --- backend/app/dependencies.py | 7 +- .../repositories/planned_leave_repository.py | 10 ++ .../sqlite/planned_leave_repository.py | 7 + backend/app/services/leave_wish_service.py | 30 ++++- backend/scripts/seed_demo.py | 2 +- backend/tests/conftest.py | 3 +- .../tests/unit/test_leave_wish_repository.py | 122 +++++++++++++++++- frontend/src/hooks/usePlannedDays.ts | 10 +- frontend/src/pages/Calendar.test.tsx | 62 ++++++++- frontend/src/pages/Calendar.tsx | 121 +++++++++++++---- 10 files changed, 337 insertions(+), 37 deletions(-) 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