fix(dayplan): calmer day dialogs, and the shrink question side by side

The day dialog hangs from a fixed top edge, so switching to the delete
question only moves its bottom edge, and the question opens at its top
with the focus on Cancel. Cancel, Close and the quiet add share one
outline; the dated add is drawn in the accent instead of filled with it.
Dates in the dialog, its hints and the new-day toast read the way the day
list writes them, and captions use the muted text colour for contrast. A
stay booked twice is named once in the warning. On the phone the dated
pill shows only its date, so both pills fit side by side.

When the trip dialog asks both how bookings follow a moved start and
which days the new dates remove, the two now stand side by side in a wide
dialog instead of one long column.
This commit is contained in:
Maurice
2026-09-23 22:20:59 +02:00
parent 83be759c49
commit 954758082b
35 changed files with 152 additions and 118 deletions
@@ -1,6 +1,6 @@
import { useId, useState } from 'react'
import { CalendarPlus, Plus } from 'lucide-react'
import { formatDate } from '../../utils/formatters'
import { dayDate } from '../../utils/dayLabel'
import type { DayAddControls } from '../../utils/dayAdd'
interface DayAddFooterProps {
@@ -15,10 +15,11 @@ interface DayAddFooterProps {
/** One button size for the whole foot of the reorder dialog, the delete question's included. */
export const FOOTER_BTN =
'inline-flex min-h-9 flex-shrink-0 items-center justify-center gap-1.5 whitespace-nowrap rounded-lg px-3 py-1.5 text-body font-medium transition-colors disabled:cursor-default disabled:opacity-40'
const SECONDARY_BTN = `${FOOTER_BTN} border border-edge text-content-secondary hover:bg-surface-hover disabled:hover:bg-transparent`
const CLOSE_BTN = `${FOOTER_BTN} ml-auto border border-edge text-content-muted hover:bg-surface-hover hover:text-content`
/** The add that matters most, tinted from the accent instead of filled with it. */
const MAIN_BTN = `${FOOTER_BTN} border border-[color:color-mix(in_srgb,var(--accent)_30%,transparent)] bg-[color:color-mix(in_srgb,var(--accent)_10%,transparent)] font-semibold text-accent-on hover:bg-[color:color-mix(in_srgb,var(--accent)_16%,transparent)] disabled:hover:bg-[color:color-mix(in_srgb,var(--accent)_10%,transparent)]`
/** Cancel, Close and the quiet add: one outline, one text colour, so none of them looks switched off beside another. */
export const SECONDARY_BTN = `${FOOTER_BTN} border border-edge text-content-secondary hover:bg-surface-hover hover:text-content disabled:hover:bg-transparent`
const CLOSE_BTN = `${SECONDARY_BTN} ml-auto`
/** The add that matters most: drawn in the accent, not filled with it, so it leads without weighing on the row. */
const MAIN_BTN = `${FOOTER_BTN} border border-accent-on bg-transparent font-semibold text-accent-on hover:bg-accent-subtle disabled:hover:bg-transparent`
/**
* The foot of the reorder dialog: close it, or add a day.
@@ -45,7 +46,7 @@ export function DayAddFooter({ dayAdd, onAddDay, onClose, t, locale }: DayAddFoo
if (!dayAdd?.nextDate) {
return (
<div className="flex flex-col gap-2.5">
{blocked && <p className="m-0 text-caption text-content-faint">{blocked}</p>}
{blocked && <p className="m-0 text-caption text-content-muted">{blocked}</p>}
<div className="flex flex-wrap items-center gap-2">
<button type="button" onClick={onAddDay} disabled={addOff} className={MAIN_BTN}>
<Plus size={15} strokeWidth={2} aria-hidden="true" />
@@ -57,7 +58,7 @@ export function DayAddFooter({ dayAdd, onAddDay, onClose, t, locale }: DayAddFoo
)
}
const date = formatDate(dayAdd.nextDate, locale) ?? dayAdd.nextDate
const date = dayDate(dayAdd.nextDate, locale) ?? dayAdd.nextDate
const hint = blocked
?? (aboutUndated ? t('dayplan.addUndatedDayHint') : dayAdd.datedBlocked ?? t('dayplan.addDatedDayHint', { date }))
const pointAtUndated = {
@@ -69,7 +70,7 @@ export function DayAddFooter({ dayAdd, onAddDay, onClose, t, locale }: DayAddFoo
return (
<div className="flex flex-col gap-2.5">
<p id={hintId} aria-live="polite" className="m-0 text-caption text-content-faint">
<p id={hintId} aria-live="polite" className="m-0 text-caption text-content-muted">
{hint}
</p>
{/* Should a language run long, the row wraps between buttons, never inside one. */}
@@ -3,7 +3,7 @@ import { GripVertical, ArrowUp, ArrowDown, Trash2, AlertTriangle } from 'lucide-
import Modal from '../shared/Modal'
import Tooltip from '../shared/Tooltip'
import DayImpactList from '../shared/DayImpactList'
import { DayAddFooter, FOOTER_BTN } from './DayAddFooter'
import { DayAddFooter, FOOTER_BTN, SECONDARY_BTN } from './DayAddFooter'
import { useNetworkMode } from '../../hooks/useNetworkMode'
import { dayLabel } from '../../utils/dayLabel'
import { deleteDayBlockedReason } from '../../utils/dayDeleteImpact'
@@ -35,7 +35,6 @@ interface ListSpot {
dayId: number
index: number
scrollTop: number
panelHeight: number
}
const ICON_BTN =
@@ -50,8 +49,10 @@ const DELETE_BTN =
* so the list reflects each move immediately.
*
* A delete only asks, and it asks right here: the dialog swaps its list for the
* question, what goes with the day and the two ways out, at the same size and in
* the same place. Cancel and Escape bring the list back where it was.
* question, what goes with the day and the two ways out, at the same width. The
* dialog hangs from a fixed top edge, so the swap only moves its bottom edge,
* and the question takes the height it needs. Cancel and Escape bring the list
* back where it was.
*/
export function DayReorderPopup({ isOpen, days, t, locale, onReorder, onAddDay, dayAdd, onDeleteDay, deleteQuestion, onClose }: DayReorderPopupProps) {
const [dragIndex, setDragIndex] = useState<number | null>(null)
@@ -77,13 +78,7 @@ export function DayReorderPopup({ isOpen, days, t, locale, onReorder, onAddDay,
}
const ask = (dayId: number, index: number) => {
const body = bodyRef.current
spot.current = {
dayId,
index,
scrollTop: body?.parentElement?.scrollTop ?? 0,
panelHeight: body?.closest<HTMLElement>('[role="presentation"]')?.offsetHeight ?? 0,
}
spot.current = { dayId, index, scrollTop: bodyRef.current?.parentElement?.scrollTop ?? 0 }
onDeleteDay?.(dayId)
}
@@ -108,43 +103,35 @@ export function DayReorderPopup({ isOpen, days, t, locale, onReorder, onAddDay,
return () => document.removeEventListener('keydown', onKey, true)
}, [asking])
// The question keeps the dialog at the height the list had, so nothing moves
// under the pointer; a longer one grows it and scrolls inside. Back at the
// list, the scroll position and the focus return to the row asked about.
// The question opens at its top with the focus on Cancel. Back at the list,
// the scroll position and the focus return to the row asked about.
const askedDayId = asking?.dayId ?? null
const lineCount = asking?.lines.length ?? 0
const shownDayId = useRef<number | null>(null)
useLayoutEffect(() => {
const body = bodyRef.current
if (!body) return
const scroller = body.parentElement
const turned = shownDayId.current !== askedDayId
// Closed with the question open: the next opening starts at the list, fresh.
if (!body) { shownDayId.current = null; spot.current = null; return }
if (shownDayId.current === askedDayId) return
shownDayId.current = askedDayId
body.style.minHeight = ''
const scroller = body.parentElement
if (askedDayId != null) {
const target = spot.current?.panelHeight ?? 0
const panel = body.closest<HTMLElement>('[role="presentation"]')
const short = target - (panel?.offsetHeight ?? target)
if (short > 0) body.style.minHeight = `${body.offsetHeight + short}px`
if (turned) {
if (scroller) scroller.scrollTop = 0
cancelRef.current?.focus()
}
if (scroller) scroller.scrollTop = 0
cancelRef.current?.focus()
return
}
const from = spot.current
if (!from || !turned) return
if (!from) return
spot.current = null
if (scroller) scroller.scrollTop = from.scrollTop
const buttons = body.querySelectorAll<HTMLButtonElement>('[data-delete-day]')
const again = body.querySelector<HTMLButtonElement>(`[data-delete-day="${from.dayId}"]`)
?? buttons[Math.min(from.index, buttons.length - 1)]
again?.focus()
}, [askedDayId, lineCount])
}, [askedDayId])
const footer = asking ? (
<div className="flex items-center justify-end gap-2">
<button ref={cancelRef} type="button" onClick={asking.onCancel} className={`${FOOTER_BTN} border border-edge text-content-secondary hover:bg-surface-hover`}>
<button ref={cancelRef} type="button" onClick={asking.onCancel} className={SECONDARY_BTN}>
{t('common.cancel')}
</button>
<button type="button" onClick={asking.onConfirm} className={`${FOOTER_BTN} bg-danger font-semibold text-white hover:opacity-90`}>
@@ -157,7 +144,7 @@ export function DayReorderPopup({ isOpen, days, t, locale, onReorder, onAddDay,
)
return (
<Modal isOpen={isOpen} onClose={close} title={t('dayplan.reorderTitle')} size="lg" footer={footer}>
<Modal isOpen={isOpen} onClose={close} title={t('dayplan.reorderTitle')} size="lg" align="top" footer={footer}>
<div ref={bodyRef}>
{asking ? (
<section aria-labelledby={titleId} aria-describedby={bodyId} className="trek-page-enter">
@@ -166,7 +153,7 @@ export function DayReorderPopup({ isOpen, days, t, locale, onReorder, onAddDay,
<AlertTriangle size={16} strokeWidth={2} aria-hidden="true" />
</span>
<div className="min-w-0 flex-1">
<h3 id={titleId} className="text-body font-semibold leading-snug text-content">{asking.title}</h3>
<h3 id={titleId} className="text-subtitle font-semibold leading-snug text-content">{asking.title}</h3>
<p id={bodyId} className="mt-0.5 text-caption text-content-muted">{t('dayplan.deleteDayBody')}</p>
</div>
</div>
@@ -174,7 +161,7 @@ export function DayReorderPopup({ isOpen, days, t, locale, onReorder, onAddDay,
</section>
) : (
<>
<p className="mb-3 text-caption text-content-faint">{t('dayplan.reorderHint')}</p>
<p className="mb-3 text-caption text-content-muted">{t('dayplan.reorderHint')}</p>
{/* The popup is a modal, so it portals out of the planner and has to opt
into the long-press drag itself (#1616). Without this a finger only
+19 -5
View File
@@ -14,11 +14,20 @@ interface TripDateReviewProps {
error?: string
}
/**
* Whether the step shows both of its parts. Then they stand side by side in a wide
* dialog, the question on the left and the removed days on the right, instead of one
* long column that has to be scrolled through before the buttons.
*/
export function dateReviewIsWide(askShift: boolean, removal: RangeCheck): boolean {
return askShift && !!removal && removal !== 'unknown'
}
/**
* The step the trip dialog puts between a change of dates and the save: how
* bookings follow a moved start, and which days the new range removes with what
* is on them. Either part can stand alone. The list's booking hint follows the
* shift mode picked above it, because the two modes leave those bookings in
* shift mode picked beside it, because the two modes leave those bookings in
* different places.
*/
export default function TripDateReview({ askShift, shiftMode, onShiftMode, removal, error }: TripDateReviewProps) {
@@ -28,13 +37,16 @@ export default function TripDateReview({ askShift, shiftMode, onShiftMode, remov
{ mode: 'shift_all', label: t('dashboard.dateShiftAll'), desc: t('dashboard.dateShiftAllDesc') },
]
const wide = dateReviewIsWide(askShift, removal)
return (
<div className="space-y-3">
{error && (
<div className="p-3 bg-danger-soft border border-danger/30 rounded-xl text-body text-danger">{error}</div>
)}
<div className={wide ? 'grid gap-5 md:grid-cols-2' : 'space-y-3'}>
{askShift && (
<>
<div className="space-y-3">
<p className="text-body text-content-secondary">{t('dashboard.dateShiftIntro')}</p>
{modes.map(({ mode, label, desc }) => (
<label key={mode}
@@ -47,7 +59,8 @@ export default function TripDateReview({ askShift, shiftMode, onShiftMode, remov
</span>
</label>
))}
</>
<p className="text-caption text-content-muted">{t('dashboard.dateShiftHint')}</p>
</div>
)}
{removal === 'unknown' && (
<div role="alert" className="flex items-start gap-3 rounded-xl border border-edge-faint bg-surface-secondary p-3">
@@ -58,7 +71,8 @@ export default function TripDateReview({ askShift, shiftMode, onShiftMode, remov
</div>
)}
{removal && removal !== 'unknown' && (
<div className={askShift ? 'border-t border-edge-faint pt-3' : ''}>
// Beside the question on a wide screen, below it on a narrow one.
<div className={wide ? 'border-t border-edge-faint pt-3 md:border-l md:border-t-0 md:pl-5 md:pt-0' : ''}>
<p className="text-body text-content-secondary">{t('dashboard.shrinkIntro')}</p>
<DayImpactList
lines={shrinkTripLines(removal, t, shiftMode)}
@@ -67,7 +81,7 @@ export default function TripDateReview({ askShift, shiftMode, onShiftMode, remov
/>
</div>
)}
{askShift && <p className="text-caption text-content-faint">{t('dashboard.dateShiftHint')}</p>}
</div>
</div>
)
}
@@ -14,7 +14,7 @@ import { getApiErrorMessage, type Trip } from '../../types'
import { MAX_TRIP_DAYS, tripSpanDays, type TripCreateRequest } from '@trek/shared'
import { NumericInput } from '../shared/NumericInput'
import { currenciesWith, SYMBOLS } from '../Budget/BudgetPanel.constants'
import TripDateReview from './TripDateReview'
import TripDateReview, { dateReviewIsWide } from './TripDateReview'
import { useTripRangeGuard, type RangeCheck } from '../../hooks/useTripRangeGuard'
import type { ShiftMode } from '../../utils/dayImpactLines'
@@ -410,9 +410,10 @@ export default function TripFormModal({ isOpen, onClose, onSave, trip, onCoverUp
title={pendingReview
? t(pendingReview.askShift ? 'dashboard.dateShiftTitle' : 'dashboard.shrinkTitle')
: isEditing ? t('dashboard.editTrip') : t('dashboard.createTrip')}
/* The review step is a few radio buttons and a short list; it would look
lost across the width the form itself needs. */
size={pendingReview ? 'md' : '2xl'}
/* The review step is a few radio buttons and a short list; alone, either
takes the width of the day dialog, where the same list stands. With both,
they stand side by side at the form's own width. */
size={pendingReview && !dateReviewIsWide(pendingReview.askShift, pendingReview.removal) ? 'lg' : '2xl'}
footer={
<div className="flex gap-3 justify-end">
{pendingReview ? (
+8 -1
View File
@@ -24,6 +24,12 @@ interface ModalProps {
size?: string
footer?: React.ReactNode
hideCloseButton?: boolean
/**
* Where the panel stands on a wide screen. Centred by default; 'top' pins its
* upper edge, so a dialog whose content changes height grows and shrinks at
* the bottom only instead of jumping. Phones always start at the top.
*/
align?: 'center' | 'top'
}
export default function Modal({
@@ -34,6 +40,7 @@ export default function Modal({
size = 'md',
footer,
hideCloseButton = false,
align = 'center',
}: ModalProps) {
const handleEsc = useCallback((e: KeyboardEvent) => {
if (e.key === 'Escape') onClose()
@@ -63,7 +70,7 @@ export default function Modal({
// click-away, the panel only keeps that click from reaching it. Escape
// and the header's close button are the keyboard route out.
role="presentation"
className="fixed inset-0 z-[10000] flex items-start sm:items-center justify-center px-4 trek-modal-backdrop trek-backdrop-enter bg-[rgba(15,23,42,0.5)]"
className={`fixed inset-0 z-[10000] flex items-start ${align === 'top' ? '' : 'sm:items-center'} justify-center px-4 trek-modal-backdrop trek-backdrop-enter bg-[rgba(15,23,42,0.5)]`}
style={{ paddingTop: 70, paddingBottom: 'calc(20px + var(--bottom-nav-h))', overflow: 'hidden' }}
onMouseDown={e => { mouseDownTarget.current = e.target }}
onClick={e => {
@@ -3,8 +3,7 @@ import MSheet from '../../../components/MSheet'
import { ReorderStack } from '../plan/MPlanTimelineRows'
import { INNER_CLS, TileHeader } from './MTripSheetUi'
import { useTranslation } from '../../../../i18n'
import { dayLabel } from '../../../../utils/dayLabel'
import { formatDate } from '../../../../utils/formatters'
import { dayDate, dayLabel } from '../../../../utils/dayLabel'
import type { MTripSheetsProps } from '../MTripShell'
/** A 44px pill, the tap size of the sheet's other actions; the label never wraps, the row does. */
@@ -21,7 +20,9 @@ const PILL_QUIET = `${PILL} border border-[color:var(--m-rowbr)] bg-[color:var(-
*
* Adding sits under the list and stays in reach while the list scrolls: on a
* trip with dates the next date, which extends the trip, and a day without a
* date, with one line above them saying what the dated one does.
* date, with one line above them saying what the dated one does. The dated
* pill shows only its date beside the calendar icon, so both pills stand side
* by side in every language; its accessible name keeps the whole action.
*/
export default function MDaysSheet({ planner, shell }: MTripSheetsProps) {
const { t, locale } = useTranslation()
@@ -31,7 +32,7 @@ export default function MDaysSheet({ planner, shell }: MTripSheetsProps) {
const deleteBlocked = planner.deleteDayBlocked
const { dayAdd } = planner
const addOff = dayAdd.busy || !!dayAdd.blocked
const date = dayAdd.nextDate ? formatDate(dayAdd.nextDate, locale) ?? dayAdd.nextDate : null
const date = dayAdd.nextDate ? dayDate(dayAdd.nextDate, locale) ?? dayAdd.nextDate : null
// The lines above the buttons: why a day cannot be deleted, and what the
// dated button does or why adding is off. Offline both say the same
// sentence; it is shown once.
@@ -83,7 +84,8 @@ export default function MDaysSheet({ planner, shell }: MTripSheetsProps) {
disabled={!!deleteBlocked}
aria-label={t('dayplan.deleteDay')}
title={deleteBlocked ?? undefined}
className="flex h-[30px] w-[30px] flex-none items-center justify-center rounded-full bg-[color:var(--m-ic)] text-m-muted active:text-[color:var(--m-st-danger)] disabled:opacity-30"
// 30px to the eye, 44px to the finger: the pseudo-element widens the hit area to the row's height.
className="relative flex h-[30px] w-[30px] flex-none items-center justify-center rounded-full bg-[color:var(--m-ic)] text-m-muted after:absolute after:-inset-[7px] active:text-[color:var(--m-st-danger)] disabled:opacity-30"
>
<Trash2 size={13} strokeWidth={2.2} />
</button>
@@ -101,9 +103,15 @@ export default function MDaysSheet({ planner, shell }: MTripSheetsProps) {
<div className="flex flex-wrap gap-[8px]">
{date ? (
<>
<button type="button" onClick={dayAdd.onAddDated} disabled={addOff || !!dayAdd.datedBlocked} className={PILL_MAIN}>
<CalendarPlus size={15} strokeWidth={2.2} />
{t('dayplan.addDatedDay', { date })}
<button
type="button"
onClick={dayAdd.onAddDated}
disabled={addOff || !!dayAdd.datedBlocked}
aria-label={t('dayplan.addDatedDay', { date })}
className={PILL_MAIN}
>
<CalendarPlus size={15} strokeWidth={2.2} aria-hidden="true" />
{date}
</button>
<button type="button" onClick={() => planner.handleAddDay()} disabled={addOff} className={PILL_QUIET}>
<Plus size={15} strokeWidth={2.2} />
+2 -2
View File
@@ -2,7 +2,7 @@ import { useCallback, useMemo, useRef, useState } from 'react'
import { useTripStore } from '../../store/tripStore'
import { useNetworkMode } from '../../hooks/useNetworkMode'
import { datedDayOption, type DayAddControls } from '../../utils/dayAdd'
import { formatDate } from '../../utils/formatters'
import { dayDate } from '../../utils/dayLabel'
import type { Day, Trip } from '../../types'
type Translate = (key: string, params?: Record<string, string | number>) => string
@@ -65,7 +65,7 @@ export function useDayAdd(options: DayAddOptions): DayAdd {
const onAddDated = useCallback(() => {
if (!canEditDays || blocked || datedBlocked || !nextDate) return
runOnce(() => useTripStore.getState().appendDatedDay(tripId).then(
day => { toast.success(t('dayplan.tripExtended', { date: formatDate(day.date ?? nextDate, locale) ?? nextDate })) },
day => { toast.success(t('dayplan.tripExtended', { date: dayDate(day.date ?? nextDate, locale) ?? nextDate })) },
(err: unknown) => {
// The server's sentences are English, and the button already keeps a trip
// without dates or at the day limit from asking, so a refusal here is a
+2 -3
View File
@@ -3,8 +3,7 @@ import { useTripStore } from '../../store/tripStore'
import { useNetworkMode } from '../../hooks/useNetworkMode'
import { dayDeleteImpact, deleteDayBlockedReason } from '../../utils/dayDeleteImpact'
import { deleteDayLines as buildDeleteDayLines, type DayDeleteQuestion, type ImpactLine } from '../../utils/dayImpactLines'
import { dayLabel } from '../../utils/dayLabel'
import { formatDate } from '../../utils/formatters'
import { dayDate, dayLabel } from '../../utils/dayLabel'
import type { Accommodation, Day, Place, Reservation, Trip } from '../../types'
type Translate = (key: string, params?: Record<string, string | number>) => string
@@ -77,7 +76,7 @@ export function useDayDelete(options: DayDeleteOptions): DayDelete {
if (!target) return []
const budget = { items: budgetItems, currency: trip?.currency, locale }
const impact = dayDeleteImpact(target, days, { assignments, dayNotes, reservations, accommodations, places, budget }, trip)
return buildDeleteDayLines(impact, t, iso => formatDate(iso, locale) ?? iso, (day, i) => dayLabel(day, i, t, locale))
return buildDeleteDayLines(impact, t, iso => dayDate(iso, locale) ?? iso, (day, i) => dayLabel(day, i, t, locale))
}, [target, days, assignments, dayNotes, reservations, accommodations, places, budgetItems, trip, t, locale])
const handleDeleteDay = useCallback((dayId: number) => {
@@ -2509,7 +2509,7 @@ describe('useTripPlanner: deleting a day', () => {
expect(lines[0].hint).toContain('together with the booking “Alma, 1 night” and its expense of')
expect(lines[0].hint).toContain('95')
expect(lines[1].text).toBe('Stay at Harbour Hotel: one night less')
expect(lines[1].hint).toMatch(/^It runs across this day and now checks out on .*Jun 2/)
expect(lines[1].hint).toMatch(/^Check-out is now on .*Jun 2, as it runs across this day\.$/)
expect(lines[4].text).toMatch(/^Day 4 takes the date .*Jun 3/)
})
})
+12 -2
View File
@@ -86,6 +86,16 @@ const byNumber = (a: Day, b: Day): number => (a.day_number ?? 0) - (b.day_number
/** 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))
/**
* Each stay once. The stays list joins in the bookings, so a stay with two of
* them comes over the wire twice; the warning names it once all the same, and
* counts its bookings through describeStay.
*/
function distinctStays(stays: Accommodation[]): Accommodation[] {
const seen = new Set<number>()
return stays.filter(stay => !seen.has(stay.id) && seen.add(stay.id))
}
/** The bookings linked to a stay, its first booking title, and the name the list gives it. */
function describeStay(stay: Accommodation, data: DayContentData) {
const linked = data.reservations.filter(r => stayOf(r) === stay.id)
@@ -121,7 +131,7 @@ function stayExpense(linked: Reservation[], budget: DayContentBudget | undefined
*/
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 stays = distinctStays(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>()
@@ -188,7 +198,7 @@ export function dayDeleteImpact(
return start || end
}).length
const shortenedStays = data.accommodations.flatMap((stay): ShortenedStay[] => {
const shortenedStays = distinctStays(data.accommodations).flatMap((stay): ShortenedStay[] => {
const from = position.get(stay.start_day_id)
const to = position.get(stay.end_day_id)
if (cancelled.has(stay.id) || from === undefined || to === undefined || from >= at || to <= at) return []
+14 -7
View File
@@ -13,11 +13,18 @@ type Translate = (key: string, params?: Record<string, string | number>) => stri
*/
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 })
return (day.date && dayDate(day.date, locale)) || t('dayplan.dayN', { n: index + 1 })
}
/**
* A calendar date the way the day list writes it: short weekday, day, month,
* and no year, which the trip around it already says. Anything that talks
* about the days in that list (the next day to add, a moved check-out, the new
* end of the trip) writes its dates this way too, so a date reads the same in
* the button as in the row it becomes. Null when the value does not parse.
*/
export function dayDate(iso: string, locale: string): string | null {
const date = new Date(`${iso.slice(0, 10)}T00:00:00`)
if (Number.isNaN(date.getTime())) return null
return date.toLocaleDateString(locale, { weekday: 'short', day: 'numeric', month: 'short' })
}
@@ -153,8 +153,8 @@ describe('MDaysSheet', () => {
it('FE-MOB-DAYSS-016: two buttons, the next date and a day without one, under a line saying what the dated one does', () => {
const { planner } = renderSheet({ dayAdd: dayAdd() })
// formatDate adds the year once the date lies outside the current year.
expect(screen.getByText(/^Extends the trip by one day, until .*Oct 13(, \d{4})?\.$/)).toBeInTheDocument()
// The date reads as in the day list: weekday, day and month, no year.
expect(screen.getByText(/^Adds .*Oct 13 and extends the trip by one day\.$/)).toBeInTheDocument()
expect(screen.queryByRole('button', { name: 'Add day' })).not.toBeInTheDocument()
fireEvent.click(datedButton())
@@ -182,7 +182,7 @@ describe('MDaysSheet', () => {
expect(undatedButton()).toBeDisabled()
expect(screen.getAllByText(offline)).toHaveLength(1)
// The offline sentence stands in for the dated line, not beside it.
expect(screen.queryByText(/^Extends the trip/)).not.toBeInTheDocument()
expect(screen.queryByText(/and extends the trip by one day/)).not.toBeInTheDocument()
})
it('FE-MOB-DAYSS-019: a trip at the day limit keeps the undated button and says why the dated one is off', () => {