Files
TREK/client/src/store/slices/daysSlice.ts
T
Maurice 2f6f921aef feat(dayplan): a day can be deleted from the reorder dialog, after a warning that says what goes with it
Every row of the reorder dialog, and of the days sheet on a phone, now has a delete button. It first asks, and the question lists what goes with the day: planned places stay in the place list, notes and day texts are deleted, bookings stay without a day, a stay that checks in or out on the day is cancelled with its booking and expense (shown in the danger tone), and on a dated trip the later days and their bookings move one date earlier or the trip ends a day earlier.

The server side moved from a bare DELETE into DayRemovalService, which REST, the delete_day tool and the plugin RPC all use. It closes the numbering, keeps the dates on their positions like a reorder, re-stamps the moved bookings, shifts the road trip boundaries, cancels touching stays through the accommodations domain, refuses the last day of a trip, and fans out day:deleted, day:reordered and trip:updated so collaborators renumber and see the new day count and end date. The trip query moved into a leaf file so the days domain can read it without a module cycle.

On the client the delete is online only, runs optimistically through the days slice with the same reducer collaborators apply, and the ConfirmDialog gained a slot for extra content, an Escape that only closes itself, and the danger and accent tokens.
2026-09-23 22:40:46 +02:00

105 lines
4.5 KiB
TypeScript

import { daysApi } from '../../api/client'
import { dayRepo } from '../../repo/dayRepo'
import type { StoreApi } from 'zustand'
import type { TripStoreState } from '../tripStore'
import type { Day } from '../../types'
import { getApiErrorMessage } from '../../types'
type SetState = StoreApi<TripStoreState>['setState']
type GetState = StoreApi<TripStoreState>['getState']
export interface DaysSlice {
reorderDays: (tripId: number | string, orderedIds: number[]) => Promise<void>
insertDay: (tripId: number | string, position?: number) => Promise<Day | undefined>
deleteDay: (tripId: number | string, dayId: number) => Promise<void>
}
type DayState = Pick<TripStoreState, 'days' | 'assignments' | 'dayNotes' | 'selectedDayId'>
/**
* The day state without one day, the way the server leaves it: the days after it
* move up one place, and on a dated trip the dates stay on their positions, so
* every later day takes the date one slot earlier. The day's assignments and
* notes go with it, and a selection on it is cleared.
*
* Both the optimistic delete below and a collaborator's day:deleted apply this,
* so the two ends of the same delete cannot drift apart field by field.
*/
export function withoutDay(state: DayState, dayId: number): DayState {
const ordered = [...state.days].sort((a, b) => (a.day_number ?? 0) - (b.day_number ?? 0))
const dates = ordered.map(d => d.date).filter((d): d is string => !!d).sort()
const days = ordered
.filter(d => d.id !== dayId)
.map((d, i) => ({ ...d, day_number: i + 1, date: dates.length ? (dates[i] ?? null) : d.date }))
const assignments = { ...state.assignments }
delete assignments[String(dayId)]
const dayNotes = { ...state.dayNotes }
delete dayNotes[String(dayId)]
return { days, assignments, dayNotes, selectedDayId: state.selectedDayId === dayId ? null : state.selectedDayId }
}
export const createDaysSlice = (set: SetState, get: GetState): DaysSlice => ({
// Move whole days. Day rows stay stable (assignments/notes/bookings ride along
// by id); only positions change and, on a dated trip, dates stay pinned to
// their slots while the content moves across them. Optimistically reorder the
// list, then refresh to pull the server-side re-stamped dates + booking times.
reorderDays: async (tripId, orderedIds) => {
const prevDays = get().days
const byId = new Map(prevDays.map(d => [d.id, d]))
const sortedDates = prevDays.map(d => d.date).filter((d): d is string => !!d).sort((a, b) => (a < b ? -1 : a > b ? 1 : 0))
const optimistic = orderedIds
.map((id, i) => {
const d = byId.get(id)
if (!d) return null
return { ...d, day_number: i + 1, date: sortedDates.length ? (sortedDates[i] ?? null) : d.date }
})
.filter((d): d is NonNullable<typeof d> => d !== null)
set({ days: optimistic })
try {
await daysApi.reorder(tripId, orderedIds)
await get().refreshDays(tripId)
await get().loadReservations(tripId)
} catch (err: unknown) {
set({ days: prevDays })
throw new Error(getApiErrorMessage(err, 'Error reordering days'))
}
},
// Insert a new empty day at a 1-based position (omit to append). On a dated
// trip this extends the trip by one day and re-pins dates server-side.
insertDay: async (tripId, position) => {
try {
const result = await daysApi.create(tripId, { position })
await get().refreshDays(tripId)
await get().loadReservations(tripId)
return result.day
} catch (err: unknown) {
throw new Error(getApiErrorMessage(err, 'Error adding day'))
}
},
// Delete a day. The list closes up at once; a refusal (the last day, no
// connection, a server error) puts every field back. Afterwards the store pulls
// the re-dated days and the bookings, which were re-stamped or let go of, and
// takes the trip the server answered with for its day count and end date.
deleteDay: async (tripId, dayId) => {
const { days, assignments, dayNotes, selectedDayId } = get()
const previous: DayState = { days, assignments, dayNotes, selectedDayId }
set(withoutDay(previous, dayId))
const outcome = await dayRepo.remove(tripId, dayId).then(
answer => ({ trip: answer.trip }),
(failure: unknown) => ({ failure }),
)
if ('failure' in outcome) {
set(previous)
throw new Error(getApiErrorMessage(outcome.failure, 'Error deleting day'))
}
if (outcome.trip) set({ trip: outcome.trip })
await get().refreshDays(tripId)
await get().loadReservations(tripId)
},
})