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.
This commit is contained in:
Maurice
2026-09-23 18:42:02 +02:00
parent 436f0740fb
commit 2f6f921aef
78 changed files with 3086 additions and 170 deletions
+124
View File
@@ -0,0 +1,124 @@
// FE-UTIL-DAYDEL-001 to FE-UTIL-DAYDEL-008
import { describe, it, expect } from 'vitest'
import { buildAssignment, buildDay, buildDayNote, buildPlace, buildReservation } from '../../tests/helpers/factories'
import { contentOnDays, dayDeleteImpact, deleteDayBlockedReason, type DayContentData } from './dayDeleteImpact'
import type { Accommodation, Day } from '../types'
const stay = (overrides: Partial<Accommodation>): Accommodation =>
({ id: 1, trip_id: 1, place_id: null, start_day_id: 0, end_day_id: 0, ...overrides }) as Accommodation
const empty = (): DayContentData => ({ assignments: {}, dayNotes: {}, reservations: [], accommodations: [] })
/** Three dated days, 1 to 3 June, and optionally a spare day without a date at the end. */
function datedDays(spare = false): Day[] {
const days = [
buildDay({ id: 1, day_number: 1, date: '2026-06-01' }),
buildDay({ id: 2, day_number: 2, date: '2026-06-02' }),
buildDay({ id: 3, day_number: 3, date: '2026-06-03' }),
]
return spare ? [...days, buildDay({ id: 4, day_number: 4, date: null })] : days
}
const range = { start_date: '2026-06-01', end_date: '2026-06-03' }
describe('contentOnDays', () => {
it('FE-UTIL-DAYDEL-001: an empty day holds nothing', () => {
const day = buildDay({ id: 7 })
expect(contentOnDays([day], empty())).toEqual({ places: 0, notes: 0, texts: 0, bookings: 0, stays: [] })
})
it('FE-UTIL-DAYDEL-002: places count once each, and the stop a stay wrote is the stay, not a place', () => {
const museum = buildPlace({ id: 50 })
const cafe = buildPlace({ id: 51 })
const hotel = buildPlace({ id: 52 })
const data = {
...empty(),
assignments: {
'7': [
buildAssignment({ day_id: 7, place: museum }),
buildAssignment({ day_id: 7, place: museum }),
buildAssignment({ day_id: 7, place: cafe }),
buildAssignment({ day_id: 7, place: hotel, accommodation_id: 9 }),
],
'8': [buildAssignment({ day_id: 8, place: buildPlace({ id: 53 }) })],
},
}
expect(contentOnDays([buildDay({ id: 7 })], data).places).toBe(2)
})
it('FE-UTIL-DAYDEL-003: notes count one by one, a day title and a day description one each', () => {
const data = { ...empty(), dayNotes: { '7': [buildDayNote({ day_id: 7 }), buildDayNote({ day_id: 7 })] } }
expect(contentOnDays([buildDay({ id: 7, title: 'Harbour day', notes: 'Ferry at nine' })], data)).toMatchObject({ notes: 2, texts: 2 })
expect(contentOnDays([buildDay({ id: 7, title: 'Harbour day', notes: null })], empty()).texts).toBe(1)
})
it('FE-UTIL-DAYDEL-004: bookings starting or ending on the day count, a cancelled stay\'s own booking does not', () => {
const data = {
...empty(),
accommodations: [stay({ id: 9, start_day_id: 7, end_day_id: 8 })],
reservations: [
buildReservation({ id: 1, day_id: 7 }),
buildReservation({ id: 2, day_id: 6, end_day_id: 7 }),
buildReservation({ id: 3, day_id: 8 }),
buildReservation({ id: 4, day_id: 7, type: 'hotel', accommodation_id: '9' }),
],
}
expect(contentOnDays([buildDay({ id: 7 })], data).bookings).toBe(2)
})
it('FE-UTIL-DAYDEL-005: a stay checking in or out on the day goes, named by its place, with its booking', () => {
const data: DayContentData = {
...empty(),
places: [buildPlace({ id: 60, name: 'Pension Alma' })],
accommodations: [
stay({ id: 1, start_day_id: 7, end_day_id: 8, place_name: 'Harbour Hotel' }),
stay({ id: 2, start_day_id: 5, end_day_id: 7, place_id: 60, reservation_title: 'Alma, two nights' }),
stay({ id: 3, start_day_id: 5, end_day_id: 7 }),
stay({ id: 4, start_day_id: 6, end_day_id: 8 }),
],
reservations: [buildReservation({ id: 30, title: 'Harbour Hotel, 1 night', accommodation_id: 1 })],
}
expect(contentOnDays([buildDay({ id: 7 })], data).stays).toEqual([
{ id: 1, name: 'Harbour Hotel', booking: 'Harbour Hotel, 1 night' },
{ id: 2, name: 'Pension Alma', booking: 'Alma, two nights' },
{ id: 3, name: '', booking: null },
])
})
})
describe('dayDeleteImpact', () => {
it('FE-UTIL-DAYDEL-006: with a spare day left, the later dated days move one date and their bookings go along', () => {
const days = datedDays(true)
const data = {
...empty(),
reservations: [
buildReservation({ id: 1, day_id: 3, reservation_time: '2026-06-03T10:00' }),
buildReservation({ id: 2, day_id: 1, end_day_id: 3, reservation_end_time: '2026-06-03T09:00' }),
buildReservation({ id: 3, day_id: 3, reservation_time: null }),
buildReservation({ id: 4, day_id: 1, reservation_time: '2026-06-01T08:00' }),
],
}
expect(dayDeleteImpact(days[1], days, data, range)).toMatchObject({ shiftedDays: 1, shiftedBookings: 2, newEndDate: null, isLastDay: false })
})
it('FE-UTIL-DAYDEL-007: with no spare day the last date goes, and a dated trip ends on the one before', () => {
const days = datedDays()
expect(dayDeleteImpact(days[0], days, empty(), range)).toMatchObject({ shiftedDays: 2, newEndDate: '2026-06-02' })
// A trip without a range keeps it; only its days move.
expect(dayDeleteImpact(days[0], days, empty(), { start_date: null, end_date: null }).newEndDate).toBeNull()
// The last day of the list moves nothing.
expect(dayDeleteImpact(days[2], days, empty(), range)).toMatchObject({ shiftedDays: 0, newEndDate: '2026-06-02' })
})
it('FE-UTIL-DAYDEL-008: a spare day moves no date, and the only day of a trip is the last one', () => {
const days = datedDays(true)
expect(dayDeleteImpact(days[3], days, empty(), range)).toMatchObject({ shiftedDays: 0, shiftedBookings: 0, newEndDate: null })
const only = [buildDay({ id: 1, day_number: 1 })]
expect(dayDeleteImpact(only[0], only, empty(), null).isLastDay).toBe(true)
const t = (key: string) => key
expect(deleteDayBlockedReason(1, true, t)).toBe('dayplan.deleteDayLast')
expect(deleteDayBlockedReason(3, true, t)).toBe('dayplan.daysOffline')
expect(deleteDayBlockedReason(3, false, t)).toBeNull()
})
})
+136
View File
@@ -0,0 +1,136 @@
import type { Accommodation, AssignmentsMap, Day, DayNotesMap, Place, Reservation, Trip } from '../types'
/** What the trip holds on its days, as the store and the planner have it. */
export interface DayContentData {
assignments: AssignmentsMap
dayNotes: DayNotesMap
reservations: Reservation[]
accommodations: Accommodation[]
places?: Place[]
}
/** A stay that goes with the days: checked in or out on one of them. */
export interface StayImpact {
id: number
name: string
/** Title of the booking that goes with it, or null when none is linked. */
booking: string | null
}
/** What sits on a set of days and would go with them. */
export interface DayContent {
/** Distinct places planned there. The places themselves stay on the trip. */
places: number
notes: number
/** Day titles and day descriptions. */
texts: number
/** Bookings on the days, the ones a cancelled stay takes along left out. */
bookings: number
stays: StayImpact[]
}
/** Deleting one day: its content, and what the dates of the other days do. */
export interface DayDeleteImpact extends DayContent {
/** Later dated days that take another date. */
shiftedDays: number
/** Bookings on those days whose date is re-stamped with them. */
shiftedBookings: number
/** The trip's new last date when the delete takes the last date along. */
newEndDate: string | null
isLastDay: boolean
}
const byNumber = (a: Day, b: Day): number => (a.day_number ?? 0) - (b.day_number ?? 0)
/** A reservation's link to a stay, which the wire carries as a number or a string. */
const stayOf = (r: Reservation): number | null => (r.accommodation_id == null ? null : Number(r.accommodation_id))
/**
* Count what sits on the given days, the way the server treats it when they go.
*
* A stop a stay wrote is the stay, not a place of its own, so it is not counted
* as one; a stay touching two of the days counts once, and the booking that came
* with it is part of the stay rather than one of the bookings. Shared by the day
* delete question and the warning before a trip is shortened.
*/
export function contentOnDays(days: Pick<Day, 'id' | 'title' | 'notes'>[], data: DayContentData): DayContent {
const ids = new Set(days.map(d => d.id))
const stays = data.accommodations.filter(stay => ids.has(stay.start_day_id) || ids.has(stay.end_day_id))
const stayIds = new Set(stays.map(stay => stay.id))
const placeIds = new Set<number>()
let notes = 0
let texts = 0
for (const day of days) {
for (const assignment of data.assignments[String(day.id)] ?? []) {
if (assignment.accommodation_id == null) placeIds.add(assignment.place.id)
}
notes += (data.dayNotes[String(day.id)] ?? []).length
if (day.title) texts += 1
if (day.notes) texts += 1
}
const onDays = (r: Reservation) => (r.day_id != null && ids.has(r.day_id)) || (r.end_day_id != null && ids.has(r.end_day_id))
const bookings = data.reservations.filter(r => onDays(r) && !stayIds.has(stayOf(r) ?? -1)).length
return {
places: placeIds.size,
notes,
texts,
bookings,
stays: stays.map(stay => {
const booking = data.reservations.find(r => stayOf(r) === stay.id)?.title ?? stay.reservation_title ?? null
const name = stay.place_name ?? data.places?.find(p => p.id === stay.place_id)?.name ?? booking ?? ''
return { id: stay.id, name, booking }
}),
}
}
/**
* What deleting one day does, as the server will do it (DayRemovalService): the
* dates stay on their positions, so every later dated day takes the date one slot
* earlier and the bookings on it are re-stamped along. When no day without a date
* is left to take the last date, the last date goes and a dated trip ends a day
* earlier.
*/
export function dayDeleteImpact(
day: Day,
days: Day[],
data: DayContentData,
trip: Pick<Trip, 'start_date' | 'end_date'> | null,
): DayDeleteImpact {
const content = contentOnDays([day], data)
const ordered = [...days].sort(byNumber)
const remaining = ordered.filter(d => d.id !== day.id)
const dates = ordered.map(d => d.date).filter((d): d is string => !!d).sort()
const moved = new Set<number>()
remaining.forEach((d, i) => {
const next = dates[i]
if (d.date && next && next !== d.date) moved.add(d.id)
})
const cancelled = new Set(content.stays.map(stay => stay.id))
const shiftedBookings = data.reservations.filter(r => {
if (cancelled.has(stayOf(r) ?? -1)) return false
const start = r.day_id != null && moved.has(r.day_id) && !!r.reservation_time
const end = r.end_day_id != null && moved.has(r.end_day_id) && !!r.reservation_end_time
return start || end
}).length
const dropsDate = remaining.length < dates.length
const newEndDate = dropsDate && trip?.start_date && trip.end_date ? (dates[remaining.length - 1] ?? null) : null
return { ...content, shiftedDays: moved.size, shiftedBookings, newEndDate, isLastDay: days.length <= 1 }
}
/**
* Why a day cannot be deleted right now, or null when it can. A trip keeps at
* least one day, and the delete runs on the server only: the cascade behind it
* (stays cancelled, bookings let go of, dates moved) is not something a queued
* offline write could replay faithfully.
*/
export function deleteDayBlockedReason(dayCount: number, offline: boolean, t: (key: string) => string): string | null {
if (dayCount <= 1) return t('dayplan.deleteDayLast')
if (offline) return t('dayplan.daysOffline')
return null
}
+56
View File
@@ -0,0 +1,56 @@
// FE-UTIL-DAYLINES-001 to FE-UTIL-DAYLINES-004
import { describe, it, expect } from 'vitest'
import { BedDouble, CalendarCheck } from 'lucide-react'
import { deleteDayLines, impactLines } from './dayImpactLines'
import type { DayDeleteImpact } from './dayDeleteImpact'
const t = (key: string, params?: Record<string, string | number>) =>
params ? `${key}(${Object.entries(params).map(([k, v]) => `${k}=${v}`).join(',')})` : key
const nothing: DayDeleteImpact = {
places: 0, notes: 0, texts: 0, bookings: 0, stays: [],
shiftedDays: 0, shiftedBookings: 0, newEndDate: null, isLastDay: false,
}
describe('impactLines', () => {
it('FE-UTIL-DAYLINES-001: stays come first in the danger tone, then every kind that has something in it', () => {
const lines = impactLines({
places: 3, notes: 2, texts: 1, bookings: 4,
stays: [{ id: 9, name: 'Harbour Hotel', booking: 'HH 1 night' }, { id: 10, name: 'Pension Alma', booking: null }],
}, t, 'deleteDay')
expect(lines.map(l => [l.key, l.tone, l.text, l.hint])).toEqual([
['stay-9', 'danger', 'dayplan.impactStay(name=Harbour Hotel)', 'dayplan.deleteDayStayBookedHint(booking=HH 1 night)'],
['stay-10', 'danger', 'dayplan.impactStay(name=Pension Alma)', 'dayplan.deleteDayStayHint'],
['places', 'neutral', 'dayplan.impactPlaces(count=3)', 'dayplan.impactPlacesHint'],
['notes', 'neutral', 'dayplan.impactNotes(count=2)', 'dayplan.impactDeletedHint'],
['texts', 'neutral', 'dayplan.impactTexts(count=1)', 'dayplan.impactDeletedHint'],
['bookings', 'neutral', 'dayplan.impactBookings(count=4)', 'dayplan.deleteDayBookingsHint'],
])
expect(lines[0].icon).toBe(BedDouble)
})
it('FE-UTIL-DAYLINES-002: kinds with nothing in them are left out', () => {
expect(impactLines({ ...nothing, notes: 1 }, t, 'deleteDay').map(l => l.key)).toEqual(['notes'])
expect(impactLines(nothing, t, 'deleteDay')).toEqual([])
})
})
describe('deleteDayLines', () => {
const formatDate = (iso: string) => `<${iso}>`
it('FE-UTIL-DAYLINES-003: moved dates follow the content, with the bookings that move along and the new end', () => {
const lines = deleteDayLines({ ...nothing, places: 1, shiftedDays: 2, shiftedBookings: 3, newEndDate: '2026-06-02' }, t, formatDate)
expect(lines.map(l => [l.key, l.tone, l.text, l.hint])).toEqual([
['places', 'neutral', 'dayplan.impactPlaces(count=1)', 'dayplan.impactPlacesHint'],
['shift', 'warning', 'dayplan.deleteDayShift(count=2)', 'dayplan.deleteDayShiftBookingsHint(count=3)'],
['shrink', 'warning', 'dayplan.deleteDayShrink(date=<2026-06-02>)', 'dayplan.deleteDayShrinkHint'],
])
expect(deleteDayLines({ ...nothing, shiftedDays: 1 }, t, formatDate)[0].hint).toBe('dayplan.deleteDayShiftHint')
})
it('FE-UTIL-DAYLINES-004: a day with nothing on it and no dates to move gets one quiet row', () => {
const lines = deleteDayLines(nothing, t, formatDate)
expect(lines).toEqual([{ key: 'empty', icon: CalendarCheck, tone: 'muted', text: 'dayplan.deleteDayEmpty' }])
})
})
+97
View File
@@ -0,0 +1,97 @@
import { BedDouble, CalendarCheck, CalendarClock, CalendarMinus, MapPin, StickyNote, Ticket, Type } from 'lucide-react'
import type { LucideIcon } from 'lucide-react'
import type { DayContent, DayDeleteImpact } from './dayDeleteImpact'
type Translate = (key: string, params?: Record<string, string | number>) => string
/**
* How loud a line is. `danger` is money: a stay cancelled with its booking and
* expense. `warning` is dates that move. `muted` is the reassurance that nothing
* is lost at all.
*/
export type ImpactTone = 'neutral' | 'muted' | 'warning' | 'danger'
/** One row of a warning list, ready for either shell to render. */
export interface ImpactLine {
key: string
icon: LucideIcon
text: string
hint?: string
tone: ImpactTone
}
/**
* The question the list answers. Deleting a day cancels a stay cleanly and lets
* go of its bookings; other ways of losing days treat both differently, so the
* hints are looked up per variant while the rows themselves stay the same.
*/
export type ImpactVariant = 'deleteDay'
const HINTS: Record<ImpactVariant, { bookings: string; stay: string; stayBooked: string }> = {
deleteDay: {
bookings: 'dayplan.deleteDayBookingsHint',
stay: 'dayplan.deleteDayStayHint',
stayBooked: 'dayplan.deleteDayStayBookedHint',
},
}
/**
* The rows for what sits on the days that go: stays first, because they cost
* money, then places, notes, day texts and bookings. Kinds with nothing in them
* are left out.
*/
export function impactLines(content: DayContent, t: Translate, variant: ImpactVariant): ImpactLine[] {
const hints = HINTS[variant]
const lines: ImpactLine[] = content.stays.map(stay => ({
key: `stay-${stay.id}`,
icon: BedDouble,
tone: 'danger',
text: t('dayplan.impactStay', { name: stay.name }),
hint: stay.booking ? t(hints.stayBooked, { booking: stay.booking }) : t(hints.stay),
}))
if (content.places > 0) {
lines.push({ key: 'places', icon: MapPin, tone: 'neutral', text: t('dayplan.impactPlaces', { count: content.places }), hint: t('dayplan.impactPlacesHint') })
}
if (content.notes > 0) {
lines.push({ key: 'notes', icon: StickyNote, tone: 'neutral', text: t('dayplan.impactNotes', { count: content.notes }), hint: t('dayplan.impactDeletedHint') })
}
if (content.texts > 0) {
lines.push({ key: 'texts', icon: Type, tone: 'neutral', text: t('dayplan.impactTexts', { count: content.texts }), hint: t('dayplan.impactDeletedHint') })
}
if (content.bookings > 0) {
lines.push({ key: 'bookings', icon: Ticket, tone: 'neutral', text: t('dayplan.impactBookings', { count: content.bookings }), hint: t(hints.bookings) })
}
return lines
}
/**
* The full list for the delete question: the content rows, then what happens to
* the dates, and a single quiet row for a day with nothing on it.
*/
export function deleteDayLines(impact: DayDeleteImpact, t: Translate, formatDate: (iso: string) => string): ImpactLine[] {
const lines = impactLines(impact, t, 'deleteDay')
if (impact.shiftedDays > 0) {
lines.push({
key: 'shift',
icon: CalendarClock,
tone: 'warning',
text: t('dayplan.deleteDayShift', { count: impact.shiftedDays }),
hint: impact.shiftedBookings > 0
? t('dayplan.deleteDayShiftBookingsHint', { count: impact.shiftedBookings })
: t('dayplan.deleteDayShiftHint'),
})
}
if (impact.newEndDate) {
lines.push({
key: 'shrink',
icon: CalendarMinus,
tone: 'warning',
text: t('dayplan.deleteDayShrink', { date: formatDate(impact.newEndDate) }),
hint: t('dayplan.deleteDayShrinkHint'),
})
}
if (lines.length === 0) {
lines.push({ key: 'empty', icon: CalendarCheck, tone: 'muted', text: t('dayplan.deleteDayEmpty') })
}
return lines
}
+22
View File
@@ -0,0 +1,22 @@
// FE-UTIL-DAYLABEL-001 to FE-UTIL-DAYLABEL-003
import { describe, it, expect } from 'vitest'
import { dayLabel } from './dayLabel'
const t = (key: string, params?: Record<string, string | number>) => `${key}:${params?.n}`
describe('dayLabel', () => {
it('FE-UTIL-DAYLABEL-001: a title wins over the date', () => {
expect(dayLabel({ title: 'Old Town', date: '2026-05-02' }, 1, t, 'en-US')).toBe('Old Town')
})
it('FE-UTIL-DAYLABEL-002: without a title the date reads as a short local weekday and day', () => {
expect(dayLabel({ title: null, date: '2026-05-01' }, 0, t, 'en-US')).toBe('Fri, May 1')
// A full timestamp names the same calendar day.
expect(dayLabel({ title: '', date: '2026-05-03T09:30:00' }, 2, t, 'en-US')).toBe('Sun, May 3')
})
it('FE-UTIL-DAYLABEL-003: no title and no usable date falls back to the position, counted from one', () => {
expect(dayLabel({ title: null, date: null }, 0, t, 'en-US')).toBe('dayplan.dayN:1')
expect(dayLabel({ title: null, date: 'not-a-date' }, 4, t, 'en-US')).toBe('dayplan.dayN:5')
})
})
+23
View File
@@ -0,0 +1,23 @@
import type { Day } from '../types'
type Translate = (key: string, params?: Record<string, string | number>) => string
/**
* How a day is named wherever days are listed as a whole: its title, else its
* date as a short weekday and day of the month, else "Day n" by its position.
*
* The reorder dialog, the phone's day sheet and the delete question all name a
* day, and they have to name it the same way, or the question asks about a day
* the list beside it calls something else. The date is read as a local calendar
* date, and a value that does not parse falls through to the position.
*/
export function dayLabel(day: Pick<Day, 'title' | 'date'>, index: number, t: Translate, locale: string): string {
if (day.title) return day.title
if (day.date) {
const date = new Date(`${day.date.slice(0, 10)}T00:00:00`)
if (!Number.isNaN(date.getTime())) {
return date.toLocaleDateString(locale, { weekday: 'short', day: 'numeric', month: 'short' })
}
}
return t('dayplan.dayN', { n: index + 1 })
}