mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-23 15:36:33 +00:00
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:
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
}
|
||||
@@ -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' }])
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
}
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
@@ -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 })
|
||||
}
|
||||
Reference in New Issue
Block a user