Give every year of a repeating period its own row
A repeating period was a single row that the planner expanded into one window per year at read time, so no year had anywhere to differ from the others. Editing one year rewrote them all, and removing a year in the middle had to split the wish in two. Neither survives contact with the thing this app is for: Easter moves, and the cheapest week is not the same week every year. Each year is now its own row, linked by a series id. Editing one year touches one row; editing the series re-projects the rest from it. Removing a year is one delete. The weekday snap is worked out once, when the rows are made, and stored, so a row now says what it means rather than being re-derived on every read. Recurrence stops being a stored field and becomes what it always was in practice — an instruction at create and update time; whether a period repeats is whether it belongs to a series. Migration 004 rebuilds the table and expands the existing rows, keeping each original row as its first year so the days already planned against it still point somewhere. A Feb 29 anchor recurring into a common year used to raise out of the allocator; it clamps now. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Ei7gCZzdd751uZtPzb1Pep
This commit is contained in:
+94
-106
@@ -16,6 +16,7 @@ import type {
|
||||
CalendarDayEntry,
|
||||
LeaveWish,
|
||||
LeaveWishCreateRequest,
|
||||
LeaveWishScope,
|
||||
LeaveWishUpdateRequest,
|
||||
PersonalData,
|
||||
PlanChange,
|
||||
@@ -81,8 +82,14 @@ function parseDateParam(searchParams: URLSearchParams): { month: Date; iso: stri
|
||||
* recreate — used both to "undo" a period delete (recreate the wish) and,
|
||||
* minus `workspaceId`/`childId`, as the patch that "undoes" a period edit
|
||||
* (restore every field to its pre-edit value; §Task7 "undo reverts the
|
||||
* wish edit, not the days"). */
|
||||
function createRequestFromWish(w: LeaveWish): LeaveWishCreateRequest {
|
||||
* wish edit, not the days").
|
||||
*
|
||||
* A wish row is ONE year now (see `LeaveWish.seriesId`), so this recreates
|
||||
* exactly that year as a standalone occurrence. `seriesId`, when passed,
|
||||
* re-attaches it to the series it came from instead — undoing "remove this
|
||||
* year" has to put the year back as part of the repeating wish, not as a
|
||||
* detached one-off a later "every year" edit would miss. */
|
||||
function createRequestFromWish(w: LeaveWish, seriesId?: string | null): LeaveWishCreateRequest {
|
||||
return {
|
||||
workspaceId: w.workspaceId,
|
||||
childId: w.childId,
|
||||
@@ -94,9 +101,9 @@ function createRequestFromWish(w: LeaveWish): LeaveWishCreateRequest {
|
||||
windowEndDay: w.windowEndDay,
|
||||
targetWeeks: w.targetWeeks,
|
||||
targetDays: w.targetDays ?? null,
|
||||
recurrence: w.recurrence,
|
||||
recurrence: 'once',
|
||||
firstYear: w.firstYear,
|
||||
lastYear: w.lastYear,
|
||||
seriesId: seriesId ?? null,
|
||||
priority: w.priority,
|
||||
parentMode: w.parentMode,
|
||||
parentPersonalDataUuid: w.parentPersonalDataUuid,
|
||||
@@ -105,7 +112,24 @@ function createRequestFromWish(w: LeaveWish): LeaveWishCreateRequest {
|
||||
}
|
||||
}
|
||||
|
||||
function patchFromWish(w: LeaveWish): LeaveWishUpdateRequest {
|
||||
/** The patch that restores one wish row's pre-edit fields, applied at the same
|
||||
* scope the edit used.
|
||||
*
|
||||
* `recurrence` is restored from whether the row belonged to a series, so
|
||||
* undoing "make this repeat" (or "stop repeating") really undoes it.
|
||||
* `lastYear` is series-level and only sent where the backend accepts it: when
|
||||
* the row was standalone (so the patch is what creates the series) or when the
|
||||
* undo is series-wide. */
|
||||
function patchFromWish(w: LeaveWish, scope: LeaveWishScope): LeaveWishUpdateRequest {
|
||||
const seriesWide = w.seriesId === null || scope === 'series'
|
||||
return {
|
||||
...patchFieldsFromWish(w),
|
||||
recurrence: w.seriesId !== null ? 'yearly' : 'once',
|
||||
...(seriesWide ? { lastYear: w.lastYear } : {}),
|
||||
}
|
||||
}
|
||||
|
||||
function patchFieldsFromWish(w: LeaveWish): LeaveWishUpdateRequest {
|
||||
return {
|
||||
name: w.name,
|
||||
nameMessageKey: w.nameMessageKey,
|
||||
@@ -115,9 +139,7 @@ function patchFromWish(w: LeaveWish): LeaveWishUpdateRequest {
|
||||
windowEndDay: w.windowEndDay,
|
||||
targetWeeks: w.targetWeeks,
|
||||
targetDays: w.targetDays ?? null,
|
||||
recurrence: w.recurrence,
|
||||
firstYear: w.firstYear,
|
||||
lastYear: w.lastYear,
|
||||
priority: w.priority,
|
||||
parentMode: w.parentMode,
|
||||
parentPersonalDataUuid: w.parentPersonalDataUuid,
|
||||
@@ -676,43 +698,52 @@ export function Calendar(): ReactNode {
|
||||
const start = splitISODate(payload.startDate)
|
||||
const end = splitISODate(payload.endDate)
|
||||
|
||||
// The popover now prefills from the *clicked* occurrence's real placed
|
||||
// dates (see PeriodPopover's `formStateFromWish`), so `payload.startDate`
|
||||
// may carry a later year than the wish's `firstYear` — e.g. clicking a
|
||||
// 2028 occurrence of a wish first taken in 2026. For a yearly wish, the
|
||||
// anchor year must stay put: writing `firstYear: 2028` here would
|
||||
// silently drop the 2026/2027 occurrences, and this popover has no
|
||||
// "this occurrence only" concept (yet — a later change adds that choice
|
||||
// explicitly). So only the recurring window's month/day come from the
|
||||
// edited dates; the anchor year is preserved unless recurrence is being
|
||||
// set to 'once', where there is exactly one occurrence and its year
|
||||
// *is* the edited date's year.
|
||||
const firstYear = payload.recurrence === 'yearly' ? previousWish.firstYear : start.year
|
||||
// One wish row is one year, so the edited dates ARE this row's window —
|
||||
// year included. The old shape had to keep the wish's anchor year and
|
||||
// rewrite the shared month/day pattern for every year, because writing
|
||||
// the clicked year into `firstYear` would have dropped the earlier
|
||||
// occurrences; there is nothing to preserve now.
|
||||
const patch: LeaveWishUpdateRequest = {
|
||||
name: payload.name,
|
||||
nameMessageKey: null,
|
||||
windowStartMonth: start.month,
|
||||
windowStartDay: start.day,
|
||||
windowEndMonth: end.month,
|
||||
windowEndDay: end.day,
|
||||
firstYear: start.year,
|
||||
recurrence: payload.recurrence,
|
||||
parentMode: payload.parentMode,
|
||||
parentPersonalDataUuid: payload.parentMode === 'specific' ? payload.parentPersonalDataUuid : null,
|
||||
wantsDubbeldag: payload.wantsDubbeldag,
|
||||
}
|
||||
// `lastYear` belongs to the whole series, so only send it where the
|
||||
// backend will take it: when this occurrence is becoming a series, or
|
||||
// when the user explicitly chose "every year".
|
||||
const isSeries = previousWish.seriesId !== null
|
||||
if (payload.recurrence === 'yearly' && (!isSeries || payload.scope === 'series')) {
|
||||
patch.lastYear = payload.lastYear
|
||||
}
|
||||
|
||||
try {
|
||||
setIsPeriodSaving(true)
|
||||
setPeriodError(null)
|
||||
await wishesHook.update(previousWish.id, {
|
||||
name: payload.name,
|
||||
nameMessageKey: null,
|
||||
windowStartMonth: start.month,
|
||||
windowStartDay: start.day,
|
||||
windowEndMonth: end.month,
|
||||
windowEndDay: end.day,
|
||||
firstYear,
|
||||
recurrence: payload.recurrence,
|
||||
lastYear: payload.recurrence === 'yearly' ? payload.lastYear : null,
|
||||
parentMode: payload.parentMode,
|
||||
parentPersonalDataUuid: payload.parentMode === 'specific' ? payload.parentPersonalDataUuid : null,
|
||||
wantsDubbeldag: payload.wantsDubbeldag,
|
||||
})
|
||||
await wishesHook.update(previousWish.id, patch, payload.scope)
|
||||
closePeriodPopover()
|
||||
await runSync(workspace.id, {
|
||||
watchWishId: previousWish.id,
|
||||
undo: {
|
||||
label: t('calendar.undoButton'),
|
||||
run: async () => {
|
||||
await wishesHook.update(previousWish.id, patchFromWish(previousWish))
|
||||
// Restores this row's own fields at the same scope the edit used.
|
||||
// A series-wide edit's undo re-projects the siblings from these
|
||||
// values, which is as faithful as the shape allows: per-year
|
||||
// divergence the user had before an "every year" save is gone
|
||||
// once that save lands.
|
||||
await wishesHook.update(
|
||||
previousWish.id,
|
||||
patchFromWish(previousWish, payload.scope),
|
||||
payload.scope,
|
||||
)
|
||||
await runSync(workspace.id, { silent: true })
|
||||
},
|
||||
},
|
||||
@@ -724,19 +755,29 @@ export function Calendar(): ReactNode {
|
||||
}
|
||||
}
|
||||
|
||||
// §todo9 — the whole-wish delete (unchanged behaviour), factored out so
|
||||
// both the "every year" scope and the "narrow down to nothing" edges of
|
||||
// the "this year" scope (below) can fall back to it.
|
||||
const deleteWholeWish = async (wishId: string, workspaceId: string) => {
|
||||
// §todo9 "this period, every year" — removes every row of the series (or
|
||||
// just the one row, when the period does not repeat). Undo recreates the
|
||||
// repeating wish from the clicked occurrence's fields; the rows get fresh
|
||||
// ids, exactly as before.
|
||||
const deleteWishSeries = async (wishId: string, workspaceId: string) => {
|
||||
const previousWish = wishesHook.data.find((w) => w.id === wishId)
|
||||
await wishesHook.remove(wishId)
|
||||
const siblingYears = previousWish?.seriesId
|
||||
? wishesHook.data.filter((w) => w.seriesId === previousWish.seriesId).map((w) => w.firstYear)
|
||||
: []
|
||||
await wishesHook.remove(wishId, 'series')
|
||||
closePeriodPopover()
|
||||
await runSync(workspaceId, {
|
||||
undo: previousWish
|
||||
? {
|
||||
label: t('calendar.undoButton'),
|
||||
run: async () => {
|
||||
await wishesHook.add(createRequestFromWish(previousWish))
|
||||
const restore = createRequestFromWish(previousWish)
|
||||
if (siblingYears.length > 0) {
|
||||
restore.recurrence = 'yearly'
|
||||
restore.firstYear = Math.min(...siblingYears)
|
||||
restore.lastYear = previousWish.lastYear ?? Math.max(...siblingYears)
|
||||
}
|
||||
await wishesHook.add(restore)
|
||||
await runSync(workspaceId, { silent: true })
|
||||
},
|
||||
}
|
||||
@@ -776,74 +817,19 @@ export function Calendar(): ReactNode {
|
||||
}
|
||||
}
|
||||
|
||||
// §todo9 "this period" (this year only) on a recurring wish — the option
|
||||
// with no existing mechanism. Narrows `firstYear`/`lastYear` to exclude
|
||||
// just the clicked occurrence's year, or splits the wish in two around a
|
||||
// middle year, using only fields the wish already has (no backend
|
||||
// change). Degenerates to the whole-wish delete when narrowing would
|
||||
// leave no valid year. A `recurrence: 'once'` wish has exactly one
|
||||
// occurrence, so "this year" and "every year" coincide — handled by the
|
||||
// same fallback.
|
||||
const narrowWishByOneYear = async (wish: LeaveWish, clickedYear: number, workspaceId: string) => {
|
||||
if (wish.recurrence === 'once') {
|
||||
await deleteWholeWish(wish.id, workspaceId)
|
||||
return
|
||||
}
|
||||
|
||||
const first = wish.firstYear
|
||||
const last = wish.lastYear
|
||||
|
||||
const finishWithUpdate = async (patch: LeaveWishUpdateRequest) => {
|
||||
await wishesHook.update(wish.id, patch)
|
||||
closePeriodPopover()
|
||||
await runSync(workspaceId, {
|
||||
watchWishId: wish.id,
|
||||
undo: {
|
||||
label: t('calendar.undoButton'),
|
||||
run: async () => {
|
||||
await wishesHook.update(wish.id, patchFromWish(wish))
|
||||
await runSync(workspaceId, { silent: true })
|
||||
},
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
if (clickedYear === first) {
|
||||
const newFirstYear = first + 1
|
||||
if (last !== null && newFirstYear > last) {
|
||||
await deleteWholeWish(wish.id, workspaceId)
|
||||
return
|
||||
}
|
||||
await finishWithUpdate({ firstYear: newFirstYear })
|
||||
return
|
||||
}
|
||||
|
||||
if (last !== null && clickedYear === last) {
|
||||
const newLastYear = last - 1
|
||||
if (newLastYear < first) {
|
||||
await deleteWholeWish(wish.id, workspaceId)
|
||||
return
|
||||
}
|
||||
await finishWithUpdate({ lastYear: newLastYear })
|
||||
return
|
||||
}
|
||||
|
||||
// A middle year (strictly between `first` and a defined `last`, or past
|
||||
// `first` with `last` still open-ended): split into two wishes around
|
||||
// the excluded year, each keeping the original's other fields.
|
||||
const created = await wishesHook.add({
|
||||
...createRequestFromWish(wish),
|
||||
firstYear: clickedYear + 1,
|
||||
lastYear: wish.lastYear,
|
||||
})
|
||||
await wishesHook.update(wish.id, { lastYear: clickedYear - 1 })
|
||||
// §todo9 "this period" (this year only). A year of a repeating wish is a
|
||||
// real row now, so this is one plain delete — no narrowing `firstYear`/
|
||||
// `lastYear`, and no splitting the wish in two around a middle year.
|
||||
const deleteWishOccurrence = async (wish: LeaveWish, workspaceId: string) => {
|
||||
await wishesHook.remove(wish.id, 'occurrence')
|
||||
closePeriodPopover()
|
||||
await runSync(workspaceId, {
|
||||
undo: {
|
||||
label: t('calendar.undoButton'),
|
||||
run: async () => {
|
||||
await wishesHook.update(wish.id, patchFromWish(wish))
|
||||
await wishesHook.remove(created.id)
|
||||
// `seriesId` puts the year back INTO its repeating wish rather than
|
||||
// beside it, so a later "every year" edit still reaches it.
|
||||
await wishesHook.add(createRequestFromWish(wish, wish.seriesId))
|
||||
await runSync(workspaceId, { silent: true })
|
||||
},
|
||||
},
|
||||
@@ -858,7 +844,7 @@ export function Calendar(): ReactNode {
|
||||
setPeriodError(null)
|
||||
|
||||
if (scope === 'allYears') {
|
||||
if (activeBand.wishId) await deleteWholeWish(activeBand.wishId, workspace.id)
|
||||
if (activeBand.wishId) await deleteWishSeries(activeBand.wishId, workspace.id)
|
||||
else closePeriodPopover() // disabled in the UI for ad-hoc bands
|
||||
return
|
||||
}
|
||||
@@ -880,7 +866,7 @@ export function Calendar(): ReactNode {
|
||||
closePeriodPopover()
|
||||
return
|
||||
}
|
||||
await narrowWishByOneYear(previousWish, splitISODate(activeBand.startDate).year, workspace.id)
|
||||
await deleteWishOccurrence(previousWish, workspace.id)
|
||||
} else {
|
||||
await deleteAdhocRange(activeBand.startDate, activeBand.endDate, activeBand.parentPersonalDataUuid)
|
||||
}
|
||||
@@ -919,7 +905,9 @@ export function Calendar(): ReactNode {
|
||||
undo: {
|
||||
label: t('calendar.undoButton'),
|
||||
run: async () => {
|
||||
await wishesHook.remove(created.id)
|
||||
// The preset is a repeating wish, so `created` is only its first
|
||||
// year — undo has to take the whole series with it.
|
||||
await wishesHook.remove(created.id, 'series')
|
||||
await runSync(workspace.id, { silent: true })
|
||||
},
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user