mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-18 21:16:47 +00:00
fix(mobile): a stay chip in the timeline header opens the stay, not the day (#2210)
The hotel chips next to the day pill shared its target, so tapping a stay opened the day sheet. A chip now carries its stay and opens it the way the stay card in the day sheet does: the accommodation editor for members who may edit days, otherwise the hotel's place, and the day sheet only when neither applies. Closing the editor returns to the timeline it was opened from instead of surfacing the day sheet, and the chip's accessible name says whether it is a check-in, a check-out or an ongoing stay.
This commit is contained in:
@@ -40,7 +40,8 @@ export type MTripCollabTab = 'chat' | 'notes' | 'polls'
|
||||
/**
|
||||
* Currently open bottom/floating sheet. Well-known ids (owned by the sheets
|
||||
* screen): 'day' (payload { dayId }), 'days', 'mehr', 'note' (payload
|
||||
* { dayId, note? }), 'transport' (payload { reservationId }), 'bract'
|
||||
* { dayId, note? }), 'accommodation' (payload { dayId?, accId?, from? }),
|
||||
* 'transport' (payload { reservationId }), 'bract'
|
||||
* (payload { placeId, dayPicker? }), 'import', 'export', 'members',
|
||||
* 'tripedit', 'bags', 'task'. The place inspector keys off the planner's
|
||||
* place selection instead of a sheet id, and planner-backed editors (place
|
||||
|
||||
@@ -10,7 +10,7 @@ import { fmtTransitDuration } from '../../../../components/Planner/transitDispla
|
||||
import { formatTime } from '../../../../utils/formatters'
|
||||
import { useMPlanTimeline, type MPlanTimelineController } from './useMPlanTimeline'
|
||||
import { cityPillsForDay, weatherIconFor } from './planTimelineModel'
|
||||
import type { PlanRow } from './planTimelineModel'
|
||||
import type { HotelChip, PlanRow } from './planTimelineModel'
|
||||
import { useMPlanDragReorder } from './useMPlanDragReorder'
|
||||
import { useTouchDragBridge } from '../../../../hooks/useTouchDragBridge'
|
||||
import { useIsTouch } from '../../../../hooks/useIsTouch'
|
||||
@@ -56,6 +56,21 @@ export default function MPlanTimeline({ planner, shell }: MPlanTimelineProps) {
|
||||
const daySchedule = usePluginDaySchedule(planner.tripId)
|
||||
const day = tl.day
|
||||
const dayId = day?.id
|
||||
// A stay chip opens the stay, the same way the stay card in the day sheet
|
||||
// does: the editor for members who may edit days, otherwise the hotel's
|
||||
// place. A stay with neither still leads to the day sheet, so the chip
|
||||
// never goes dead (#2210).
|
||||
const openStay = (chip: HotelChip) => {
|
||||
if (!day) return
|
||||
if (canEdit) shell.openSheet('accommodation', { dayId: day.id, accId: chip.accId, from: 'timeline' })
|
||||
else if (chip.placeId != null) planner.handlePlaceClick(chip.placeId)
|
||||
else shell.openSheet('day', { dayId: day.id })
|
||||
}
|
||||
// The icon alone tells check-out from check-in; the accessible name says it.
|
||||
const stayLabel = (chip: HotelChip) => {
|
||||
const kind = chip.variant === 'checkin' ? t('day.checkIn') : chip.variant === 'checkout' ? t('day.checkOut') : t('mobileTrip.stay')
|
||||
return `${kind} · ${chip.name}${chip.time ? ` · ${chip.time.slice(0, 5)}` : ''}`
|
||||
}
|
||||
// Mirrors MDaySheet's own label so the pill and the sheet it opens agree.
|
||||
const dayLabel = day
|
||||
? day.title || t('planner.dayN', { n: day.day_number || planner.days.indexOf(day) + 1 })
|
||||
@@ -159,6 +174,8 @@ export default function MPlanTimeline({ planner, shell }: MPlanTimelineProps) {
|
||||
? `${t('day.overview')} · ${t('day.weatherFor', { name: tl.weatherPlaceName })}`
|
||||
: undefined}
|
||||
onOpenDay={() => shell.openSheet('day', { dayId: day.id })}
|
||||
onOpenStay={openStay}
|
||||
stayLabel={stayLabel}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -396,16 +413,20 @@ function EditHeader({ tl, planner, shell }: {
|
||||
* (check-out / check-in / stay) and the weather chip.
|
||||
*
|
||||
* The day pill leads unconditionally. It used to be the accommodation chip that
|
||||
* carried this, which left a day without a stay — and, with no weather either,
|
||||
* the whole header — with no way into the day sheet at all (#2004).
|
||||
* carried this, which left a day without a stay (and, with no weather either,
|
||||
* the whole header) with no way into the day sheet at all (#2004). Since then
|
||||
* the stay chips have a target of their own: tapping a hotel opens that stay,
|
||||
* not the day (#2210). Only the weather chip still shares the day pill's target.
|
||||
*/
|
||||
function TimelineHeader({ tl, dayLabel, openLabel, weatherLabel, onOpenDay }: {
|
||||
function TimelineHeader({ tl, dayLabel, openLabel, weatherLabel, onOpenDay, onOpenStay, stayLabel }: {
|
||||
tl: MPlanTimelineController
|
||||
dayLabel: string
|
||||
openLabel: string
|
||||
/** Weather-chip label naming the forecast's anchor place (#2167); falls back to openLabel. */
|
||||
weatherLabel?: string
|
||||
onOpenDay: () => void
|
||||
onOpenStay: (chip: HotelChip) => void
|
||||
stayLabel: (chip: HotelChip) => string
|
||||
}) {
|
||||
const WeatherIcon = weatherIconFor(tl.weather?.main)
|
||||
return (
|
||||
@@ -428,7 +449,8 @@ function TimelineHeader({ tl, dayLabel, openLabel, weatherLabel, onOpenDay }: {
|
||||
<button
|
||||
key={chip.key}
|
||||
type="button"
|
||||
onClick={onOpenDay}
|
||||
onClick={() => onOpenStay(chip)}
|
||||
aria-label={stayLabel(chip)}
|
||||
className="flex flex-none items-center gap-[5px] whitespace-nowrap rounded-full bg-[color:var(--m-ic)] px-2.5 py-1 font-geist text-[0.6875rem] font-semibold"
|
||||
>
|
||||
<HotelChipIcon variant={chip.variant} />
|
||||
|
||||
@@ -153,6 +153,9 @@ export interface HotelChip {
|
||||
variant: 'checkout' | 'checkin' | 'stay'
|
||||
name: string
|
||||
time: string | null
|
||||
/** The stay behind the chip, so a tap can open it rather than the day (#2210). */
|
||||
accId: number
|
||||
placeId: number | null
|
||||
}
|
||||
|
||||
const accommodationName = (a: Accommodation): string => a.place_name || a.reservation_title || ''
|
||||
@@ -162,12 +165,13 @@ export function hotelChipsForDay(day: Day, days: Day[], accommodations: Accommod
|
||||
const inRange = accommodations.filter(a => isDayInAccommodationRange(day, a.start_day_id, a.end_day_id, days))
|
||||
const chips: HotelChip[] = []
|
||||
for (const a of inRange) {
|
||||
const stay = { name: accommodationName(a), accId: a.id, placeId: a.place_id ?? null }
|
||||
if (a.end_day_id === day.id) {
|
||||
chips.push({ key: `out-${a.id}`, variant: 'checkout', name: accommodationName(a), time: a.check_out || null })
|
||||
chips.push({ ...stay, key: `out-${a.id}`, variant: 'checkout', time: a.check_out || null })
|
||||
} else if (a.start_day_id === day.id) {
|
||||
chips.push({ key: `in-${a.id}`, variant: 'checkin', name: accommodationName(a), time: a.check_in || null })
|
||||
chips.push({ ...stay, key: `in-${a.id}`, variant: 'checkin', time: a.check_in || null })
|
||||
} else {
|
||||
chips.push({ key: `stay-${a.id}`, variant: 'stay', name: accommodationName(a), time: null })
|
||||
chips.push({ ...stay, key: `stay-${a.id}`, variant: 'stay', time: null })
|
||||
}
|
||||
}
|
||||
const rank = { checkout: 0, checkin: 1, stay: 2 }
|
||||
|
||||
@@ -13,6 +13,8 @@ interface AccommodationPayload {
|
||||
dayId?: number
|
||||
/** Present = edit an existing accommodation, absent = add. */
|
||||
accId?: number
|
||||
/** Opened from the timeline's stay chip: closing returns there, not to the day sheet (#2210). */
|
||||
from?: 'timeline'
|
||||
}
|
||||
|
||||
interface HotelForm {
|
||||
@@ -72,10 +74,14 @@ export default function MAccommodationSheet({ planner, shell }: MTripSheetsProps
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [open, payload.dayId, payload.accId])
|
||||
|
||||
// Cancelling / saving returns to the day sheet it was opened from — opened for
|
||||
// an edit there is no dayId in the payload, so fall back to the stay's own
|
||||
// start day instead of leaving the day sheet without one.
|
||||
const back = () => shell.openSheet('day', { dayId: payload.dayId ?? editing?.start_day_id })
|
||||
// Cancelling / saving returns to the day sheet it was opened from. Opened for
|
||||
// an edit there may be no dayId in the payload, so fall back to the stay's own
|
||||
// start day instead of leaving the day sheet without one. Opened straight from
|
||||
// the timeline's stay chip there is no day sheet to return to, so just close.
|
||||
const back = () => {
|
||||
if (payload.from === 'timeline') shell.closeSheet()
|
||||
else shell.openSheet('day', { dayId: payload.dayId ?? editing?.start_day_id })
|
||||
}
|
||||
|
||||
const firstId = days[0]?.id
|
||||
const lastId = days[days.length - 1]?.id
|
||||
|
||||
@@ -189,6 +189,24 @@ describe('MAccommodationSheet', () => {
|
||||
expect(shell.openSheet).toHaveBeenCalledWith('day', { dayId: 12 })
|
||||
})
|
||||
|
||||
it('FE-MOB-ACCSH-011c: opened from the timeline chip, cancel and X close instead of reopening the day (#2210)', () => {
|
||||
const { shell } = setup(makePlanner(), makeShell({ dayId: 12, accId: 77, from: 'timeline' }))
|
||||
expect(screen.getByRole('dialog', { name: 'Edit accommodation' })).toBeInTheDocument()
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Cancel' }))
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Close' }))
|
||||
expect(shell.closeSheet).toHaveBeenCalledTimes(2)
|
||||
expect(shell.openSheet).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('FE-MOB-ACCSH-011d: a save from the timeline lands back on the timeline', async () => {
|
||||
vi.spyOn(accommodationsApi, 'update').mockResolvedValue({})
|
||||
const { planner, shell } = setup(makePlanner(), makeShell({ dayId: 12, accId: 77, from: 'timeline' }))
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Save' }))
|
||||
await waitFor(() => expect(planner.loadAccommodations).toHaveBeenCalled())
|
||||
expect(shell.closeSheet).toHaveBeenCalledTimes(1)
|
||||
expect(shell.openSheet).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('FE-MOB-ACCSH-012: the All chip spans the whole trip and reaches the payload', async () => {
|
||||
const create = vi.spyOn(accommodationsApi, 'create').mockResolvedValue({})
|
||||
setup()
|
||||
|
||||
@@ -211,14 +211,38 @@ describe('MPlanTimeline', () => {
|
||||
expect(screen.getByTitle('Shibuya')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-MOB-PLTL-006: a chip opens the day sheet', () => {
|
||||
it('FE-MOB-PLTL-006: a stay chip opens the stay editor for members who may edit days (#2210)', () => {
|
||||
const { shell } = renderTimeline({
|
||||
hotelChips: [{ key: 'stay-3', variant: 'stay', name: 'Capsule Tokyo', time: null }],
|
||||
hotelChips: [{ key: 'in-3', variant: 'checkin', name: 'Capsule Tokyo', time: '15:00:00', accId: 3, placeId: 301 }],
|
||||
})
|
||||
|
||||
fireEvent.click(screen.getByText('Capsule Tokyo'))
|
||||
fireEvent.click(screen.getByRole('button', { name: 'day.checkIn · Capsule Tokyo · 15:00' }))
|
||||
|
||||
expect(shell.openSheet).toHaveBeenCalledWith('accommodation', { dayId: 2, accId: 3, from: 'timeline' })
|
||||
})
|
||||
|
||||
it('FE-MOB-PLTL-006b: without day_edit the chip opens the hotel place instead', () => {
|
||||
const { planner, shell } = renderTimeline(
|
||||
{ hotelChips: [{ key: 'stay-3', variant: 'stay', name: 'Capsule Tokyo', time: null, accId: 3, placeId: 301 }] },
|
||||
{ can: vi.fn(() => false) },
|
||||
)
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'mobileTrip.stay · Capsule Tokyo' }))
|
||||
|
||||
expect(planner.handlePlaceClick).toHaveBeenCalledWith(301)
|
||||
expect(shell.openSheet).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('FE-MOB-PLTL-006c: a stay without a place still leads a read-only member to the day sheet', () => {
|
||||
const { planner, shell } = renderTimeline(
|
||||
{ hotelChips: [{ key: 'out-3', variant: 'checkout', name: 'Capsule Tokyo', time: '11:00', accId: 3, placeId: null }] },
|
||||
{ can: vi.fn(() => false) },
|
||||
)
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'day.checkOut · Capsule Tokyo · 11:00' }))
|
||||
|
||||
expect(shell.openSheet).toHaveBeenCalledWith('day', { dayId: 2 })
|
||||
expect(planner.handlePlaceClick).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('FE-MOB-PLTL-007: still renders, with the day pill, without chips or weather (#2004)', () => {
|
||||
|
||||
@@ -259,16 +259,16 @@ describe('planTimelineModel — buildPlanRows', () => {
|
||||
})
|
||||
|
||||
describe('planTimelineModel — hotel chips and legs', () => {
|
||||
it('FE-MOB-PTLM-022: orders check-out before check-in before an ongoing stay', () => {
|
||||
it('FE-MOB-PTLM-022: orders check-out before check-in before an ongoing stay, each chip naming its stay (#2210)', () => {
|
||||
const chips = hotelChipsForDay(DAY2, DAYS, [
|
||||
accommodation({ id: 1, start_day_id: 1, end_day_id: 3, place_name: 'Long Stay' }),
|
||||
accommodation({ id: 2, start_day_id: 2, end_day_id: 3, place_name: 'New Hotel', check_in: '15:00' }),
|
||||
accommodation({ id: 2, start_day_id: 2, end_day_id: 3, place_name: 'New Hotel', check_in: '15:00', place_id: 102 }),
|
||||
accommodation({ id: 3, start_day_id: 1, end_day_id: 2, place_name: 'Old Hotel', check_out: '11:00' }),
|
||||
])
|
||||
expect(chips).toEqual([
|
||||
{ key: 'out-3', variant: 'checkout', name: 'Old Hotel', time: '11:00' },
|
||||
{ key: 'in-2', variant: 'checkin', name: 'New Hotel', time: '15:00' },
|
||||
{ key: 'stay-1', variant: 'stay', name: 'Long Stay', time: null },
|
||||
{ key: 'out-3', variant: 'checkout', name: 'Old Hotel', time: '11:00', accId: 3, placeId: null },
|
||||
{ key: 'in-2', variant: 'checkin', name: 'New Hotel', time: '15:00', accId: 2, placeId: 102 },
|
||||
{ key: 'stay-1', variant: 'stay', name: 'Long Stay', time: null, accId: 1, placeId: null },
|
||||
])
|
||||
})
|
||||
|
||||
@@ -277,7 +277,7 @@ describe('planTimelineModel — hotel chips and legs', () => {
|
||||
accommodation({ id: 4, place_name: null, reservation_title: 'Airbnb Wieden' }),
|
||||
accommodation({ id: 5, place_name: null, reservation_title: null }),
|
||||
])
|
||||
expect(chips).toEqual([{ key: 'stay-4', variant: 'stay', name: 'Airbnb Wieden', time: null }])
|
||||
expect(chips).toEqual([{ key: 'stay-4', variant: 'stay', name: 'Airbnb Wieden', time: null, accId: 4, placeId: null }])
|
||||
})
|
||||
|
||||
it('FE-MOB-PTLM-042: leaves the chip time empty when the stay has no check-in or check-out', () => {
|
||||
|
||||
@@ -133,7 +133,7 @@ describe('useMPlanTimeline', () => {
|
||||
expect(result.current.hotelLegs.top?.seg).toBe(out)
|
||||
expect(result.current.hotelLegs.bottom?.seg).toBe(back)
|
||||
expect(result.current.hotelChips).toEqual([
|
||||
{ key: 'stay-71', variant: 'stay', name: 'Hotel Sacher', time: null },
|
||||
{ key: 'stay-71', variant: 'stay', name: 'Hotel Sacher', time: null, accId: 71, placeId: null },
|
||||
])
|
||||
})
|
||||
|
||||
|
||||
Reference in New Issue
Block a user