mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-24 16:03:02 +00:00
15aefa449e
Everything here came out of putting the first commit in front of somebody and watching them use it. **The marker card is one card now, and it carries the entry's photos.** Leaflet showed a one-line label and the GL map a two-line card, so the same marker said different things depending on a renderer setting nobody connects to journeys. Both now build from one module: a strip of up to three thumbnails bleeding to the card's edges, the name, and the place and date as chips. On the GL map the thumbnails open the entry's photos; the rest of the card still never takes the pointer, so the map drags from anywhere on it. **A pin that would not sit still.** Dragging the map left the marker trailing behind and snapping back at the end. The app's press-feedback rule puts a 180ms transition on `[role="button"]`, MapLibre and Mapbox give their marker element exactly that role, and both rewrite its `transform` on every frame of a movement. Markers are excluded now — every map in TREK, not just this one. **The phone card, after four rounds of looking at it.** The day is the card's own edge rather than a bar across its foot, and the ring is inset because the carousel clips. A card without a photo is opaque: it was a wash of the day's colour over the map. The country is a real flag rather than two letters — Windows has no flag glyphs, so the emoji drew "DE" where a flag was meant, and the image falls back to the emoji when it cannot be fetched. Mood, weather and the date share the top edge; the flag sits opposite them. **The day bar answers to a finger.** Press the date and slide sideways to run through the days. The bar is still a row of buttons underneath, because that is the only version of it a keyboard can reach, and each one is 20px tall now rather than 3. Also here: - Videos upload to an entry (issue #2341). The gallery has taken them since #823 and an entry could not: everything went through the images-only filter and came back 400, reported as "1 of 1 photos failed". Same two-part upload, same absence of transcoding, and the editor now previews a clip in a `<video>` instead of a broken-image glyph. - The search box takes the gap the toolbar already had instead of clipping its own placeholder. - Entry cards line up with the day header again: the reorder arrows moved out of a column of their own into the feed's padding and appear on hover, and the day-coloured ring on the active card is a layer over it — an outline tight enough to sit on the card's edge was painting behind it. - Map attribution is collapsed to its ⓘ. It cannot go away — the OSM licence asks for it — but it does not have to lie under the carousel. - The suggestion dismiss and the day-header plus use TREK's own tooltip rather than the browser's.
352 lines
12 KiB
TypeScript
352 lines
12 KiB
TypeScript
// FE-COMP-JMAPTL-001 to FE-COMP-JMAPTL-016
|
|
|
|
const mapHandle = vi.hoisted(() => ({
|
|
highlightMarker: vi.fn(),
|
|
focusMarker: vi.fn(),
|
|
invalidateSize: vi.fn(),
|
|
}))
|
|
|
|
const lastMapProps = vi.hoisted(() => ({ current: {} as Record<string, unknown> }))
|
|
|
|
// The Leaflet map is exercised in JourneyMap.test.tsx — here it only needs to
|
|
// hand back its imperative handle and expose the props it was given.
|
|
vi.mock('./JourneyMap', async () => {
|
|
const React = await import('react')
|
|
return {
|
|
default: React.forwardRef(function MockJourneyMap(
|
|
props: Record<string, unknown>,
|
|
ref: React.ForwardedRef<typeof mapHandle>,
|
|
) {
|
|
lastMapProps.current = props
|
|
React.useImperativeHandle(ref, () => mapHandle, [])
|
|
return React.createElement('div', { 'data-testid': 'journey-map' })
|
|
}),
|
|
}
|
|
})
|
|
|
|
import userEvent from '@testing-library/user-event'
|
|
import { render, screen, fireEvent, act } from '../../../tests/helpers/render'
|
|
import { resetAllStores } from '../../../tests/helpers/store'
|
|
import MobileMapTimeline from './MobileMapTimeline'
|
|
import { DAY_COLORS } from './dayColors'
|
|
|
|
interface TimelineEntry {
|
|
id: number
|
|
type: string
|
|
title: string
|
|
entry_date: string
|
|
entry_time: string | null
|
|
location_name: string | null
|
|
location_lat: number | null
|
|
location_lng: number | null
|
|
mood: string | null
|
|
weather: string | null
|
|
photos: { photo_id: number }[]
|
|
story: string | null
|
|
}
|
|
|
|
function buildEntry(id: number, date: string, title: string): TimelineEntry {
|
|
return {
|
|
id,
|
|
type: 'entry',
|
|
title,
|
|
entry_date: date,
|
|
entry_time: null,
|
|
location_name: 'Somewhere',
|
|
location_lat: 48 + id,
|
|
location_lng: 2 + id,
|
|
mood: null,
|
|
weather: null,
|
|
photos: [],
|
|
story: null,
|
|
}
|
|
}
|
|
|
|
const entries = [
|
|
buildEntry(1, '2025-06-01', 'Louvre'),
|
|
buildEntry(2, '2025-06-02', 'Museumsinsel'),
|
|
buildEntry(3, '2025-06-02', 'Reichstag'),
|
|
]
|
|
|
|
const mapEntries = entries.map(e => ({
|
|
id: String(e.id),
|
|
lat: e.location_lat!,
|
|
lng: e.location_lng!,
|
|
title: e.title,
|
|
mood: null,
|
|
entry_date: e.entry_date,
|
|
}))
|
|
|
|
function fakeRect(left: number, width: number): DOMRect {
|
|
return {
|
|
left, width, right: left + width, top: 0, bottom: 0, height: 0, x: left, y: 0,
|
|
toJSON: () => ({}),
|
|
} as DOMRect
|
|
}
|
|
|
|
function carouselOf(container: HTMLElement) {
|
|
const carousel = container.querySelector('.overflow-x-auto') as HTMLElement
|
|
const cards = [...carousel.children] as HTMLElement[]
|
|
return { carousel, cards }
|
|
}
|
|
|
|
function renderTimeline(props: Partial<{
|
|
entries: TimelineEntry[]
|
|
onEntryClick: (entry: TimelineEntry) => void
|
|
onAddEntry: () => void
|
|
readOnly: boolean
|
|
carouselBottom: string
|
|
}> = {}) {
|
|
const onEntryClick = props.onEntryClick ?? vi.fn()
|
|
const result = render(
|
|
<MobileMapTimeline
|
|
entries={props.entries ?? entries}
|
|
mapEntries={mapEntries}
|
|
onEntryClick={onEntryClick}
|
|
onAddEntry={props.onAddEntry}
|
|
readOnly={props.readOnly}
|
|
carouselBottom={props.carouselBottom}
|
|
/>,
|
|
)
|
|
return { ...result, onEntryClick }
|
|
}
|
|
|
|
beforeEach(() => {
|
|
resetAllStores()
|
|
vi.clearAllMocks()
|
|
})
|
|
|
|
describe('MobileMapTimeline', () => {
|
|
it('FE-COMP-JMAPTL-001: renders one card per entry on top of a full-screen map', () => {
|
|
renderTimeline()
|
|
expect(screen.getByTestId('journey-map')).toBeInTheDocument()
|
|
// The strip below the map grew with the cover cards and the day bar (#2299), so
|
|
// the map keeps that much clear when it frames the journey.
|
|
expect(lastMapProps.current).toMatchObject({ height: 9999, fullScreen: true, paddingBottom: 250 })
|
|
expect(screen.getByText('Louvre')).toBeInTheDocument()
|
|
expect(screen.getByText('Museumsinsel')).toBeInTheDocument()
|
|
expect(screen.getByText('Reichstag')).toBeInTheDocument()
|
|
})
|
|
|
|
it('FE-COMP-JMAPTL-002: entries carry their day as a colour rather than as a number', () => {
|
|
// The number used to say which stop of the day this was, which is not a
|
|
// question anybody asks; the day bar answers the one they do (#2299). What
|
|
// survives is the colour, on a hairline at the foot of each card.
|
|
const { container } = renderTimeline()
|
|
const { cards } = carouselOf(container)
|
|
const ring = (card: HTMLElement) => (card.querySelector('button') as HTMLElement).style.boxShadow
|
|
|
|
expect(ring(cards[0])).toContain(DAY_COLORS[0])
|
|
expect(ring(cards[1])).toContain(DAY_COLORS[1])
|
|
expect(ring(cards[2])).toContain(DAY_COLORS[1])
|
|
})
|
|
|
|
it('FE-COMP-JMAPTL-002b: the day bar offers one segment per day and jumps to it', async () => {
|
|
const { container } = renderTimeline()
|
|
const segments = screen.getAllByRole('button').filter(b => b.getAttribute('aria-label')?.startsWith('Jump to'))
|
|
|
|
expect(segments).toHaveLength(2)
|
|
|
|
await userEvent.click(segments[1])
|
|
|
|
const { cards } = carouselOf(container)
|
|
expect(cards[1].querySelector('button')!.className).toContain('w-[164px]')
|
|
})
|
|
|
|
it('FE-COMP-JMAPTL-003: the first entry starts out active and drives the map highlight', () => {
|
|
renderTimeline()
|
|
expect(lastMapProps.current.activeMarkerId).toBe('1')
|
|
})
|
|
|
|
it('FE-COMP-JMAPTL-004: tapping an inactive card only activates it', () => {
|
|
const onEntryClick = vi.fn()
|
|
renderTimeline({ onEntryClick })
|
|
|
|
fireEvent.click(screen.getByText('Reichstag'))
|
|
|
|
expect(onEntryClick).not.toHaveBeenCalled()
|
|
expect(lastMapProps.current.activeMarkerId).toBe('3')
|
|
expect(Element.prototype.scrollIntoView).toHaveBeenCalledWith(
|
|
expect.objectContaining({ inline: 'center', behavior: 'smooth' }),
|
|
)
|
|
})
|
|
|
|
it('FE-COMP-JMAPTL-005: tapping the already active card opens the entry', () => {
|
|
const onEntryClick = vi.fn()
|
|
renderTimeline({ onEntryClick })
|
|
|
|
fireEvent.click(screen.getByText('Louvre'))
|
|
|
|
expect(onEntryClick).toHaveBeenCalledWith(entries[0])
|
|
})
|
|
|
|
it('FE-COMP-JMAPTL-006: clicking a map marker centres the matching card', () => {
|
|
renderTimeline()
|
|
const onMarkerClick = lastMapProps.current.onMarkerClick as (id: string) => void
|
|
|
|
act(() => { onMarkerClick('2') })
|
|
|
|
expect(lastMapProps.current.activeMarkerId).toBe('2')
|
|
expect(Element.prototype.scrollIntoView).toHaveBeenCalled()
|
|
})
|
|
|
|
it('FE-COMP-JMAPTL-007: a marker click for an unknown entry is ignored', () => {
|
|
renderTimeline()
|
|
const onMarkerClick = lastMapProps.current.onMarkerClick as (id: string) => void
|
|
|
|
act(() => { onMarkerClick('999') })
|
|
|
|
expect(lastMapProps.current.activeMarkerId).toBe('1')
|
|
expect(Element.prototype.scrollIntoView).not.toHaveBeenCalled()
|
|
})
|
|
|
|
it('FE-COMP-JMAPTL-008: the map is focused on the first entry once leaflet has settled', () => {
|
|
vi.useFakeTimers()
|
|
try {
|
|
renderTimeline()
|
|
expect(mapHandle.focusMarker).not.toHaveBeenCalled()
|
|
act(() => { vi.advanceTimersByTime(500) })
|
|
expect(mapHandle.focusMarker).toHaveBeenCalledWith('1')
|
|
} finally {
|
|
vi.useRealTimers()
|
|
}
|
|
})
|
|
|
|
it('FE-COMP-JMAPTL-008b: markers arriving after the entries still get the initial focus', () => {
|
|
vi.useFakeTimers()
|
|
try {
|
|
const onEntryClick = vi.fn()
|
|
const { rerender } = render(
|
|
<MobileMapTimeline entries={entries} mapEntries={[]} onEntryClick={onEntryClick} />,
|
|
)
|
|
act(() => { vi.advanceTimersByTime(500) })
|
|
expect(mapHandle.focusMarker).not.toHaveBeenCalled()
|
|
|
|
rerender(<MobileMapTimeline entries={entries} mapEntries={mapEntries} onEntryClick={onEntryClick} />)
|
|
act(() => { vi.advanceTimersByTime(500) })
|
|
|
|
expect(mapHandle.focusMarker).toHaveBeenCalledWith('1')
|
|
} finally {
|
|
vi.useRealTimers()
|
|
}
|
|
})
|
|
|
|
it('FE-COMP-JMAPTL-009: an entry that has no marker clears the highlight instead', () => {
|
|
vi.useFakeTimers()
|
|
try {
|
|
renderTimeline({ entries: [buildEntry(77, '2025-06-01', 'Unmapped')] })
|
|
act(() => { vi.advanceTimersByTime(500) })
|
|
expect(mapHandle.focusMarker).not.toHaveBeenCalled()
|
|
expect(mapHandle.highlightMarker).toHaveBeenCalledWith(null)
|
|
} finally {
|
|
vi.useRealTimers()
|
|
}
|
|
})
|
|
|
|
it('FE-COMP-JMAPTL-010: a settled carousel scroll activates the card nearest the centre', () => {
|
|
vi.useFakeTimers()
|
|
try {
|
|
const { container } = renderTimeline()
|
|
const { carousel, cards } = carouselOf(container)
|
|
Object.defineProperty(carousel, 'clientWidth', { value: 300, configurable: true })
|
|
carousel.getBoundingClientRect = () => fakeRect(0, 300)
|
|
cards[0].getBoundingClientRect = () => fakeRect(-400, 240)
|
|
cards[1].getBoundingClientRect = () => fakeRect(30, 240)
|
|
cards[2].getBoundingClientRect = () => fakeRect(320, 240)
|
|
|
|
fireEvent.scroll(carousel)
|
|
// nothing happens mid-swipe, only once scrolling settles
|
|
expect(mapHandle.focusMarker).not.toHaveBeenCalled()
|
|
act(() => { vi.advanceTimersByTime(200) })
|
|
|
|
expect(mapHandle.focusMarker).toHaveBeenCalledWith('2')
|
|
expect(lastMapProps.current.activeMarkerId).toBe('2')
|
|
} finally {
|
|
vi.useRealTimers()
|
|
}
|
|
})
|
|
|
|
it('FE-COMP-JMAPTL-011: repeated scrolls restart the settle timer', () => {
|
|
vi.useFakeTimers()
|
|
try {
|
|
const { container } = renderTimeline()
|
|
const { carousel, cards } = carouselOf(container)
|
|
Object.defineProperty(carousel, 'clientWidth', { value: 300, configurable: true })
|
|
carousel.getBoundingClientRect = () => fakeRect(0, 300)
|
|
cards.forEach((c, i) => { c.getBoundingClientRect = () => fakeRect(i === 2 ? 30 : -400, 240) })
|
|
|
|
fireEvent.scroll(carousel)
|
|
act(() => { vi.advanceTimersByTime(100) })
|
|
fireEvent.scroll(carousel)
|
|
act(() => { vi.advanceTimersByTime(100) })
|
|
expect(lastMapProps.current.activeMarkerId).toBe('1')
|
|
|
|
act(() => { vi.advanceTimersByTime(100) })
|
|
expect(lastMapProps.current.activeMarkerId).toBe('3')
|
|
} finally {
|
|
vi.useRealTimers()
|
|
}
|
|
})
|
|
|
|
it('FE-COMP-JMAPTL-012: settling on the card that was already active does not refocus the map', () => {
|
|
vi.useFakeTimers()
|
|
try {
|
|
const { container } = renderTimeline()
|
|
act(() => { vi.advanceTimersByTime(500) })
|
|
mapHandle.focusMarker.mockClear()
|
|
|
|
const { carousel, cards } = carouselOf(container)
|
|
Object.defineProperty(carousel, 'clientWidth', { value: 300, configurable: true })
|
|
carousel.getBoundingClientRect = () => fakeRect(0, 300)
|
|
cards.forEach((c, i) => { c.getBoundingClientRect = () => fakeRect(i === 0 ? 30 : 900, 240) })
|
|
|
|
fireEvent.scroll(carousel)
|
|
act(() => { vi.advanceTimersByTime(200) })
|
|
|
|
expect(mapHandle.focusMarker).not.toHaveBeenCalled()
|
|
} finally {
|
|
vi.useRealTimers()
|
|
}
|
|
})
|
|
|
|
it('FE-COMP-JMAPTL-013: the add button reports back and can be hidden', () => {
|
|
const onAddEntry = vi.fn()
|
|
const withFab = renderTimeline({ onAddEntry })
|
|
fireEvent.click(screen.getByRole('button', { name: '' }))
|
|
expect(onAddEntry).toHaveBeenCalledTimes(1)
|
|
withFab.unmount()
|
|
|
|
renderTimeline({ onAddEntry, readOnly: true })
|
|
expect(screen.queryByRole('button', { name: '' })).not.toBeInTheDocument()
|
|
})
|
|
|
|
it('FE-COMP-JMAPTL-014: carouselBottom positions the strip above the tab bar', () => {
|
|
const { container } = renderTimeline({ carouselBottom: '120px' })
|
|
const strip = container.querySelector('.z-40') as HTMLElement
|
|
expect(strip.style.bottom).toBe('120px')
|
|
})
|
|
|
|
it('FE-COMP-JMAPTL-015: an empty journey shows only the map and the add button', () => {
|
|
const onAddEntry = vi.fn()
|
|
const { container } = renderTimeline({ entries: [], onAddEntry })
|
|
|
|
expect(screen.getByTestId('journey-map')).toBeInTheDocument()
|
|
expect(container.querySelector('.overflow-x-auto')).toBeNull()
|
|
// no card is active, so the map gets no highlight to chase
|
|
expect(lastMapProps.current.activeMarkerId).toBeUndefined()
|
|
|
|
fireEvent.click(screen.getByRole('button', { name: '' }))
|
|
expect(onAddEntry).toHaveBeenCalledTimes(1)
|
|
})
|
|
|
|
it('FE-COMP-JMAPTL-016: an empty read-only journey has no add button', () => {
|
|
renderTimeline({ entries: [], onAddEntry: vi.fn(), readOnly: true })
|
|
expect(screen.queryByRole('button', { name: '' })).not.toBeInTheDocument()
|
|
})
|
|
|
|
it('FE-COMP-JMAPTL-017: without an add handler no button is rendered at all', () => {
|
|
renderTimeline({ entries: [] })
|
|
expect(screen.queryByRole('button', { name: '' })).not.toBeInTheDocument()
|
|
})
|
|
})
|