mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-21 22:46:32 +00:00
feat(journey): the enhancements from discussion #2299
roel-de-vries pitched seventeen changes to Journey. Four were already built or rest on a misunderstanding, one cannot work as described, and three would take away more than they give; the rest are here, plus two things the list did not ask for. **The entry you write on the move now has a name.** Quick capture hid the title field outright and offered a note instead, so every entry caught on a phone arrived nameless and a day's timeline read as a column of identical placeholders. One line of input; it is the single change in this branch that users will feel most. **The map stops yanking.** Stepping through the timeline called flyTo with a zoom floor of 12, so reading a journey from a country view snapped to street level on the first scroll and stayed there — every marker filling the screen, and no sense of where anything was. Both renderers pan now and leave the frame the reader chose. The initial fitBounds still decides how close a journey opens. **The phone timeline is photos.** A 64px thumbnail beside three lines of text spent the card on a story nobody reads at that size and truncated the title after a few characters. The picture is the card now, with the name and place on it, the country's flag and the date in the corners, and the day carried as a colour rather than as a number nobody was reading. Above the carousel, a segment per day: it says how long the journey is, how far in you are, and takes you to day nine without swiping past days three to eight. Everything else, briefly: - A plus in each day header, starting a draft on that day instead of on today. - A journey opens on today when today is part of it, and at the beginning when it is not — a journey that ended last spring is something you read. - Suggestions can be waved away one at a time. The row survives so the trip sync does not offer the same place again; journey settings brings them all back. - Mood, weather and the pros/cons list can be switched off per journey. Nothing written is erased — the form stops asking, the values stay. - The weather fills itself in at the desk the way it already did on the phone. The date picks the source, so writing up last Tuesday gets last Tuesday's. - Enter opens the next pro or con, below the one you are in. - The verdict moved behind the story's fold, and the fold itself came in from nine lines to four. - The external picker lost the count it was printing twice and some padding it could not afford inside the editor. - The phone can reorder entries within a day at last; the desktop arrows finally have translated labels. - Marker labels are off where a carousel already names the entry, and on where they are the only name a marker has. - Search over a journey's titles, stories, places and tags, accent-blind. - An entry knows which country it happened in, resolved once from its coordinates against the bundled boundaries. Not built, and why: the photos-for-markers idea needs clustering first or thirty markers become soup, and the active entry's photo is already on screen in the card below. Removing mood, weather and the verdict outright would take them from everyone who uses them, so they became switches. `capture` on the file picker takes the photo library away on a phone, which is why the camera is a second input; it is hidden on desktop now instead. The suggestion strip in the new-entry flow would be a third surface over the same data — tapping a suggestion already opens the editor filled in. The one gap the list missed and this branch does not close: Journey is the only domain with no offline path. That belongs in its own change.
This commit is contained in:
@@ -0,0 +1,54 @@
|
||||
// FE-JRN-SCRUBBER-001 to FE-JRN-SCRUBBER-005
|
||||
|
||||
import { describe, it, expect, vi } from 'vitest'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { render, screen } from '../../../tests/helpers/render'
|
||||
import JourneyDayScrubber from './JourneyDayScrubber'
|
||||
|
||||
const DAYS = [
|
||||
{ date: '2026-03-15', color: '#6366f1' },
|
||||
{ date: '2026-03-16', color: '#f97316' },
|
||||
{ date: '2026-03-17', color: '#14b8a6' },
|
||||
]
|
||||
|
||||
describe('JourneyDayScrubber', () => {
|
||||
it('FE-JRN-SCRUBBER-001: one segment per day', () => {
|
||||
render(<JourneyDayScrubber days={DAYS} activeDate="2026-03-15" onPick={() => {}} />)
|
||||
|
||||
expect(screen.getAllByRole('button')).toHaveLength(3)
|
||||
})
|
||||
|
||||
it('FE-JRN-SCRUBBER-002: says which day you are on, and how far in', () => {
|
||||
render(<JourneyDayScrubber days={DAYS} activeDate="2026-03-16" onPick={() => {}} />)
|
||||
|
||||
expect(screen.getByText('Day 2')).toBeInTheDocument()
|
||||
// The long date is the label the reader was missing on the cards.
|
||||
expect(screen.getByText(/16/)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-JRN-SCRUBBER-003: marks the day it is on', () => {
|
||||
render(<JourneyDayScrubber days={DAYS} activeDate="2026-03-17" onPick={() => {}} />)
|
||||
|
||||
const marked = screen.getAllByRole('button').filter(b => b.getAttribute('aria-current') === 'true')
|
||||
expect(marked).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('FE-JRN-SCRUBBER-004: a tap asks for that day', async () => {
|
||||
const onPick = vi.fn()
|
||||
render(<JourneyDayScrubber days={DAYS} activeDate="2026-03-15" onPick={onPick} />)
|
||||
|
||||
await userEvent.click(screen.getAllByRole('button')[2])
|
||||
|
||||
expect(onPick).toHaveBeenCalledWith('2026-03-17')
|
||||
})
|
||||
|
||||
it('FE-JRN-SCRUBBER-005: a one-day journey has nothing to scrub', () => {
|
||||
// The bar would say only what the card's own date already says, and it would
|
||||
// take a row of the map to do it.
|
||||
const { container } = render(
|
||||
<JourneyDayScrubber days={[DAYS[0]]} activeDate="2026-03-15" onPick={() => {}} />,
|
||||
)
|
||||
|
||||
expect(container).toBeEmptyDOMElement()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,74 @@
|
||||
import { useTranslation } from '../../i18n'
|
||||
import type { JourneyDay } from './journeyCard'
|
||||
|
||||
/**
|
||||
* The day bar above the phone timeline.
|
||||
*
|
||||
* A horizontal carousel of cards tells you what happened but never where you are
|
||||
* in the trip: scrolling for a while gives no sense of having crossed from the
|
||||
* second day into the third, and there is no way to reach day nine except by
|
||||
* swiping past days three to eight (discussion #2299). One segment per day fixes
|
||||
* both — it says how long the journey is, how far in you are, and it takes you
|
||||
* anywhere in one tap.
|
||||
*
|
||||
* The segment colours are the same `DAY_COLORS` the map markers and the card
|
||||
* edges use, so the bar is a legend for the whole screen rather than a fourth
|
||||
* thing to learn.
|
||||
*/
|
||||
|
||||
interface Props {
|
||||
days: JourneyDay[]
|
||||
activeDate: string | null
|
||||
onPick: (date: string) => void
|
||||
}
|
||||
|
||||
export default function JourneyDayScrubber({ days, activeDate, onPick }: Props) {
|
||||
const { t, locale } = useTranslation()
|
||||
// One day is not a journey to scrub through, and the bar would say nothing the
|
||||
// card's own date does not already say.
|
||||
if (days.length < 2) return null
|
||||
|
||||
const long = (date: string) =>
|
||||
new Date(date + 'T00:00:00').toLocaleDateString(locale, { weekday: 'short', day: 'numeric', month: 'long' })
|
||||
|
||||
const activeIndex = activeDate ? days.findIndex(d => d.date === activeDate) : -1
|
||||
const shown = activeIndex >= 0 ? days[activeIndex] : days[0]
|
||||
|
||||
return (
|
||||
<div className="pointer-events-auto px-4 pb-[6px]">
|
||||
<div className="mb-[5px] flex items-baseline gap-2">
|
||||
<span className="rounded-full bg-black/55 px-[9px] py-[3px] text-[11px] font-bold whitespace-nowrap text-white backdrop-blur-[3px]">
|
||||
{long(shown.date)}
|
||||
</span>
|
||||
{activeIndex >= 0 && (
|
||||
<span className="text-[10px] font-semibold text-white/70 [text-shadow:0_1px_3px_rgba(0,0,0,.65)]">
|
||||
{t('journey.detail.day', { number: String(activeIndex + 1) })}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{/* Buttons rather than one draggable rail: a 9px-wide drag target on a map that
|
||||
also pans is a fight, and a tap is the whole gesture anyway. */}
|
||||
<div className="flex items-end gap-[3px]">
|
||||
{days.map(day => {
|
||||
const on = day.date === shown.date
|
||||
return (
|
||||
<button
|
||||
key={day.date}
|
||||
type="button"
|
||||
onClick={() => onPick(day.date)}
|
||||
aria-label={t('journey.detail.jumpToDay', { date: long(day.date) })}
|
||||
aria-current={on ? 'true' : undefined}
|
||||
className="min-w-[6px] flex-1 rounded-full transition-all duration-150"
|
||||
style={{
|
||||
height: on ? 6 : 3,
|
||||
background: day.color,
|
||||
opacity: on ? 1 : 0.42,
|
||||
boxShadow: on ? `0 0 0 1.5px rgba(255,255,255,.75)` : '0 0 0 1px rgba(0,0,0,.18)',
|
||||
}}
|
||||
/>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -126,10 +126,14 @@ describe('EntryCard', () => {
|
||||
expect(screen.getByText('food')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-JRN-CARD-009: renders the pros/cons verdict and the story body', () => {
|
||||
it('FE-JRN-CARD-009: renders the story body, and the verdict once the fold is opened', async () => {
|
||||
// The verdict moved behind the fold in discussion #2299 — see FE-JRN-CARD-020.
|
||||
mountCard(buildEntry({ story: 'A wonderful evening', pros_cons: { pros: ['Great food'], cons: ['Crowded'] } }))
|
||||
|
||||
expect(screen.getByText('A wonderful evening')).toBeInTheDocument()
|
||||
|
||||
await userEvent.click(screen.getByText('Show more'))
|
||||
|
||||
expect(screen.getByText('Great food')).toBeInTheDocument()
|
||||
expect(screen.getByText('Crowded')).toBeInTheDocument()
|
||||
})
|
||||
@@ -258,3 +262,62 @@ describe('CheckinCard', () => {
|
||||
expect(container.querySelector('.cursor-pointer')).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
describe('the fold (discussion #2299)', () => {
|
||||
it('FE-JRN-CARD-020: the verdict rides behind the story fold', async () => {
|
||||
// A feed of open pro/con tables reads as a spreadsheet. The verdict belongs to
|
||||
// one entry, so it comes out when that entry is opened.
|
||||
render(
|
||||
<EntryCard
|
||||
entry={buildEntry({ story: 'A short line.', pros_cons: { pros: ['Great coffee'], cons: ['Loud'] } })}
|
||||
onEdit={() => {}}
|
||||
onDelete={() => {}}
|
||||
onPhotoClick={() => {}}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.queryByText('Great coffee')).toBeNull()
|
||||
|
||||
await userEvent.click(screen.getByText('Show more'))
|
||||
|
||||
expect(screen.getByText('Great coffee')).toBeInTheDocument()
|
||||
expect(screen.getByText('Loud')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-JRN-CARD-021: a verdict on an entry with no story stays in the open', () => {
|
||||
// There is no fold to put it behind, and hiding it behind a button that says
|
||||
// "show more story" when there is no story would be a lie.
|
||||
render(
|
||||
<EntryCard
|
||||
entry={buildEntry({ story: null, pros_cons: { pros: ['Great coffee'], cons: [] } })}
|
||||
onEdit={() => {}}
|
||||
onDelete={() => {}}
|
||||
onPhotoClick={() => {}}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getByText('Great coffee')).toBeInTheDocument()
|
||||
expect(screen.queryByText('Show more')).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('dismissing a suggestion', () => {
|
||||
it('FE-JRN-CARD-022: a suggestion offers a way out, and the way out is not the card itself', async () => {
|
||||
const onClick = vi.fn()
|
||||
const onDismiss = vi.fn()
|
||||
render(<SkeletonCard entry={buildEntry({ type: 'skeleton' })} onClick={onClick} onDismiss={onDismiss} />)
|
||||
|
||||
await userEvent.click(screen.getByRole('button', { name: 'Dismiss this suggestion' }))
|
||||
|
||||
expect(onDismiss).toHaveBeenCalledTimes(1)
|
||||
// The card underneath opens the editor; dismissing must not do both.
|
||||
expect(onClick).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('FE-JRN-CARD-023: a reader who cannot edit is offered nothing to dismiss', () => {
|
||||
render(<SkeletonCard entry={buildEntry({ type: 'skeleton' })} />)
|
||||
|
||||
expect(screen.queryByRole('button', { name: 'Dismiss this suggestion' })).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useState, useRef, useEffect } from 'react'
|
||||
import { createPortal } from 'react-dom'
|
||||
import { MapPin, Clock, MoreHorizontal, Pencil, Trash2, Plus, RouteOff } from 'lucide-react'
|
||||
import { MapPin, Clock, MoreHorizontal, Pencil, Trash2, Plus, RouteOff, X } from 'lucide-react'
|
||||
import { formatLocationName } from '../../utils/formatters'
|
||||
import { useTranslation } from '../../i18n'
|
||||
import { pluginsApi } from '../../api/client'
|
||||
@@ -152,13 +152,16 @@ export function EntryCard({ entry, readOnly, onEdit, onDelete, onPhotoClick }: {
|
||||
{!photos.length && entry.location_name && !entry.title && (
|
||||
<div className="mb-2" />
|
||||
)}
|
||||
{entry.story && (
|
||||
<ExpandableStory story={entry.story} />
|
||||
)}
|
||||
|
||||
{/* Pros & Cons — "Pros & Cons" style */}
|
||||
{hasProscons && (
|
||||
<VerdictSection pros={prosArr} cons={consArr} />
|
||||
{/* The verdict rides behind the story's fold: it belongs to one entry, and a
|
||||
feed of open pro/con tables is a spreadsheet rather than a journal. An
|
||||
entry with no story keeps it in the open, since there is no fold to
|
||||
put it behind. */}
|
||||
{entry.story ? (
|
||||
<ExpandableStory story={entry.story}>
|
||||
{hasProscons && <VerdictSection pros={prosArr} cons={consArr} />}
|
||||
</ExpandableStory>
|
||||
) : (
|
||||
hasProscons && <VerdictSection pros={prosArr} cons={consArr} />
|
||||
)}
|
||||
|
||||
{(mood || weather || (entry.tags && entry.tags.length > 0)) && (
|
||||
@@ -193,7 +196,16 @@ export function EntryCard({ entry, readOnly, onEdit, onDelete, onPhotoClick }: {
|
||||
)
|
||||
}
|
||||
|
||||
export function SkeletonCard({ entry, onClick }: { entry: JourneyEntry; onClick?: () => void }) {
|
||||
/**
|
||||
* A place the linked trip planned, offered as an entry waiting to be written.
|
||||
*
|
||||
* `onDismiss` is the way out of one that will never be written: plans change, and
|
||||
* a journey used to have no answer to a suggestion for a museum the traveller
|
||||
* skipped except hiding every suggestion at once (discussion #2299). The row is
|
||||
* kept server-side so the trip sync does not offer it again, and the journey
|
||||
* settings sheet brings them all back.
|
||||
*/
|
||||
export function SkeletonCard({ entry, onClick, onDismiss }: { entry: JourneyEntry; onClick?: () => void; onDismiss?: () => void }) {
|
||||
const { t } = useTranslation()
|
||||
return (
|
||||
<div
|
||||
@@ -220,6 +232,18 @@ export function SkeletonCard({ entry, onClick }: { entry: JourneyEntry; onClick?
|
||||
<Plus size={12} strokeWidth={2.6} /> {t('journey.detail.addEntry')}
|
||||
</span>
|
||||
)}
|
||||
{onDismiss && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={e => { e.stopPropagation(); onDismiss() }}
|
||||
aria-label={t('journey.suggestions.dismiss')}
|
||||
title={t('journey.suggestions.dismiss')}
|
||||
className="flex-shrink-0 w-7 h-7 rounded-full flex items-center justify-center opacity-45 hover:opacity-100 transition-opacity"
|
||||
style={{ color: 'var(--vg-ink3)' }}
|
||||
>
|
||||
<X size={14} strokeWidth={2.4} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -4,7 +4,7 @@ import { X, Plus, Image, Minus, Check, MapPin, Locate, Camera } from 'lucide-rea
|
||||
import { normalizeImageFiles } from '../../utils/convertHeic'
|
||||
import { type ResilientResult, type UploadProgress } from '../../utils/uploadQueue'
|
||||
import { useTranslation } from '../../i18n'
|
||||
import { journeyApi, mapsApi, addonsApi, memoriesApi } from '../../api/client'
|
||||
import { journeyApi, mapsApi, addonsApi, memoriesApi, weatherApi } from '../../api/client'
|
||||
import { useToast } from '../shared/Toast'
|
||||
import { getCurrentPositionOnce } from '../../hooks/useGeolocation'
|
||||
import { getApiErrorMessage } from '../../types'
|
||||
@@ -16,16 +16,27 @@ import { DatePicker } from './JourneyDetailPageDatePicker'
|
||||
import CustomTimePicker from '../shared/CustomTimePicker'
|
||||
import ToggleSwitch from '../Settings/ToggleSwitch'
|
||||
import { ProviderPicker, type ProviderPhotoGroup } from './JourneyDetailPageProviderPicker'
|
||||
import { journeyWeatherCategory } from '../../mobile/screens/journey/mobileJourneyMeta'
|
||||
|
||||
type PendingProviderGroup = ProviderPhotoGroup & { provider: string }
|
||||
|
||||
export function EntryEditor({ entry, journeyId, tripDates, galleryPhotos, trips, userId = 0, onClose, onSave, onUploadPhotos, onAddProviderPhotos, onDone }: {
|
||||
export function EntryEditor({ entry, journeyId, tripDates, galleryPhotos, trips, userId = 0, showVerdict = true, showMood = true, showWeather = true, onClose, onSave, onUploadPhotos, onAddProviderPhotos, onDone }: {
|
||||
entry: JourneyEntry
|
||||
journeyId: number
|
||||
tripDates: Set<string>
|
||||
galleryPhotos: GalleryPhoto[]
|
||||
trips: JourneyTrip[]
|
||||
userId?: number
|
||||
/**
|
||||
* The optional fields this journey still keeps (discussion #2299).
|
||||
*
|
||||
* A field switched off disappears from the form but keeps whatever an entry
|
||||
* already holds: the value is not cleared, and switching it back on brings it
|
||||
* into view again. Nobody loses a verdict they wrote by tidying a form.
|
||||
*/
|
||||
showVerdict?: boolean
|
||||
showMood?: boolean
|
||||
showWeather?: boolean
|
||||
onClose: () => void
|
||||
onSave: (data: Record<string, unknown>, existingEntryId?: number) => Promise<number>
|
||||
onUploadPhotos: (entryId: number, files: File[], cbs?: { onProgress?: (p: UploadProgress) => void }) => Promise<ResilientResult<JourneyPhoto>>
|
||||
@@ -73,6 +84,9 @@ export function EntryEditor({ entry, journeyId, tripDates, galleryPhotos, trips,
|
||||
// camera, so tablets and laptops get the same route the phone sheet already has.
|
||||
const cameraRef = useRef<HTMLInputElement>(null)
|
||||
const storyRef = useRef<HTMLTextAreaElement>(null)
|
||||
// Which verdict row to put the caret in after the next render. Enter adds a row
|
||||
// and the caret has to follow it, or the key does half a job.
|
||||
const verdictFocusRef = useRef<string | null>(null)
|
||||
const persistedEntryIdRef = useRef<number | null>(entry.id > 0 ? entry.id : null)
|
||||
|
||||
// Track which fields differ from the entry we started editing so we can
|
||||
@@ -133,6 +147,66 @@ export function EntryEditor({ entry, journeyId, tripDates, galleryPhotos, trips,
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Fill the weather in from the forecast once the entry knows where and when.
|
||||
*
|
||||
* The phone's quick capture has done this since it was built; typing an entry
|
||||
* up at a desk was the one place you still picked the icon by hand (discussion
|
||||
* #2299). The date decides the source on the server: today comes from the
|
||||
* forecast, a backdated day from the ERA5 archive, so writing up last Tuesday
|
||||
* gets last Tuesday's weather rather than this afternoon's.
|
||||
*
|
||||
* Only ever fills an empty field, and each place-and-day is tried once, so a
|
||||
* cleared icon stays cleared and a chosen one is never overwritten.
|
||||
*/
|
||||
const weatherTriedRef = useRef<string | null>(null)
|
||||
useEffect(() => {
|
||||
if (!showWeather) return
|
||||
if (typeof locationLat !== 'number' || typeof locationLng !== 'number') return
|
||||
if (weather) return
|
||||
const key = `${locationLat.toFixed(3)},${locationLng.toFixed(3)},${entryDate}`
|
||||
if (weatherTriedRef.current === key) return
|
||||
weatherTriedRef.current = key
|
||||
|
||||
let active = true
|
||||
weatherApi.get(locationLat, locationLng, entryDate, language)
|
||||
.then(result => {
|
||||
// An error-shaped answer carries no `main`, and the dev-only schema check
|
||||
// does not stop it reaching here in production.
|
||||
if (!active || !result || result.error || typeof result.main !== 'string') return
|
||||
const category = journeyWeatherCategory(result.main, result.description ?? '')
|
||||
// Re-checked rather than trusted from the closure: the request is a
|
||||
// round trip and the traveller may have picked an icon while it was out.
|
||||
setWeather(current => current || category)
|
||||
})
|
||||
.catch(() => { /* no weather is a fine outcome for a journal entry */ })
|
||||
return () => { active = false }
|
||||
}, [showWeather, locationLat, locationLng, entryDate, weather, language])
|
||||
|
||||
/**
|
||||
* Enter in a pro or con opens the next one, the way every list of short things
|
||||
* behaves. Reaching for the plus button between every item was the complaint
|
||||
* (discussion #2299); the button stays for the mouse.
|
||||
*
|
||||
* The new row goes directly below the one you are in rather than at the end, so
|
||||
* a thought inserted in the middle lands where you meant it.
|
||||
*/
|
||||
const addVerdictRow = (list: 'pros' | 'cons', index: number) => {
|
||||
const [values, setValues] = list === 'pros' ? [pros, setPros] as const : [cons, setCons] as const
|
||||
const next = [...values]
|
||||
next.splice(index + 1, 0, '')
|
||||
setValues(next)
|
||||
verdictFocusRef.current = `${list}-${index + 1}`
|
||||
}
|
||||
|
||||
/** Give the caret to the row `addVerdictRow` just made, once React has drawn it. */
|
||||
const verdictRowRef = (key: string) => (el: HTMLInputElement | null) => {
|
||||
if (el && verdictFocusRef.current === key) {
|
||||
verdictFocusRef.current = null
|
||||
el.focus()
|
||||
}
|
||||
}
|
||||
|
||||
const handleClose = () => {
|
||||
if (isDirty && !window.confirm(t('journey.editor.discardChangesConfirm'))) return
|
||||
onClose()
|
||||
@@ -307,12 +381,16 @@ export function EntryEditor({ entry, journeyId, tripDates, galleryPhotos, trips,
|
||||
<Image size={13} /> {t('journey.editor.fromGallery')}
|
||||
</button>
|
||||
)}
|
||||
{/* Only where a camera is plausibly attached to the thing you are typing
|
||||
on. On a desktop it was a second button to the same file dialog with a
|
||||
different icon (discussion #2299) — the phone shell has its own sheet,
|
||||
and this modal is what a tablet gets. */}
|
||||
<button type="button"
|
||||
onClick={() => { setPhotoTab('upload'); setShowGalleryPick(false); cameraRef.current?.click() }}
|
||||
disabled={saving}
|
||||
aria-label={t('journey.photo.add')}
|
||||
title={t('journey.photo.add')}
|
||||
className="border border-dashed border-zinc-200 dark:border-zinc-700 rounded-xl px-4 text-[12px] text-zinc-500 hover:border-zinc-400 dark:hover:border-zinc-500 hover:bg-zinc-50 dark:hover:bg-zinc-800 flex items-center justify-center disabled:opacity-50"
|
||||
className="md:hidden border border-dashed border-zinc-200 dark:border-zinc-700 rounded-xl px-4 text-[12px] text-zinc-500 hover:border-zinc-400 dark:hover:border-zinc-500 hover:bg-zinc-50 dark:hover:bg-zinc-800 flex items-center justify-center disabled:opacity-50"
|
||||
>
|
||||
<Camera size={14} />
|
||||
</button>
|
||||
@@ -530,7 +608,8 @@ export function EntryEditor({ entry, journeyId, tripDates, galleryPhotos, trips,
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-4 min-w-0">
|
||||
{/* Pros & Cons */}
|
||||
{/* Pros & Cons — gone entirely when the journey does not keep a verdict */}
|
||||
{showVerdict && (
|
||||
<div className="bg-zinc-50 dark:bg-zinc-800/50 rounded-2xl p-5">
|
||||
<div className="mb-4">
|
||||
<span className="text-[11px] font-semibold tracking-[0.12em] uppercase text-zinc-500">{t('journey.editor.prosCons')}</span>
|
||||
@@ -549,8 +628,10 @@ export function EntryEditor({ entry, journeyId, tripDates, galleryPhotos, trips,
|
||||
<div key={i} className="flex items-center gap-2 h-9 px-3 border rounded-[10px] border-zinc-200 dark:border-zinc-700">
|
||||
<span className="w-[5px] h-[5px] rounded-full bg-green-500 flex-shrink-0" />
|
||||
<input
|
||||
ref={verdictRowRef(`pros-${i}`)}
|
||||
value={p}
|
||||
onChange={e => { const next = [...pros]; next[i] = e.target.value; setPros(next) }}
|
||||
onKeyDown={e => { if (e.key === 'Enter') { e.preventDefault(); addVerdictRow('pros', i) } }}
|
||||
placeholder={t('journey.editor.proPlaceholder')}
|
||||
className="flex-1 min-w-0 bg-transparent border-none outline-none text-[13px] text-zinc-900 dark:text-zinc-100 placeholder:text-green-400 dark:placeholder:text-green-600"
|
||||
/>
|
||||
@@ -562,7 +643,7 @@ export function EntryEditor({ entry, journeyId, tripDates, galleryPhotos, trips,
|
||||
</div>
|
||||
))}
|
||||
<button type="button"
|
||||
onClick={() => setPros([...pros, ''])}
|
||||
onClick={() => addVerdictRow('pros', pros.length - 1)}
|
||||
className="flex items-center justify-center gap-1.5 h-9 w-full border border-dashed border-green-200 dark:border-green-800/40 rounded-[10px] text-[12px] font-medium text-green-700 dark:text-green-400 hover:border-green-300 dark:hover:border-green-700 transition-colors"
|
||||
>
|
||||
<Plus size={13} strokeWidth={2.5} /> {t('journey.editor.addAnother')}
|
||||
@@ -583,8 +664,10 @@ export function EntryEditor({ entry, journeyId, tripDates, galleryPhotos, trips,
|
||||
<div key={i} className="flex items-center gap-2 h-9 px-3 border rounded-[10px] border-zinc-200 dark:border-zinc-700">
|
||||
<span className="w-[5px] h-[5px] rounded-full bg-red-500 flex-shrink-0" />
|
||||
<input
|
||||
ref={verdictRowRef(`cons-${i}`)}
|
||||
value={c}
|
||||
onChange={e => { const next = [...cons]; next[i] = e.target.value; setCons(next) }}
|
||||
onKeyDown={e => { if (e.key === 'Enter') { e.preventDefault(); addVerdictRow('cons', i) } }}
|
||||
placeholder={t('journey.editor.conPlaceholder')}
|
||||
className="flex-1 min-w-0 bg-transparent border-none outline-none text-[13px] text-zinc-900 dark:text-zinc-100 placeholder:text-red-400 dark:placeholder:text-red-600"
|
||||
/>
|
||||
@@ -596,7 +679,7 @@ export function EntryEditor({ entry, journeyId, tripDates, galleryPhotos, trips,
|
||||
</div>
|
||||
))}
|
||||
<button type="button"
|
||||
onClick={() => setCons([...cons, ''])}
|
||||
onClick={() => addVerdictRow('cons', cons.length - 1)}
|
||||
className="flex items-center justify-center gap-1.5 h-9 w-full border border-dashed border-red-200 dark:border-red-800/40 rounded-[10px] text-[12px] font-medium text-red-700 dark:text-red-400 hover:border-red-300 dark:hover:border-red-700 transition-colors"
|
||||
>
|
||||
<Plus size={13} strokeWidth={2.5} /> {t('journey.editor.addAnother')}
|
||||
@@ -605,6 +688,7 @@ export function EntryEditor({ entry, journeyId, tripDates, galleryPhotos, trips,
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* The date needs the room, not the clock: a long localized date
|
||||
("12. Sept. 2026") wrapped onto a second line while the time field
|
||||
@@ -720,6 +804,7 @@ export function EntryEditor({ entry, journeyId, tripDates, galleryPhotos, trips,
|
||||
</div>
|
||||
)}
|
||||
|
||||
{showMood && (
|
||||
<div>
|
||||
<label className="text-[10px] font-semibold tracking-[0.12em] uppercase text-zinc-500 block mb-2">{t('journey.editor.mood')}</label>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
@@ -739,7 +824,9 @@ export function EntryEditor({ entry, journeyId, tripDates, galleryPhotos, trips,
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{showWeather && (
|
||||
<div>
|
||||
<label className="text-[10px] font-semibold tracking-[0.12em] uppercase text-zinc-500 block mb-2">{t('journey.editor.weather')}</label>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
@@ -758,6 +845,7 @@ export function EntryEditor({ entry, journeyId, tripDates, galleryPhotos, trips,
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,9 +1,21 @@
|
||||
import { useEffect, useState, useRef, type HTMLAttributes } from 'react'
|
||||
import { useEffect, useState, useRef, type HTMLAttributes, type ReactNode } from 'react'
|
||||
import { ChevronRight } from 'lucide-react'
|
||||
import { useTranslation } from '../../i18n'
|
||||
import JournalBody from './JournalBody'
|
||||
|
||||
export function ExpandableStory({ story }: { story: string }) {
|
||||
/**
|
||||
* The story, folded, with whatever else belongs behind the fold.
|
||||
*
|
||||
* Nine lines was most of a screen: a feed of long entries read as a wall, and the
|
||||
* fold only earned its keep on the very longest of them (discussion #2299). Four
|
||||
* lines is enough to know whether this is the entry you were looking for.
|
||||
*
|
||||
* `children` is the rest of what unfolding reveals — today the pros and cons,
|
||||
* which are a detail of one entry rather than something to scan a journey by.
|
||||
* They keep the toggle alive on their own, so an entry with a two-line story and
|
||||
* a verdict still offers a way to see it.
|
||||
*/
|
||||
export function ExpandableStory({ story, children }: { story: string; children?: ReactNode }) {
|
||||
const { t } = useTranslation()
|
||||
const [expanded, setExpanded] = useState(false)
|
||||
const [clamped, setClamped] = useState(false)
|
||||
@@ -26,7 +38,8 @@ export function ExpandableStory({ story }: { story: string }) {
|
||||
// The text block toggles the same clamp as the Show more / Show less buttons
|
||||
// below it. It only takes focus while there is something to toggle, so a
|
||||
// short story stays plain text instead of an empty stop in the tab order.
|
||||
const toggle: HTMLAttributes<HTMLDivElement> = clamped || expanded
|
||||
const hasFold = clamped || !!children
|
||||
const toggle: HTMLAttributes<HTMLDivElement> = hasFold || expanded
|
||||
? {
|
||||
role: 'button',
|
||||
tabIndex: 0,
|
||||
@@ -42,12 +55,13 @@ export function ExpandableStory({ story }: { story: string }) {
|
||||
ref={ref}
|
||||
{...toggle}
|
||||
className={`text-[13px] text-zinc-700 dark:text-zinc-300 leading-relaxed ${
|
||||
expanded ? '' : 'line-clamp-3 md:line-clamp-[9]'
|
||||
} ${clamped || expanded ? 'cursor-pointer' : ''}`}
|
||||
expanded ? '' : 'line-clamp-3 md:line-clamp-4'
|
||||
} ${hasFold || expanded ? 'cursor-pointer' : ''}`}
|
||||
>
|
||||
<JournalBody text={story} />
|
||||
</div>
|
||||
{clamped && !expanded && (
|
||||
{expanded && children}
|
||||
{hasFold && !expanded && (
|
||||
<button type="button"
|
||||
onClick={() => setExpanded(true)}
|
||||
className="mt-2 inline-flex items-center gap-1 px-2.5 py-1 rounded-full bg-zinc-100 dark:bg-zinc-800 text-[10px] font-medium text-zinc-500 dark:text-zinc-400 hover:bg-zinc-200 dark:hover:bg-zinc-700 active:scale-95 transition-all"
|
||||
|
||||
@@ -221,10 +221,12 @@ export function ProviderPicker({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Filter bar */}
|
||||
<div className="flex-shrink-0 border-b border-zinc-200 px-6 py-3 dark:border-zinc-700">
|
||||
{/* Filter bar. Embedded in the entry editor this sits in a panel a few hundred
|
||||
pixels tall, so its padding is the difference between three rows of
|
||||
thumbnails and two. */}
|
||||
<div className={`flex-shrink-0 border-b border-zinc-200 dark:border-zinc-700 ${embedded ? 'px-3 py-2' : 'px-6 py-3'}`}>
|
||||
{/* Tabs */}
|
||||
<div className="mb-3 flex gap-1.5">
|
||||
<div className={`flex gap-1.5 ${embedded && filter === 'day' ? '' : 'mb-3'}`}>
|
||||
{[
|
||||
...(initialDate ? [{ id: 'day' as const, label: t('journey.picker.day') || 'This day' }] : []),
|
||||
{ id: 'trip' as const, label: t('journey.picker.tripPeriod') },
|
||||
@@ -552,14 +554,11 @@ export function ProviderPicker({
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="flex flex-shrink-0 items-center justify-between border-t border-zinc-200 bg-zinc-50 px-6 py-4 dark:border-zinc-700 dark:bg-zinc-800/50">
|
||||
<span className="inline-flex items-center gap-1.5 rounded-full bg-zinc-200/60 px-2.5 py-1 text-[11px] leading-none text-zinc-500 dark:bg-zinc-700/60 dark:text-zinc-400">
|
||||
<span className="inline-flex h-[18px] min-w-[18px] items-center justify-center rounded-full bg-zinc-900 px-1 text-[10px] leading-none font-bold text-white dark:bg-white dark:text-zinc-900">
|
||||
{selected.size}
|
||||
</span>
|
||||
<span className="leading-[18px]">{t('journey.picker.selected')}</span>
|
||||
</span>
|
||||
{/* Footer. The count used to sit here as its own pill as well as on the Add
|
||||
button; one of the two was always saying it twice, and inside the entry
|
||||
editor the row it took cost the grid a row of thumbnails (discussion
|
||||
#2299). Embedded, the whole bar is tighter for the same reason. */}
|
||||
<div className={`flex flex-shrink-0 items-center justify-end border-t border-zinc-200 bg-zinc-50 dark:border-zinc-700 dark:bg-zinc-800/50 ${embedded ? 'px-3 py-2' : 'px-6 py-4'}`}>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useState, useRef } from 'react'
|
||||
import { useNavigate } from 'react-router'
|
||||
import { X, ImagePlus, Plus, Trash2, UserPlus, Archive, ArchiveRestore } from 'lucide-react'
|
||||
import { X, ImagePlus, Plus, Trash2, UserPlus, Archive, ArchiveRestore, Undo2 } from 'lucide-react'
|
||||
import { useJourneyStore } from '../../store/journeyStore'
|
||||
import { useTranslation } from '../../i18n'
|
||||
import { journeyApi } from '../../api/client'
|
||||
@@ -12,12 +12,14 @@ import { pickGradient } from '../../pages/journeyDetail/JourneyDetailPage.helper
|
||||
import { AddTripDialog } from './JourneyDetailPageAddTripDialog'
|
||||
import { normalizeImageFile } from '../../utils/convertHeic'
|
||||
|
||||
export function JourneySettingsDialog({ journey, onClose, onSaved, onOpenInvite, onRefresh }: {
|
||||
export function JourneySettingsDialog({ journey, onClose, onSaved, onOpenInvite, onRefresh, onRestoreSuggestions }: {
|
||||
journey: JourneyDetail
|
||||
onClose: () => void
|
||||
onSaved: () => void
|
||||
onOpenInvite: () => void
|
||||
onRefresh: () => void
|
||||
/** Bring back every suggestion waved away card by card. Absent for a viewer. */
|
||||
onRestoreSuggestions?: () => Promise<void> | void
|
||||
}) {
|
||||
const { t } = useTranslation()
|
||||
const [title, setTitle] = useState(journey.title)
|
||||
@@ -95,6 +97,30 @@ export function JourneySettingsDialog({ journey, onClose, onSaved, onOpenInvite,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Turn one of the optional entry fields off for this journey.
|
||||
*
|
||||
* Saved on the spot for the same reason the tracks switch is, and through
|
||||
* `onRefresh` rather than `onSaved` so flipping a switch does not close the
|
||||
* dialog out from under a half-typed title.
|
||||
*
|
||||
* Nothing is erased: an entry that already carries a mood keeps it in the
|
||||
* database, the form simply stops asking. Switching back on brings it into
|
||||
* view again.
|
||||
*/
|
||||
const [savingField, setSavingField] = useState<string | null>(null)
|
||||
const handleFieldToggle = async (field: 'show_verdict' | 'show_mood' | 'show_weather') => {
|
||||
setSavingField(field)
|
||||
try {
|
||||
await updateJourney(journey.id, { [field]: journey[field] === 0 })
|
||||
onRefresh()
|
||||
} catch {
|
||||
toast.error(t('journey.settings.saveFailed'))
|
||||
} finally {
|
||||
setSavingField(null)
|
||||
}
|
||||
}
|
||||
|
||||
const handleDelete = async () => {
|
||||
try {
|
||||
await deleteJourney(journey.id)
|
||||
@@ -183,6 +209,57 @@ export function JourneySettingsDialog({ journey, onClose, onSaved, onOpenInvite,
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* The three fields a journey may put away (discussion #2299) */}
|
||||
<div>
|
||||
<label className="text-[10px] font-semibold tracking-[0.12em] uppercase text-zinc-500 block mb-1.5">{t('journey.settings.entryFields')}</label>
|
||||
<p className="text-[11px] leading-snug text-zinc-500 mb-2">{t('journey.settings.entryFieldsHint')}</p>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
{([
|
||||
['show_verdict', t('journey.settings.showVerdict')],
|
||||
['show_mood', t('journey.settings.showMood')],
|
||||
['show_weather', t('journey.settings.showWeather')],
|
||||
] as const).map(([field, label]) => {
|
||||
const on = journey[field] !== 0
|
||||
return (
|
||||
<button
|
||||
key={field}
|
||||
type="button"
|
||||
role="switch"
|
||||
aria-checked={on}
|
||||
aria-label={label}
|
||||
disabled={savingField !== null}
|
||||
onClick={() => handleFieldToggle(field)}
|
||||
className="w-full flex items-center gap-3 px-3.5 py-2.5 border border-zinc-200 dark:border-zinc-700 rounded-xl bg-white dark:bg-zinc-800 text-left disabled:opacity-60"
|
||||
>
|
||||
<span className="flex-1 min-w-0 text-[14px] text-zinc-900 dark:text-white">{label}</span>
|
||||
<span aria-hidden="true" className={`w-9 h-5 rounded-full flex-shrink-0 p-0.5 transition-colors ${on ? 'bg-emerald-500' : 'bg-zinc-300 dark:bg-zinc-600'}`}>
|
||||
<span className={`block w-4 h-4 rounded-full bg-white transition-transform ${on ? 'translate-x-4' : ''}`} />
|
||||
</span>
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* The way back from dismissing suggestions one at a time. Only ever shown
|
||||
when there is something to bring back, so it is not a permanent row
|
||||
about a feature most journeys never touch. */}
|
||||
{onRestoreSuggestions && (journey.dismissed_count ?? 0) > 0 && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => { void onRestoreSuggestions() }}
|
||||
className="w-full flex items-center gap-3 px-3.5 py-2.5 border border-zinc-200 dark:border-zinc-700 rounded-xl bg-white dark:bg-zinc-800 text-left hover:bg-zinc-50 dark:hover:bg-zinc-700/50 transition-colors"
|
||||
>
|
||||
<Undo2 size={15} className="text-zinc-400 flex-shrink-0" />
|
||||
<span className="flex-1 min-w-0">
|
||||
<span className="block text-[14px] text-zinc-900 dark:text-white">{t('journey.suggestions.restore')}</span>
|
||||
<span className="block text-[11px] text-zinc-500">
|
||||
{t('journey.suggestions.restoreCount', { count: String(journey.dismissed_count) })}
|
||||
</span>
|
||||
</span>
|
||||
</button>
|
||||
)}
|
||||
|
||||
</div>
|
||||
|
||||
{/* Right column */}
|
||||
|
||||
@@ -0,0 +1,112 @@
|
||||
// FE-JRN-COVER-001 to FE-JRN-COVER-008
|
||||
|
||||
import { describe, it, expect, vi } from 'vitest'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { render, screen } from '../../../tests/helpers/render'
|
||||
import JourneyEntryCover, { type CoverEntry } from './JourneyEntryCover'
|
||||
|
||||
function buildEntry(overrides: Partial<CoverEntry> = {}): CoverEntry {
|
||||
return {
|
||||
id: 10,
|
||||
type: 'entry',
|
||||
title: 'Mercado da Ribeira',
|
||||
entry_date: '2026-03-15',
|
||||
location_name: 'Lisbon, Portugal',
|
||||
country_code: 'PT',
|
||||
photos: [{ photo_id: 7 }],
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
describe('JourneyEntryCover', () => {
|
||||
it('FE-JRN-COVER-001: the photo fills the card, with the name and place on it', () => {
|
||||
// Queried by tag: the photo is decoration behind the words, so it carries an
|
||||
// empty alt and no img role.
|
||||
const { container } = render(<JourneyEntryCover entry={buildEntry()} dayColor="#6366f1" isActive={false} onClick={() => {}} />)
|
||||
|
||||
expect(container.querySelector('img')).toHaveAttribute('src', '/api/photos/7/thumbnail')
|
||||
expect(screen.getByText('Mercado da Ribeira')).toBeInTheDocument()
|
||||
expect(screen.getByText('Lisbon, Portugal')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-JRN-COVER-002: an entry with no photo still reads as a card', () => {
|
||||
// The day colour carries it, so the carousel does not go blank where somebody
|
||||
// wrote something down without taking a picture.
|
||||
const { container } = render(<JourneyEntryCover entry={buildEntry({ photos: [] })} dayColor="#6366f1" isActive={false} onClick={() => {}} />)
|
||||
|
||||
expect(container.querySelector('img')).toBeNull()
|
||||
expect(screen.getByText('Mercado da Ribeira')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-JRN-COVER-003: the country flag rides in the corner, and only with a country', () => {
|
||||
const { rerender } = render(
|
||||
<JourneyEntryCover entry={buildEntry()} dayColor="#6366f1" isActive={false} onClick={() => {}} />,
|
||||
)
|
||||
expect(screen.getByText('🇵🇹')).toBeInTheDocument()
|
||||
|
||||
rerender(
|
||||
<JourneyEntryCover entry={buildEntry({ country_code: null })} dayColor="#6366f1" isActive={false} onClick={() => {}} />,
|
||||
)
|
||||
expect(screen.queryByText('🇵🇹')).toBeNull()
|
||||
})
|
||||
|
||||
it('FE-JRN-COVER-004: a suggestion says so, so it is not mistaken for somewhere you have been', () => {
|
||||
render(
|
||||
<JourneyEntryCover
|
||||
entry={buildEntry({ type: 'skeleton', title: null })}
|
||||
dayColor="#6366f1"
|
||||
isActive={false}
|
||||
onClick={() => {}}
|
||||
/>,
|
||||
)
|
||||
|
||||
// Once as the badge, once as the fallback title.
|
||||
expect(screen.getAllByText('Suggestion').length).toBe(2)
|
||||
})
|
||||
|
||||
it('FE-JRN-COVER-005: mood and weather appear only while the journey keeps those fields', () => {
|
||||
const entry = buildEntry({ mood: 'good', weather: 'sunny' })
|
||||
const { container, rerender } = render(
|
||||
<JourneyEntryCover entry={entry} dayColor="#6366f1" isActive={false} onClick={() => {}} />,
|
||||
)
|
||||
const withBoth = container.querySelectorAll('svg').length
|
||||
|
||||
rerender(
|
||||
<JourneyEntryCover entry={entry} dayColor="#6366f1" isActive={false} onClick={() => {}} showMood={false} showWeather={false} />,
|
||||
)
|
||||
expect(container.querySelectorAll('svg').length).toBe(withBoth - 2)
|
||||
})
|
||||
|
||||
it('FE-JRN-COVER-006: the active card is the larger one', () => {
|
||||
const { container, rerender } = render(
|
||||
<JourneyEntryCover entry={buildEntry()} dayColor="#6366f1" isActive={false} onClick={() => {}} />,
|
||||
)
|
||||
expect(container.querySelector('button')!.className).toContain('w-[136px]')
|
||||
|
||||
rerender(<JourneyEntryCover entry={buildEntry()} dayColor="#6366f1" isActive onClick={() => {}} />)
|
||||
expect(container.querySelector('button')!.className).toContain('w-[164px]')
|
||||
})
|
||||
|
||||
it('FE-JRN-COVER-007: a shared journey serves its photos through the share token', () => {
|
||||
const { container } = render(
|
||||
<JourneyEntryCover
|
||||
entry={buildEntry()}
|
||||
dayColor="#6366f1"
|
||||
isActive={false}
|
||||
onClick={() => {}}
|
||||
photoUrlFor={id => `/api/public/journey/tok/photos/${id}/original`}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(container.querySelector('img')).toHaveAttribute('src', '/api/public/journey/tok/photos/7/original')
|
||||
})
|
||||
|
||||
it('FE-JRN-COVER-008: tapping it opens the entry', async () => {
|
||||
const onClick = vi.fn()
|
||||
render(<JourneyEntryCover entry={buildEntry()} dayColor="#6366f1" isActive onClick={onClick} />)
|
||||
|
||||
await userEvent.click(screen.getByRole('button'))
|
||||
|
||||
expect(onClick).toHaveBeenCalledOnce()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,154 @@
|
||||
import { MapPin } from 'lucide-react'
|
||||
import { useTranslation } from '../../i18n'
|
||||
import { moodMeta, weatherMeta } from '../../mobile/screens/journey/mobileJourneyMeta'
|
||||
import { cardDateLabel, cardPhotoId, cardPlace, cardTitle, countryFlag, type CardPhoto } from './journeyCard'
|
||||
import type { JourneyEntry } from '../../store/journeyStore'
|
||||
|
||||
/**
|
||||
* One entry of the phone timeline: the photo IS the card.
|
||||
*
|
||||
* It used to be a 64px thumbnail beside three lines of text, which spent the
|
||||
* card's width on a story nobody reads at that size and left the title truncated
|
||||
* after a handful of characters (discussion #2299). Now the picture fills the
|
||||
* card and the words sit on it: title and place bottom-left, where the eye lands,
|
||||
* and the two facts that place the moment — which country, which day — in the
|
||||
* corners above.
|
||||
*
|
||||
* The day is carried by the colour rather than by a number. A numbered card only
|
||||
* answers "how many stops into this day am I", which is not a question anyone
|
||||
* asks; the day scrubber above the carousel answers the one they do.
|
||||
*
|
||||
* Two shells draw this card. `tone` picks the surface a card without a photo
|
||||
* falls back to, because the phone shell's tokens are scoped to `.m-root` and
|
||||
* the tablet timeline is outside it. Everything else is a photo and white text,
|
||||
* which needs no tokens at all.
|
||||
*/
|
||||
|
||||
/** The subset of an entry a card reads. Shared journeys hand over less than the journey's own. */
|
||||
export interface CoverEntry {
|
||||
id: number
|
||||
type: JourneyEntry['type']
|
||||
title?: string | null
|
||||
story?: string | null
|
||||
location_name?: string | null
|
||||
location_lat?: number | null
|
||||
location_lng?: number | null
|
||||
country_code?: string | null
|
||||
entry_date: string
|
||||
entry_time?: string | null
|
||||
mood?: string | null
|
||||
weather?: string | null
|
||||
photos?: CardPhoto[]
|
||||
}
|
||||
|
||||
interface Props {
|
||||
entry: CoverEntry | JourneyEntry
|
||||
/** The colour of the day this entry belongs to, from `DAY_COLORS`. */
|
||||
dayColor: string
|
||||
isActive: boolean
|
||||
onClick: () => void
|
||||
/** Off when the journey has put that field away (journey settings). */
|
||||
showMood?: boolean
|
||||
showWeather?: boolean
|
||||
/** The public journey page serves photos through a share token. */
|
||||
photoUrlFor?: (photoId: number) => string
|
||||
tone?: 'app' | 'mobile'
|
||||
}
|
||||
|
||||
export default function JourneyEntryCover({
|
||||
entry,
|
||||
dayColor,
|
||||
isActive,
|
||||
onClick,
|
||||
showMood = true,
|
||||
showWeather = true,
|
||||
photoUrlFor,
|
||||
tone = 'app',
|
||||
}: Props) {
|
||||
const { t, locale } = useTranslation()
|
||||
const isSuggestion = entry.type === 'skeleton'
|
||||
|
||||
const photoId = cardPhotoId(entry.photos?.[0])
|
||||
const src = photoId == null ? null : photoUrlFor ? photoUrlFor(photoId) : `/api/photos/${photoId}/thumbnail`
|
||||
|
||||
const title = cardTitle(entry, t)
|
||||
const place = cardPlace(entry)
|
||||
const flag = countryFlag(entry.country_code)
|
||||
const date = cardDateLabel(entry.entry_date, locale)
|
||||
const mood = showMood ? moodMeta(entry.mood) : undefined
|
||||
const weather = showWeather ? weatherMeta(entry.weather) : undefined
|
||||
|
||||
const emptyGround = tone === 'mobile' ? 'bg-[color:var(--m-sheet)]' : 'bg-white/90 dark:bg-zinc-800/90'
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
// The whole card is the photo, so its own ring is what separates it from the
|
||||
// map behind. A suggestion is drawn as an outline rather than a solid thing:
|
||||
// it is somewhere you planned to be, not somewhere you have written about.
|
||||
className={`relative flex-none overflow-hidden rounded-[18px] text-left transition-[width,height] duration-150 ${
|
||||
isActive ? 'h-[180px] w-[164px] shadow-[0_18px_40px_-16px_rgba(0,0,0,.55)]' : 'h-[152px] w-[136px] shadow-[0_10px_24px_-14px_rgba(0,0,0,.5)]'
|
||||
} ${src ? '' : emptyGround} ${
|
||||
isActive ? 'ring-2 ring-white/85 dark:ring-white/70' : 'ring-1 ring-black/10 dark:ring-white/15'
|
||||
} ${isSuggestion ? 'opacity-90' : ''}`}
|
||||
style={src ? undefined : { background: `linear-gradient(160deg, ${dayColor}2e, ${dayColor}0d)` }}
|
||||
>
|
||||
{src ? (
|
||||
<img src={src} alt="" loading="lazy" className="absolute inset-0 h-full w-full object-cover" />
|
||||
) : (
|
||||
<span className="absolute inset-0 flex items-center justify-center">
|
||||
<MapPin size={24} strokeWidth={1.8} style={{ color: dayColor }} className="opacity-60" />
|
||||
</span>
|
||||
)}
|
||||
|
||||
{/* Two washes rather than one: the top corners carry small marks that would
|
||||
otherwise sit on a bright sky, and the bottom has to hold two lines of text. */}
|
||||
<span className="pointer-events-none absolute inset-x-0 top-0 h-16 bg-gradient-to-b from-black/45 to-transparent" />
|
||||
<span className="pointer-events-none absolute inset-x-0 bottom-0 h-24 bg-gradient-to-t from-black/80 via-black/45 to-transparent" />
|
||||
|
||||
{/* Top left: where in the world, and whether this has happened yet. */}
|
||||
<span className="absolute left-2 top-2 flex items-center gap-1">
|
||||
{flag && <span className="text-[15px] leading-none drop-shadow-[0_1px_2px_rgba(0,0,0,.5)]">{flag}</span>}
|
||||
{isSuggestion && (
|
||||
<span className="rounded-full bg-white/22 px-[7px] py-[2px] text-[9px] font-bold uppercase tracking-[0.06em] text-white backdrop-blur-[2px]">
|
||||
{t('journey.entry.suggestion')}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
|
||||
{/* Top right: when, plus whatever the journey still keeps of mood and weather. */}
|
||||
<span className="absolute right-2 top-2 flex items-center gap-1">
|
||||
{mood && (
|
||||
<span className="flex h-[18px] w-[18px] items-center justify-center rounded-full bg-white/22 backdrop-blur-[2px]" style={{ color: mood.color }}>
|
||||
<mood.icon size={11} strokeWidth={2.4} />
|
||||
</span>
|
||||
)}
|
||||
{weather && (
|
||||
<span className="flex h-[18px] w-[18px] items-center justify-center rounded-full bg-white/22 text-white backdrop-blur-[2px]">
|
||||
<weather.icon size={11} strokeWidth={2.4} />
|
||||
</span>
|
||||
)}
|
||||
<span className="rounded-full bg-white/22 px-[7px] py-[2px] text-[10px] font-bold whitespace-nowrap text-white backdrop-blur-[2px]">
|
||||
{date}
|
||||
</span>
|
||||
</span>
|
||||
|
||||
{/* Bottom: the name gets the room the thumbnail layout never had. */}
|
||||
<span className="absolute inset-x-0 bottom-0 flex flex-col gap-[1px] px-[10px] pb-[10px]">
|
||||
<span className={`line-clamp-2 text-[13px] leading-[1.25] font-bold text-white drop-shadow-[0_1px_3px_rgba(0,0,0,.6)] ${isSuggestion ? 'italic' : ''}`}>
|
||||
{title}
|
||||
</span>
|
||||
{place && (
|
||||
<span className="truncate text-[10.5px] leading-[1.3] font-medium text-white/80 drop-shadow-[0_1px_3px_rgba(0,0,0,.6)]">
|
||||
{place}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
|
||||
{/* The day, as a colour. Sits under the text rather than beside it so it costs
|
||||
the card no width, and reads as one bar across the carousel per day. */}
|
||||
<span className="absolute inset-x-0 bottom-0 h-[3px]" style={{ background: dayColor }} />
|
||||
</button>
|
||||
)
|
||||
}
|
||||
@@ -26,6 +26,7 @@ vi.mock('leaflet', () => {
|
||||
fitBounds: vi.fn(),
|
||||
setView: vi.fn(),
|
||||
flyTo: vi.fn(),
|
||||
panTo: vi.fn(),
|
||||
getZoom: vi.fn(() => 10),
|
||||
// Leaflet throws "Set map center and zoom first." out of these until the map
|
||||
// has a view; the loaded map is the default here, FE-COMP-JOURNEYMAP-050
|
||||
@@ -316,20 +317,22 @@ describe('JourneyMap', () => {
|
||||
expect(vi.mocked(L.divIcon).mock.calls.length).toBe(iconsBefore);
|
||||
});
|
||||
|
||||
it('FE-COMP-JOURNEYMAP-017: focusMarker flies to the pin, never below zoom 12', () => {
|
||||
it('FE-COMP-JOURNEYMAP-017: focusMarker pans to the pin and leaves the zoom where the reader put it', () => {
|
||||
const ref = React.createRef<JourneyMapHandle>();
|
||||
render(<JourneyMap ref={ref} checkins={[]} entries={entriesWithCoords} />);
|
||||
|
||||
act(() => { ref.current!.focusMarker('e2'); });
|
||||
|
||||
// getZoom() is stubbed at 10, so the floor of 12 wins
|
||||
expect(mockedMap().flyTo).toHaveBeenCalledWith({ lat: 0, lng: 0 }, 12, { duration: 0.5 });
|
||||
// It used to force a floor of zoom 12, which yanked a country view to street
|
||||
// level on the first scroll through the timeline (discussion #2299).
|
||||
expect(mockedMap().panTo).toHaveBeenCalledWith({ lat: 0, lng: 0 }, { animate: true, duration: 0.5 });
|
||||
expect(mockedMap().flyTo).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('FE-COMP-JOURNEYMAP-018: focusMarker swallows leaflet errors when the map has no view yet', () => {
|
||||
const ref = React.createRef<JourneyMapHandle>();
|
||||
render(<JourneyMap ref={ref} checkins={[]} entries={entriesWithCoords} />);
|
||||
vi.mocked(mockedMap().getZoom).mockImplementationOnce(() => { throw new Error('Set map center and zoom first'); });
|
||||
vi.mocked(mockedMap().panTo).mockImplementationOnce(() => { throw new Error('Set map center and zoom first'); });
|
||||
|
||||
expect(() => act(() => { ref.current!.focusMarker('e1'); })).not.toThrow();
|
||||
expect(mockedMap().flyTo).not.toHaveBeenCalled();
|
||||
@@ -462,13 +465,13 @@ describe('JourneyMap', () => {
|
||||
expect(layer.getMaplibreMap().setStyle).not.toHaveBeenCalledWith(expect.stringContaining('tiles.test'));
|
||||
});
|
||||
|
||||
it('FE-COMP-JOURNEYMAP-028: the activeMarkerId prop flies to that marker after the settle delay', () => {
|
||||
it('FE-COMP-JOURNEYMAP-028: the activeMarkerId prop pans to that marker after the settle delay', () => {
|
||||
vi.useFakeTimers();
|
||||
try {
|
||||
render(<JourneyMap checkins={[]} entries={entriesWithCoords} activeMarkerId="e2" />);
|
||||
expect(mockedMap().flyTo).not.toHaveBeenCalled();
|
||||
expect(mockedMap().panTo).not.toHaveBeenCalled();
|
||||
act(() => { vi.advanceTimersByTime(60); });
|
||||
expect(mockedMap().flyTo).toHaveBeenCalledWith({ lat: 0, lng: 0 }, 12, { duration: 0.5 });
|
||||
expect(mockedMap().panTo).toHaveBeenCalledWith({ lat: 0, lng: 0 }, { animate: true, duration: 0.5 });
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
@@ -478,8 +481,9 @@ describe('JourneyMap', () => {
|
||||
vi.useFakeTimers();
|
||||
try {
|
||||
render(<JourneyMap checkins={[]} entries={entriesWithCoords} activeMarkerId="e1" />);
|
||||
vi.mocked(mockedMap().getZoom).mockImplementationOnce(() => { throw new Error('Set map center and zoom first'); });
|
||||
vi.mocked(mockedMap().panTo).mockImplementationOnce(() => { throw new Error('Set map center and zoom first'); });
|
||||
act(() => { vi.advanceTimersByTime(60); });
|
||||
// The catch is where a map with no view at all gets its first one.
|
||||
expect(mockedMap().setView).toHaveBeenCalledWith({ lat: 0, lng: 0 }, 12);
|
||||
expect(mockedMap().flyTo).not.toHaveBeenCalled();
|
||||
} finally {
|
||||
|
||||
@@ -98,6 +98,16 @@ interface Props {
|
||||
activeMarkerId?: string | null
|
||||
onMarkerClick?: (id: string, type?: string) => void
|
||||
fullScreen?: boolean
|
||||
/**
|
||||
* Leave the marker labels off.
|
||||
*
|
||||
* On the phone the map sits above a carousel whose active card already carries
|
||||
* the entry's name, and a tooltip on touch is a tap-to-open box rather than a
|
||||
* hover hint — so it says the same thing twice and covers the map to do it
|
||||
* (discussion #2299). On desktop the label is the only name a marker has, so
|
||||
* this stays off there.
|
||||
*/
|
||||
hideMarkerTooltip?: boolean
|
||||
paddingBottom?: number
|
||||
/** CARTO key from the share payload: the public journey has no settings store to read. */
|
||||
cartoApiKey?: string
|
||||
@@ -149,8 +159,12 @@ const EMPTY_TRACKS: JourneyTrack[] = []
|
||||
const TRACK_FALLBACK_COLOR = '#4f46e5'
|
||||
|
||||
function JourneyMap(
|
||||
{ entries, photos, onPhotoClick, trail, tracks, height = 220, dark, activeMarkerId, onMarkerClick, fullScreen, paddingBottom, cartoApiKey, ref }: Props,
|
||||
{ entries, photos, onPhotoClick, trail, tracks, height = 220, dark, activeMarkerId, onMarkerClick, fullScreen, paddingBottom, cartoApiKey, hideMarkerTooltip, ref }: Props,
|
||||
) {
|
||||
// Read through a ref: the flag is fixed per surface, and putting it in the
|
||||
// marker effect's deps would rebuild every marker for nothing.
|
||||
const hideMarkerTooltipRef = useRef(hideMarkerTooltip)
|
||||
hideMarkerTooltipRef.current = hideMarkerTooltip
|
||||
const stableTrail = trail || EMPTY_TRAIL
|
||||
const stableTracks = tracks || EMPTY_TRACKS
|
||||
const mapTileUrl = useSettingsStore(s => s.settings.map_tile_url)
|
||||
@@ -219,12 +233,22 @@ function JourneyMap(
|
||||
}
|
||||
}, [])
|
||||
|
||||
/**
|
||||
* Bring an entry's marker under the reader without changing how far out they are.
|
||||
*
|
||||
* This fires on every step through the timeline, and it used to force zoom 12.
|
||||
* Reading a journey from a country view therefore yanked the map to street level
|
||||
* on the first scroll and kept it there: every marker filled the screen, and the
|
||||
* one thing a map is for — where is this, relative to everything else — was gone
|
||||
* (discussion #2299). Panning keeps the frame the reader chose; the initial
|
||||
* fitBounds is what decides how close the journey starts out.
|
||||
*/
|
||||
const focusMarker = useCallback((id: string) => {
|
||||
highlightMarker(id)
|
||||
const marker = markersRef.current.get(id)
|
||||
if (marker && mapRef.current) {
|
||||
try {
|
||||
mapRef.current.flyTo(marker.getLatLng(), Math.max(mapRef.current.getZoom(), 12), { duration: 0.5 })
|
||||
mapRef.current.panTo(marker.getLatLng(), { animate: true, duration: 0.5 })
|
||||
} catch { /* map not yet initialized */ }
|
||||
}
|
||||
}, [])
|
||||
@@ -348,11 +372,13 @@ function JourneyMap(
|
||||
const marker = L.marker(pos, { icon }).addTo(map)
|
||||
// Escaped for the same reason as the track tooltip above: the label is an
|
||||
// entry title, and this map is what the public journey page renders.
|
||||
marker.bindTooltip(escapeHtml(item.label), {
|
||||
direction: 'top',
|
||||
offset: [0, -MARKER_H],
|
||||
className: 'map-tooltip',
|
||||
})
|
||||
if (!hideMarkerTooltipRef.current) {
|
||||
marker.bindTooltip(escapeHtml(item.label), {
|
||||
direction: 'top',
|
||||
offset: [0, -MARKER_H],
|
||||
className: 'map-tooltip',
|
||||
})
|
||||
}
|
||||
|
||||
marker.on('click', () => {
|
||||
onMarkerClickRef.current?.(item.id)
|
||||
@@ -462,11 +488,11 @@ function JourneyMap(
|
||||
highlightMarker(activeMarkerId)
|
||||
const marker = markersRef.current.get(activeMarkerId)
|
||||
if (!marker || !mapRef.current) return
|
||||
// fitBounds may still be pending when this fires — getZoom() throws
|
||||
// "Set map center and zoom first" until the map has a view. Guard it.
|
||||
// Pan, don't zoom — see focusMarker. fitBounds may still be pending when this
|
||||
// fires, and panTo on a map with no view throws "Set map center and zoom
|
||||
// first", so the catch is where the map gets its first one.
|
||||
try {
|
||||
const currentZoom = mapRef.current.getZoom()
|
||||
mapRef.current.flyTo(marker.getLatLng(), Math.max(currentZoom, 12), { duration: 0.5 })
|
||||
mapRef.current.panTo(marker.getLatLng(), { animate: true, duration: 0.5 })
|
||||
} catch {
|
||||
mapRef.current.setView(marker.getLatLng(), 12)
|
||||
}
|
||||
|
||||
@@ -34,6 +34,7 @@ interface Props {
|
||||
onMarkerClick?: (id: string, type?: string) => void
|
||||
fullScreen?: boolean
|
||||
paddingBottom?: number
|
||||
hideMarkerTooltip?: boolean
|
||||
}
|
||||
|
||||
function JourneyMapAuto({ ref, ...props }: Props) {
|
||||
|
||||
@@ -35,6 +35,7 @@ const gl = vi.hoisted(() => {
|
||||
remove: vi.fn(),
|
||||
resize: vi.fn(),
|
||||
flyTo: vi.fn(),
|
||||
easeTo: vi.fn(),
|
||||
fitBounds: vi.fn(),
|
||||
getZoom: vi.fn(() => 10),
|
||||
addSource: vi.fn(),
|
||||
@@ -460,19 +461,21 @@ describe('JourneyMapGL', () => {
|
||||
expect(document.querySelectorAll('#trek-journey-popup-style')).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('FE-COMP-JMAPGL-023: focusMarker flies in with the 3D pitch and a zoom floor of 14', () => {
|
||||
it('FE-COMP-JMAPGL-023: focusMarker eases over with the 3D pitch and no zoom of its own', () => {
|
||||
withToken()
|
||||
const ref = React.createRef<JourneyMapGLHandle>()
|
||||
render(<JourneyMapGL ref={ref} checkins={[]} entries={entries} />)
|
||||
|
||||
act(() => { ref.current!.focusMarker('e2') })
|
||||
|
||||
expect(gl.map.flyTo).toHaveBeenCalledWith({
|
||||
// No zoom in the call at all: stepping through the timeline keeps the frame the
|
||||
// reader chose (discussion #2299), same as the Leaflet twin.
|
||||
expect(gl.map.easeTo).toHaveBeenCalledWith({
|
||||
center: [13.405, 52.52],
|
||||
zoom: 14,
|
||||
pitch: 45,
|
||||
duration: 600,
|
||||
})
|
||||
expect(gl.map.flyTo).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('FE-COMP-JMAPGL-024: with 3D disabled the camera stays flat', () => {
|
||||
@@ -482,7 +485,7 @@ describe('JourneyMapGL', () => {
|
||||
|
||||
act(() => { ref.current!.focusMarker('e1') })
|
||||
|
||||
expect(gl.map.flyTo).toHaveBeenCalledWith(expect.objectContaining({ pitch: 0 }))
|
||||
expect(gl.map.easeTo).toHaveBeenCalledWith(expect.objectContaining({ pitch: 0 }))
|
||||
expect(addCustom3dBuildings).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
@@ -490,7 +493,7 @@ describe('JourneyMapGL', () => {
|
||||
withToken()
|
||||
const ref = React.createRef<JourneyMapGLHandle>()
|
||||
render(<JourneyMapGL ref={ref} checkins={[]} entries={entries} />)
|
||||
gl.map.getZoom.mockImplementationOnce(() => { throw new Error('not ready') })
|
||||
gl.map.easeTo.mockImplementationOnce(() => { throw new Error('not ready') })
|
||||
|
||||
expect(() => act(() => { ref.current!.focusMarker('e1') })).not.toThrow()
|
||||
expect(gl.map.flyTo).not.toHaveBeenCalled()
|
||||
@@ -512,19 +515,18 @@ describe('JourneyMapGL', () => {
|
||||
expect(gl.map.resize).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('FE-COMP-JMAPGL-028: the activeMarkerId prop highlights and flies after the settle delay', () => {
|
||||
it('FE-COMP-JMAPGL-028: the activeMarkerId prop highlights and eases over after the settle delay', () => {
|
||||
vi.useFakeTimers()
|
||||
try {
|
||||
withToken()
|
||||
render(<JourneyMapGL checkins={[]} entries={entries} activeMarkerId="e2" />)
|
||||
expect(gl.map.flyTo).not.toHaveBeenCalled()
|
||||
expect(gl.map.easeTo).not.toHaveBeenCalled()
|
||||
|
||||
act(() => { vi.advanceTimersByTime(60) })
|
||||
|
||||
expect(gl.popups).toHaveLength(1)
|
||||
expect(gl.map.flyTo).toHaveBeenCalledWith(expect.objectContaining({
|
||||
expect(gl.map.easeTo).toHaveBeenCalledWith(expect.objectContaining({
|
||||
center: [13.405, 52.52],
|
||||
zoom: 12,
|
||||
duration: 500,
|
||||
}))
|
||||
} finally {
|
||||
|
||||
@@ -35,6 +35,8 @@ interface Props {
|
||||
activeMarkerId?: string | null
|
||||
onMarkerClick?: (id: string, type?: string) => void
|
||||
fullScreen?: boolean
|
||||
/** See the Leaflet twin: no marker labels where a carousel already names the entry. */
|
||||
hideMarkerTooltip?: boolean
|
||||
paddingBottom?: number
|
||||
glProvider?: GlMapProvider
|
||||
/**
|
||||
@@ -217,8 +219,10 @@ const EMPTY_TRACKS: JourneyTrack[] = []
|
||||
const TRACK_FALLBACK_COLOR = '#4f46e5'
|
||||
|
||||
function JourneyMapGL(
|
||||
{ entries, trail, tracks, height = 220, dark, activeMarkerId, onMarkerClick, fullScreen, paddingBottom, glProvider = 'mapbox-gl', gl, ref }: Props,
|
||||
{ entries, trail, tracks, height = 220, dark, activeMarkerId, onMarkerClick, fullScreen, paddingBottom, glProvider = 'mapbox-gl', gl, hideMarkerTooltip, ref }: Props,
|
||||
) {
|
||||
const hideMarkerTooltipRef = useRef(hideMarkerTooltip)
|
||||
hideMarkerTooltipRef.current = hideMarkerTooltip
|
||||
const stableTrail = trail || EMPTY_TRAIL
|
||||
const stableTracks = tracks || EMPTY_TRACKS
|
||||
const rawMapboxStyle = useSettingsStore(s => s.settings.mapbox_style || MAPBOX_DEFAULT_STYLE)
|
||||
@@ -247,6 +251,9 @@ function JourneyMapGL(
|
||||
mapLangRef.current = mapLang
|
||||
|
||||
const showPopup = useCallback((id: string) => {
|
||||
// See `hideMarkerTooltip` on the Leaflet twin: below the carousel the card
|
||||
// already says this, and the popup only covers the map to repeat it.
|
||||
if (hideMarkerTooltipRef.current) return
|
||||
const item = itemsRef.current.find(i => i.id === id)
|
||||
if (!item || !mapRef.current) return
|
||||
ensureJourneyPopupStyle()
|
||||
@@ -330,14 +337,14 @@ function JourneyMapGL(
|
||||
}
|
||||
}, [setMarkerStyle, showPopup, hidePopup])
|
||||
|
||||
/** Pan to the marker and leave the zoom alone — see the Leaflet twin for why. */
|
||||
const focusMarker = useCallback((id: string) => {
|
||||
highlightMarker(id)
|
||||
const marker = markersRef.current.get(id)
|
||||
if (!marker || !mapRef.current) return
|
||||
try {
|
||||
mapRef.current.flyTo({
|
||||
mapRef.current.easeTo({
|
||||
center: marker.getLngLat(),
|
||||
zoom: Math.max(mapRef.current.getZoom(), 14),
|
||||
pitch: enableMapbox3d ? 45 : 0,
|
||||
duration: 600,
|
||||
})
|
||||
@@ -516,9 +523,8 @@ function JourneyMapGL(
|
||||
const marker = markersRef.current.get(activeMarkerId)
|
||||
if (!marker || !mapRef.current) return
|
||||
try {
|
||||
mapRef.current.flyTo({
|
||||
mapRef.current.easeTo({
|
||||
center: marker.getLngLat(),
|
||||
zoom: Math.max(mapRef.current.getZoom(), 12),
|
||||
pitch: enableMapbox3d && fullScreen ? 45 : 0,
|
||||
duration: 500,
|
||||
})
|
||||
|
||||
@@ -1,183 +0,0 @@
|
||||
// FE-COMP-JENTRYCARD-001 to FE-COMP-JENTRYCARD-014
|
||||
|
||||
import { render, screen, fireEvent } from '../../../tests/helpers/render'
|
||||
import { resetAllStores } from '../../../tests/helpers/store'
|
||||
import MobileEntryCard from './MobileEntryCard'
|
||||
|
||||
type CardEntry = Parameters<typeof MobileEntryCard>[0]['entry']
|
||||
|
||||
function buildEntry(overrides: Partial<Record<string, unknown>> = {}): CardEntry {
|
||||
return {
|
||||
id: 1,
|
||||
type: 'entry',
|
||||
title: 'Sunrise over the fjord',
|
||||
location_name: 'Geiranger, Møre og Romsdal, Norway',
|
||||
location_lat: 62.1,
|
||||
location_lng: 7.2,
|
||||
entry_date: '2025-06-01',
|
||||
entry_time: '07:30:00',
|
||||
mood: null,
|
||||
weather: null,
|
||||
photos: [],
|
||||
story: null,
|
||||
...overrides,
|
||||
} as unknown as CardEntry
|
||||
}
|
||||
|
||||
function renderCard(entry: CardEntry, props: Partial<{ isActive: boolean; onClick: () => void; publicPhotoUrl: (id: number) => string; dayLabel: number; dayColor: string }> = {}) {
|
||||
const onClick = props.onClick ?? vi.fn()
|
||||
const result = render(
|
||||
<MobileEntryCard
|
||||
entry={entry}
|
||||
dayLabel={props.dayLabel ?? 3}
|
||||
dayColor={props.dayColor ?? '#ff0055'}
|
||||
isActive={props.isActive ?? false}
|
||||
onClick={onClick}
|
||||
publicPhotoUrl={props.publicPhotoUrl}
|
||||
/>,
|
||||
)
|
||||
return { ...result, onClick }
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
resetAllStores()
|
||||
})
|
||||
|
||||
describe('MobileEntryCard', () => {
|
||||
it('FE-COMP-JENTRYCARD-001: shows the day badge, date and time', () => {
|
||||
renderCard(buildEntry())
|
||||
const badge = screen.getByText('3')
|
||||
expect(badge).toHaveStyle({ background: '#ff0055' })
|
||||
expect(screen.getByText('Jun 1')).toBeInTheDocument()
|
||||
// seconds are trimmed off the stored time
|
||||
expect(screen.getByText('· 07:30')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-COMP-JENTRYCARD-002: an entry without a time shows only the date', () => {
|
||||
renderCard(buildEntry({ entry_time: null }))
|
||||
expect(screen.queryByText(/·/)).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-COMP-JENTRYCARD-003: renders the first photo thumbnail from the api', () => {
|
||||
const { container } = renderCard(buildEntry({ photos: [{ photo_id: 42 }, { photo_id: 43 }] }))
|
||||
expect(container.querySelector('img')).toHaveAttribute('src', '/api/photos/42/thumbnail')
|
||||
})
|
||||
|
||||
it('FE-COMP-JENTRYCARD-003b: a photo carrying only an id also resolves against the api', () => {
|
||||
const { container } = renderCard(buildEntry({ photos: [{ id: 11 }] }))
|
||||
expect(container.querySelector('img')).toHaveAttribute('src', '/api/photos/11/thumbnail')
|
||||
})
|
||||
|
||||
it('FE-COMP-JENTRYCARD-004: a public journey builds its own photo url', () => {
|
||||
const publicPhotoUrl = (id: number) => `/public/p/${id}.jpg`
|
||||
const { container } = renderCard(buildEntry({ photos: [{ photo_id: 7 }] }), { publicPhotoUrl })
|
||||
expect(container.querySelector('img')).toHaveAttribute('src', '/public/p/7.jpg')
|
||||
})
|
||||
|
||||
it('FE-COMP-JENTRYCARD-004b: a shared-journey photo carrying only an id still resolves', () => {
|
||||
const publicPhotoUrl = (id: number) => `/public/p/${id}.jpg`
|
||||
const { container } = renderCard(buildEntry({ photos: [{ id: 11 }] }), { publicPhotoUrl })
|
||||
expect(container.querySelector('img')).toHaveAttribute('src', '/public/p/11.jpg')
|
||||
})
|
||||
|
||||
it('FE-COMP-JENTRYCARD-005: several photos add a counter badge', () => {
|
||||
const { container } = renderCard(
|
||||
buildEntry({ photos: [{ photo_id: 1 }, { photo_id: 2 }, { photo_id: 3 }] }),
|
||||
{ dayLabel: 9 },
|
||||
)
|
||||
expect(container.querySelector('svg.lucide-camera')).not.toBeNull()
|
||||
expect(screen.getByText('3')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-COMP-JENTRYCARD-006: a single photo has no counter badge', () => {
|
||||
const { container } = renderCard(buildEntry({ photos: [{ photo_id: 1 }] }))
|
||||
expect(container.querySelector('svg.lucide-camera')).toBeNull()
|
||||
})
|
||||
|
||||
it('FE-COMP-JENTRYCARD-007: without photos it falls back to a pin placeholder', () => {
|
||||
const { container } = renderCard(buildEntry())
|
||||
expect(container.querySelector('img')).toBeNull()
|
||||
expect(container.querySelectorAll('svg.lucide-map-pin').length).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
it('FE-COMP-JENTRYCARD-008: mood and weather render their own icons', () => {
|
||||
const { container } = renderCard(buildEntry({ mood: 'amazing', weather: 'rainy' }))
|
||||
expect(container.querySelector('svg.lucide-laugh')).not.toBeNull()
|
||||
expect(container.querySelector('svg.lucide-cloud-rain')).not.toBeNull()
|
||||
})
|
||||
|
||||
it('FE-COMP-JENTRYCARD-008b: every known mood gets its own chip colour', () => {
|
||||
const cases: [string, string, string][] = [
|
||||
['good', 'lucide-smile', 'bg-amber-100'],
|
||||
['neutral', 'lucide-meh', 'bg-zinc-100'],
|
||||
['rough', 'lucide-frown', 'bg-violet-100'],
|
||||
]
|
||||
for (const [mood, icon, bg] of cases) {
|
||||
const { container, unmount } = renderCard(buildEntry({ mood }))
|
||||
const chip = container.querySelector(`svg.${icon}`)!.parentElement!
|
||||
expect(chip.className).toContain(bg)
|
||||
unmount()
|
||||
}
|
||||
})
|
||||
|
||||
it('FE-COMP-JENTRYCARD-009: an unknown mood renders no mood icon', () => {
|
||||
const { container } = renderCard(buildEntry({ mood: 'ecstatic' }))
|
||||
expect(container.querySelector('svg.lucide-laugh')).toBeNull()
|
||||
expect(container.querySelector('svg.lucide-smile')).toBeNull()
|
||||
})
|
||||
|
||||
it('FE-COMP-JENTRYCARD-010: untitled entries fall back to a type-specific label', () => {
|
||||
const { unmount } = renderCard(buildEntry({ title: null, type: 'checkin' }))
|
||||
expect(screen.getByText('Check-in')).toBeInTheDocument()
|
||||
unmount()
|
||||
|
||||
const skeleton = renderCard(buildEntry({ title: null, type: 'skeleton' }))
|
||||
expect(screen.getByText('Add your story…')).toBeInTheDocument()
|
||||
skeleton.unmount()
|
||||
|
||||
renderCard(buildEntry({ title: '', type: 'entry' }))
|
||||
expect(screen.getByText('Untitled')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-COMP-JENTRYCARD-011: the story preview only appears on the active card, stripped of markdown', () => {
|
||||
const story = '## Day one\n\nWe **walked** to the pier'
|
||||
const inactive = renderCard(buildEntry({ story }))
|
||||
expect(screen.queryByText(/We walked to the pier/)).not.toBeInTheDocument()
|
||||
inactive.unmount()
|
||||
|
||||
renderCard(buildEntry({ story }), { isActive: true })
|
||||
expect(screen.getByText('Day one We walked to the pier')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-COMP-JENTRYCARD-012: the location badge shortens a long location name', () => {
|
||||
renderCard(buildEntry())
|
||||
// formatLocationName collapses the 3+ part name
|
||||
expect(screen.getByText('Geiranger, Møre og Romsdal, Norway')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-COMP-JENTRYCARD-013: a located entry without a name still says it is on the map', () => {
|
||||
renderCard(buildEntry({ location_name: null }))
|
||||
expect(screen.getByText('On the map')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-COMP-JENTRYCARD-014: an entry without coordinates is marked as unlocated', () => {
|
||||
renderCard(buildEntry({ location_lat: null, location_lng: null }))
|
||||
expect(screen.getByText('No location')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-COMP-JENTRYCARD-015: tapping the card calls back', () => {
|
||||
const onClick = vi.fn()
|
||||
renderCard(buildEntry(), { onClick })
|
||||
fireEvent.click(screen.getByRole('button'))
|
||||
expect(onClick).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('FE-COMP-JENTRYCARD-016: the active card grows', () => {
|
||||
const inactive = renderCard(buildEntry())
|
||||
expect(screen.getByRole('button').className).toContain('w-[240px]')
|
||||
inactive.unmount()
|
||||
|
||||
renderCard(buildEntry(), { isActive: true })
|
||||
expect(screen.getByRole('button').className).toContain('w-[320px]')
|
||||
})
|
||||
})
|
||||
@@ -1,163 +0,0 @@
|
||||
import { MapPin, Camera, Smile, Laugh, Meh, Frown, Sun, CloudSun, Cloud, CloudRain, CloudLightning, Snowflake } from 'lucide-react'
|
||||
import { formatLocationName } from '../../utils/formatters'
|
||||
import type { JourneyEntry } from '../../store/journeyStore'
|
||||
|
||||
const MOOD_ICONS: Record<string, typeof Smile> = {
|
||||
amazing: Laugh,
|
||||
good: Smile,
|
||||
neutral: Meh,
|
||||
rough: Frown,
|
||||
}
|
||||
|
||||
const MOOD_COLORS: Record<string, string> = {
|
||||
amazing: 'text-pink-500',
|
||||
good: 'text-amber-500',
|
||||
neutral: 'text-zinc-400',
|
||||
rough: 'text-violet-500',
|
||||
}
|
||||
|
||||
const WEATHER_ICONS: Record<string, typeof Sun> = {
|
||||
sunny: Sun,
|
||||
partly: CloudSun,
|
||||
cloudy: Cloud,
|
||||
rainy: CloudRain,
|
||||
stormy: CloudLightning,
|
||||
cold: Snowflake,
|
||||
}
|
||||
|
||||
// Shared journeys hand us photos that only carry `id`, the journey's own
|
||||
// photos carry `photo_id` — accept either.
|
||||
function photoId(p: { photo_id?: number; id?: number }): number | undefined {
|
||||
return p.photo_id ?? p.id
|
||||
}
|
||||
|
||||
function photoUrl(id: number): string {
|
||||
return `/api/photos/${id}/thumbnail`
|
||||
}
|
||||
|
||||
function stripMarkdown(text: string): string {
|
||||
return text
|
||||
.replace(/[#*_~`>\[\]()!|-]/g, '')
|
||||
.replace(/\n+/g, ' ')
|
||||
.trim()
|
||||
}
|
||||
|
||||
interface Props {
|
||||
entry: JourneyEntry | { id: number; type: string; title?: string | null; location_name?: string | null; location_lat?: number | null; location_lng?: number | null; entry_date: string; entry_time?: string | null; mood?: string | null; weather?: string | null; photos?: { photo_id: number }[]; story?: string | null }
|
||||
dayLabel: number
|
||||
dayColor: string
|
||||
isActive: boolean
|
||||
onClick: () => void
|
||||
publicPhotoUrl?: (photoId: number) => string
|
||||
}
|
||||
|
||||
export default function MobileEntryCard({ entry, dayLabel, dayColor, isActive, onClick, publicPhotoUrl }: Props) {
|
||||
const hasLocation = !!(entry.location_lat && entry.location_lng)
|
||||
const hasPhotos = entry.photos && entry.photos.length > 0
|
||||
const firstPhoto = hasPhotos ? entry.photos![0] : null
|
||||
const MoodIcon = entry.mood ? MOOD_ICONS[entry.mood] : null
|
||||
const moodColor = entry.mood ? MOOD_COLORS[entry.mood] : ''
|
||||
const WeatherIcon = entry.weather ? WEATHER_ICONS[entry.weather] : null
|
||||
|
||||
const firstPhotoId = firstPhoto ? photoId(firstPhoto) : undefined
|
||||
const thumbSrc = firstPhotoId == null
|
||||
? null
|
||||
: publicPhotoUrl
|
||||
? publicPhotoUrl(firstPhotoId)
|
||||
: photoUrl(firstPhotoId)
|
||||
|
||||
const date = new Date(entry.entry_date + 'T00:00:00')
|
||||
const dateStr = date.toLocaleDateString(undefined, { month: 'short', day: 'numeric' })
|
||||
|
||||
const storyPreview = entry.story ? stripMarkdown(entry.story) : ''
|
||||
|
||||
return (
|
||||
<button type="button"
|
||||
onClick={onClick}
|
||||
className={`flex-shrink-0 rounded-xl overflow-hidden text-left transition-all duration-100 ${
|
||||
isActive
|
||||
? 'w-[320px] sm:w-[340px] bg-white dark:bg-zinc-800 shadow-lg ring-2 ring-zinc-900/70 dark:ring-white/60'
|
||||
: 'w-[240px] sm:w-[260px] bg-white/90 dark:bg-zinc-800/90 shadow-md'
|
||||
} backdrop-blur-lg`}
|
||||
>
|
||||
<div className={`flex ${isActive ? 'h-[140px]' : 'h-[110px]'} transition-all duration-100`}>
|
||||
{/* Photo thumbnail */}
|
||||
{thumbSrc ? (
|
||||
<div className={`${isActive ? 'w-[110px]' : 'w-[90px]'} flex-shrink-0 relative overflow-hidden transition-all duration-100`}>
|
||||
<img
|
||||
src={thumbSrc}
|
||||
alt=""
|
||||
className="w-full h-full object-cover"
|
||||
loading="lazy"
|
||||
/>
|
||||
{hasPhotos && entry.photos!.length > 1 && (
|
||||
<div className="absolute bottom-1 right-1 flex items-center gap-0.5 bg-black/60 text-white rounded px-1 py-0.5 text-[10px] font-medium">
|
||||
<Camera size={10} />
|
||||
{entry.photos!.length}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className={`${isActive ? 'w-[110px]' : 'w-[90px]'} flex-shrink-0 bg-zinc-100 dark:bg-zinc-700 flex items-center justify-center transition-all duration-100`}>
|
||||
<MapPin size={20} className="text-zinc-300 dark:text-zinc-500" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Content */}
|
||||
<div className="flex-1 p-3 flex flex-col min-w-0">
|
||||
{/* Day number + date + mood/weather */}
|
||||
<div className="flex items-center gap-1.5 mb-1">
|
||||
<span className="w-5 h-5 rounded text-white text-[10px] font-bold flex items-center justify-center flex-shrink-0" style={{ background: dayColor }}>
|
||||
{dayLabel}
|
||||
</span>
|
||||
<span className="text-[11px] text-zinc-400 font-medium">{dateStr}</span>
|
||||
{entry.entry_time && (
|
||||
<span className="text-[11px] text-zinc-400">· {entry.entry_time.slice(0, 5)}</span>
|
||||
)}
|
||||
<div className="flex items-center gap-1.5 ml-auto flex-shrink-0">
|
||||
{MoodIcon && (
|
||||
<span className={`inline-flex items-center justify-center w-5 h-5 rounded-full ${
|
||||
entry.mood === 'amazing' ? 'bg-pink-100 dark:bg-pink-900/30' :
|
||||
entry.mood === 'good' ? 'bg-amber-100 dark:bg-amber-900/30' :
|
||||
entry.mood === 'rough' ? 'bg-violet-100 dark:bg-violet-900/30' :
|
||||
'bg-zinc-100 dark:bg-zinc-700'
|
||||
}`}>
|
||||
<MoodIcon size={11} className={moodColor} />
|
||||
</span>
|
||||
)}
|
||||
{WeatherIcon && (
|
||||
<span className="inline-flex items-center justify-center w-5 h-5 rounded-full bg-zinc-100 dark:bg-zinc-700">
|
||||
<WeatherIcon size={11} className="text-zinc-500 dark:text-zinc-400" />
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Title */}
|
||||
<h4 className="text-[13px] font-semibold text-zinc-900 dark:text-white leading-tight truncate">
|
||||
{entry.title || (entry.type === 'checkin' ? 'Check-in' : entry.type === 'skeleton' ? 'Add your story…' : 'Untitled')}
|
||||
</h4>
|
||||
|
||||
{/* Story preview (1-2 lines, only on active card) */}
|
||||
{isActive && storyPreview && (
|
||||
<p className="text-[11px] text-zinc-400 dark:text-zinc-500 leading-snug mt-0.5 line-clamp-2">
|
||||
{storyPreview}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{/* Location badge */}
|
||||
<div className="flex items-center gap-1 mt-auto">
|
||||
{hasLocation ? (
|
||||
<span className="inline-flex items-center gap-1 px-2 py-0.5 rounded-full bg-zinc-100 dark:bg-zinc-700 text-[10px] font-medium text-zinc-600 dark:text-zinc-300 max-w-full overflow-hidden">
|
||||
<MapPin size={10} className="flex-shrink-0" />
|
||||
<span className="truncate">{formatLocationName(entry.location_name) || 'On the map'}</span>
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-[10px] text-zinc-400 italic">No location</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
)
|
||||
}
|
||||
@@ -24,6 +24,7 @@ vi.mock('./JourneyMap', async () => {
|
||||
}
|
||||
})
|
||||
|
||||
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'
|
||||
@@ -119,24 +120,37 @@ 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()
|
||||
expect(lastMapProps.current).toMatchObject({ height: 9999, fullScreen: true, paddingBottom: 200 })
|
||||
// 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, hideMarkerTooltip: true })
|
||||
expect(screen.getByText('Louvre')).toBeInTheDocument()
|
||||
expect(screen.getByText('Museumsinsel')).toBeInTheDocument()
|
||||
expect(screen.getByText('Reichstag')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-COMP-JMAPTL-002: entries on the same day share a colour and count up', () => {
|
||||
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 badge = (card: HTMLElement) => card.querySelector('span[style]') as HTMLElement
|
||||
const dayBar = (card: HTMLElement) => [...card.querySelectorAll('span')].at(-1) as HTMLElement
|
||||
|
||||
expect(badge(cards[0]).textContent).toBe('1')
|
||||
expect(badge(cards[0])).toHaveStyle({ background: DAY_COLORS[0] })
|
||||
// second day, first and second entry of that day
|
||||
expect(badge(cards[1]).textContent).toBe('1')
|
||||
expect(badge(cards[2]).textContent).toBe('2')
|
||||
expect(badge(cards[1])).toHaveStyle({ background: DAY_COLORS[1] })
|
||||
expect(badge(cards[2])).toHaveStyle({ background: DAY_COLORS[1] })
|
||||
expect(dayBar(cards[0])).toHaveStyle({ background: DAY_COLORS[0] })
|
||||
expect(dayBar(cards[1])).toHaveStyle({ background: DAY_COLORS[1] })
|
||||
expect(dayBar(cards[2])).toHaveStyle({ background: 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', () => {
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
import { useRef, useState, useEffect, useCallback, useMemo } from 'react'
|
||||
import { Plus } from 'lucide-react'
|
||||
import JourneyMap from './JourneyMap'
|
||||
import MobileEntryCard from './MobileEntryCard'
|
||||
import JourneyEntryCover from './JourneyEntryCover'
|
||||
import JourneyDayScrubber from './JourneyDayScrubber'
|
||||
import { dayColorOf, journeyDays } from './journeyCard'
|
||||
import type { JourneyMapHandle } from './JourneyMap'
|
||||
import type { JourneyEntry } from '../../store/journeyStore'
|
||||
import { DAY_COLORS } from './dayColors'
|
||||
import type { JourneyTrack } from '@trek/shared'
|
||||
|
||||
interface MapEntry {
|
||||
@@ -29,6 +30,11 @@ interface Props {
|
||||
carouselBottom?: string
|
||||
/** CARTO key from the share payload, forwarded to the map (#2054). */
|
||||
cartoApiKey?: string
|
||||
/** Off when the journey has put that field away (journey settings). */
|
||||
showMood?: boolean
|
||||
showWeather?: boolean
|
||||
/** Which entry to open on. Today's, when today is part of the journey (#2299). */
|
||||
initialEntryId?: string | null
|
||||
}
|
||||
|
||||
export default function MobileMapTimeline({
|
||||
@@ -43,21 +49,15 @@ export default function MobileMapTimeline({
|
||||
publicPhotoUrl,
|
||||
carouselBottom = 'calc(var(--bottom-nav-h, 84px) + 8px)',
|
||||
cartoApiKey,
|
||||
showMood = true,
|
||||
showWeather = true,
|
||||
initialEntryId,
|
||||
}: Props) {
|
||||
const mapRef = useRef<JourneyMapHandle>(null)
|
||||
const carouselRef = useRef<HTMLDivElement>(null)
|
||||
const [activeIndex, setActiveIndex] = useState(0)
|
||||
|
||||
const entryDayMeta = useMemo(() => {
|
||||
const uniqueDates = [...new Set(entries.map((e: any) => e.entry_date).sort((a, b) => (a < b ? -1 : a > b ? 1 : 0)))]
|
||||
const counters = new Map<string, number>()
|
||||
return entries.map((e: any) => {
|
||||
const dayIdx = uniqueDates.indexOf(e.entry_date)
|
||||
const dayLabel = (counters.get(e.entry_date) ?? 0) + 1
|
||||
counters.set(e.entry_date, dayLabel)
|
||||
return { dayLabel, dayColor: DAY_COLORS[dayIdx % DAY_COLORS.length] }
|
||||
})
|
||||
}, [entries])
|
||||
const scrubberDays = useMemo(() => journeyDays(entries), [entries])
|
||||
const cardRefs = useRef<Map<number, HTMLDivElement>>(new Map())
|
||||
// Sync map focus when carousel scrolls (with guard for uninitialized map)
|
||||
const syncMapToCarousel = useCallback((index: number) => {
|
||||
@@ -141,15 +141,37 @@ export default function MobileMapTimeline({
|
||||
}
|
||||
}, [activeIndex, onEntryClick, scrollCardIntoCenter])
|
||||
|
||||
// The day bar lands on the first entry of that day, which is where a reader
|
||||
// who asked for "day nine" means.
|
||||
const jumpToDay = useCallback((date: string) => {
|
||||
const idx = entries.findIndex((e: any) => e.entry_date === date)
|
||||
if (idx === -1) return
|
||||
setActiveIndex(idx)
|
||||
syncMapToCarousel(idx)
|
||||
scrollCardIntoCenter(idx)
|
||||
}, [entries, scrollCardIntoCenter, syncMapToCarousel])
|
||||
|
||||
// Initial map focus — delay to let Leaflet initialize and fitBounds. Also
|
||||
// re-runs when the markers arrive later than the entries, otherwise the
|
||||
// focus would fire against an empty map and never be retried.
|
||||
const openedRef = useRef(false)
|
||||
useEffect(() => {
|
||||
if (entries.length > 0) {
|
||||
const timer = setTimeout(() => syncMapToCarouselRef.current(activeIndexRef.current), 500)
|
||||
return () => clearTimeout(timer)
|
||||
}
|
||||
}, [entries.length, mapEntries.length])
|
||||
if (entries.length === 0) return
|
||||
const timer = setTimeout(() => {
|
||||
if (!openedRef.current && initialEntryId) {
|
||||
openedRef.current = true
|
||||
const idx = entries.findIndex((e: any) => String(e.id) === initialEntryId)
|
||||
if (idx > 0) {
|
||||
setActiveIndex(idx)
|
||||
cardRefs.current.get(idx)?.scrollIntoView({ inline: 'center', block: 'nearest' })
|
||||
syncMapToCarouselRef.current(idx)
|
||||
return
|
||||
}
|
||||
}
|
||||
syncMapToCarouselRef.current(activeIndexRef.current)
|
||||
}, 500)
|
||||
return () => clearTimeout(timer)
|
||||
}, [entries.length, mapEntries.length, initialEntryId])
|
||||
|
||||
const activeEntryId = entries[activeIndex]
|
||||
? String(entries[activeIndex].id)
|
||||
@@ -204,18 +226,24 @@ export default function MobileMapTimeline({
|
||||
activeMarkerId={activeEntryId}
|
||||
onMarkerClick={handleMarkerClick}
|
||||
fullScreen
|
||||
paddingBottom={200}
|
||||
paddingBottom={250}
|
||||
cartoApiKey={cartoApiKey}
|
||||
hideMarkerTooltip
|
||||
/>
|
||||
|
||||
{/* Bottom carousel */}
|
||||
{/* Day bar + card carousel, as one block at the bottom of the map */}
|
||||
<div
|
||||
className="fixed left-0 right-0 z-40"
|
||||
style={{ touchAction: 'pan-x', bottom: carouselBottom }}
|
||||
>
|
||||
<JourneyDayScrubber
|
||||
days={scrubberDays}
|
||||
activeDate={entries[activeIndex]?.entry_date ?? null}
|
||||
onPick={jumpToDay}
|
||||
/>
|
||||
<div
|
||||
ref={carouselRef}
|
||||
className="flex gap-3 overflow-x-auto px-4 pb-3 pt-1"
|
||||
className="flex items-end gap-[10px] overflow-x-auto px-4 pb-3 pt-1"
|
||||
style={{
|
||||
scrollSnapType: 'x mandatory',
|
||||
WebkitOverflowScrolling: 'touch',
|
||||
@@ -230,13 +258,14 @@ export default function MobileMapTimeline({
|
||||
ref={node => { if (node) cardRefs.current.set(i, node); else cardRefs.current.delete(i); }}
|
||||
style={{ scrollSnapAlign: 'center' }}
|
||||
>
|
||||
<MobileEntryCard
|
||||
<JourneyEntryCover
|
||||
entry={entry}
|
||||
dayLabel={entryDayMeta[i]?.dayLabel ?? i + 1}
|
||||
dayColor={entryDayMeta[i]?.dayColor ?? DAY_COLORS[0]}
|
||||
dayColor={dayColorOf(scrubberDays, entry.entry_date)}
|
||||
isActive={i === activeIndex}
|
||||
onClick={() => handleCardTap(entry, i)}
|
||||
publicPhotoUrl={publicPhotoUrl}
|
||||
showMood={showMood}
|
||||
showWeather={showWeather}
|
||||
photoUrlFor={publicPhotoUrl}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
@@ -247,7 +276,7 @@ export default function MobileMapTimeline({
|
||||
{!readOnly && onAddEntry && (
|
||||
<div
|
||||
className="fixed right-4 z-30"
|
||||
style={{ bottom: 'calc(var(--bottom-nav-h, 84px) + 168px)' }}
|
||||
style={{ bottom: 'calc(var(--bottom-nav-h, 84px) + 226px)' }}
|
||||
>
|
||||
<button type="button"
|
||||
onClick={onAddEntry}
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
// FE-JRN-CARDMODEL-001 to FE-JRN-CARDMODEL-010
|
||||
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { cardDateLabel, cardPhotoId, cardPlace, cardTitle, countryFlag, dayColorOf, journeyDays } from './journeyCard'
|
||||
import { DAY_COLORS } from './dayColors'
|
||||
|
||||
const t = (key: string) => key
|
||||
|
||||
describe('countryFlag', () => {
|
||||
it('FE-JRN-CARDMODEL-001: turns an ISO code into the flag', () => {
|
||||
expect(countryFlag('DE')).toBe('🇩🇪')
|
||||
expect(countryFlag('jp')).toBe('🇯🇵')
|
||||
})
|
||||
|
||||
it('FE-JRN-CARDMODEL-002: draws nothing rather than two stray letters', () => {
|
||||
// Everything that is not a plain alpha-2 pair, plus Kosovo, which has no flag
|
||||
// on any platform and would render as the letters XK in a corner.
|
||||
expect(countryFlag(null)).toBe('')
|
||||
expect(countryFlag(undefined)).toBe('')
|
||||
expect(countryFlag('')).toBe('')
|
||||
expect(countryFlag('DEU')).toBe('')
|
||||
expect(countryFlag('D1')).toBe('')
|
||||
expect(countryFlag('XK')).toBe('')
|
||||
})
|
||||
})
|
||||
|
||||
describe('cardTitle', () => {
|
||||
it('FE-JRN-CARDMODEL-003: uses the title when there is one', () => {
|
||||
expect(cardTitle({ title: 'Mercado', type: 'entry', location_name: 'Lisbon' }, t)).toBe('Mercado')
|
||||
})
|
||||
|
||||
it('FE-JRN-CARDMODEL-004: says what a suggestion is rather than calling it untitled', () => {
|
||||
expect(cardTitle({ title: null, type: 'skeleton', location_name: 'Museum' }, t)).toBe('journey.entry.suggestion')
|
||||
})
|
||||
|
||||
it('FE-JRN-CARDMODEL-005: falls back to the place before it falls back to a placeholder', () => {
|
||||
// Where you were is at least true; "Untitled" says nothing at all.
|
||||
expect(cardTitle({ title: ' ', type: 'entry', location_name: 'Porto, Portugal' }, t)).toBe('Porto, Portugal')
|
||||
expect(cardTitle({ title: null, type: 'entry', location_name: null }, t)).toBe('journey.editor.titlePlaceholder')
|
||||
expect(cardTitle({ title: null, type: 'checkin', location_name: null }, t)).toBe('journey.detail.journeyTab')
|
||||
})
|
||||
})
|
||||
|
||||
describe('cardPlace', () => {
|
||||
it('FE-JRN-CARDMODEL-006: is left out when the title is already the place', () => {
|
||||
// cardTitle promoted the place to the title, so repeating it below would be
|
||||
// the same words twice on a card that has room for two lines.
|
||||
expect(cardPlace({ title: null, location_name: 'Porto, Portugal' })).toBe('')
|
||||
expect(cardPlace({ title: 'Mercado', location_name: 'Porto, Portugal' })).toBe('Porto, Portugal')
|
||||
expect(cardPlace({ title: 'Mercado', location_name: null })).toBe('')
|
||||
})
|
||||
})
|
||||
|
||||
describe('cardPhotoId', () => {
|
||||
it('FE-JRN-CARDMODEL-007: reads either shape a shell hands over', () => {
|
||||
expect(cardPhotoId({ photo_id: 4, id: 9 })).toBe(4)
|
||||
expect(cardPhotoId({ id: 9 })).toBe(9)
|
||||
expect(cardPhotoId(undefined)).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe('cardDateLabel', () => {
|
||||
it('FE-JRN-CARDMODEL-008: is short, and in the reader locale', () => {
|
||||
expect(cardDateLabel('2026-03-15', 'en-GB')).toBe('15 Mar')
|
||||
})
|
||||
})
|
||||
|
||||
describe('journeyDays', () => {
|
||||
it('FE-JRN-CARDMODEL-009: one entry per day, in order, coloured by position', () => {
|
||||
const days = journeyDays([
|
||||
{ entry_date: '2026-03-16' },
|
||||
{ entry_date: '2026-03-15' },
|
||||
{ entry_date: '2026-03-16' },
|
||||
])
|
||||
|
||||
expect(days).toEqual([
|
||||
{ date: '2026-03-15', color: DAY_COLORS[0] },
|
||||
{ date: '2026-03-16', color: DAY_COLORS[1] },
|
||||
])
|
||||
})
|
||||
|
||||
it('FE-JRN-CARDMODEL-010: a date nobody knows still gets a colour', () => {
|
||||
const days = journeyDays([{ entry_date: '2026-03-15' }])
|
||||
expect(dayColorOf(days, '2026-03-15')).toBe(DAY_COLORS[0])
|
||||
expect(dayColorOf(days, '2099-01-01')).toBe(DAY_COLORS[0])
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,96 @@
|
||||
import { formatLocationName } from '../../utils/formatters'
|
||||
import { DAY_COLORS } from './dayColors'
|
||||
import type { JourneyEntry } from '../../store/journeyStore'
|
||||
|
||||
/**
|
||||
* What a timeline card needs to know about an entry, worked out once.
|
||||
*
|
||||
* The phone shell and the tablet/public timeline draw the same card in two
|
||||
* different token sets, so the markup lives in `JourneyEntryCover` and the
|
||||
* questions it asks live here. Anything a card has to decide — which photo,
|
||||
* what to call an entry with no title, which flag — is decided in this file so
|
||||
* the two surfaces cannot answer it differently.
|
||||
*/
|
||||
|
||||
/** A photo as either shell hands it over: the journey's own carry `photo_id`, shared ones only `id`. */
|
||||
export interface CardPhoto {
|
||||
photo_id?: number
|
||||
id?: number
|
||||
}
|
||||
|
||||
export function cardPhotoId(photo: CardPhoto | undefined): number | undefined {
|
||||
return photo?.photo_id ?? photo?.id
|
||||
}
|
||||
|
||||
/**
|
||||
* The flag of the country an entry happened in.
|
||||
*
|
||||
* Two regional-indicator code points, which every platform we ship to renders as
|
||||
* a flag and every other one renders as the country's two letters — a fair
|
||||
* fallback either way. Anything that is not a plain ISO 3166-1 alpha-2 pair
|
||||
* (including the UN's `XK` for Kosovo, which has no flag) draws nothing rather
|
||||
* than two stray letters in a corner.
|
||||
*/
|
||||
export function countryFlag(code: string | null | undefined): string {
|
||||
if (!code || !/^[A-Za-z]{2}$/.test(code)) return ''
|
||||
const upper = code.toUpperCase()
|
||||
if (upper === 'XK') return ''
|
||||
return String.fromCodePoint(...[...upper].map(c => 0x1f1e6 + c.charCodeAt(0) - 65))
|
||||
}
|
||||
|
||||
/** Short date for the card corner: "12 Sep", in the reader's locale and order. */
|
||||
export function cardDateLabel(entryDate: string, locale: string): string {
|
||||
return new Date(entryDate + 'T00:00:00').toLocaleDateString(locale, { day: 'numeric', month: 'short' })
|
||||
}
|
||||
|
||||
/**
|
||||
* The name to put on a card.
|
||||
*
|
||||
* An untitled entry is common — quick capture used to have no title field at all
|
||||
* — so the fallback matters as much as the title. A suggestion says what it is,
|
||||
* because a card that says "Untitled" over a photo of somewhere you have not been
|
||||
* yet is a lie; anything else falls back to its place before it falls back to a
|
||||
* placeholder, since where you were is at least true.
|
||||
*/
|
||||
export function cardTitle(
|
||||
entry: Pick<JourneyEntry, 'title' | 'type' | 'location_name'>,
|
||||
t: (key: string) => string,
|
||||
): string {
|
||||
if (entry.title?.trim()) return entry.title
|
||||
if (entry.type === 'skeleton') return t('journey.entry.suggestion')
|
||||
const place = formatLocationName(entry.location_name)
|
||||
if (place) return place
|
||||
return entry.type === 'checkin' ? t('journey.detail.journeyTab') : t('journey.editor.titlePlaceholder')
|
||||
}
|
||||
|
||||
/** The place line under the title, left out when the title is already the place. */
|
||||
export function cardPlace(
|
||||
entry: Pick<JourneyEntry, 'title' | 'location_name'>,
|
||||
): string {
|
||||
const place = formatLocationName(entry.location_name)
|
||||
if (!place) return ''
|
||||
return entry.title?.trim() ? place : ''
|
||||
}
|
||||
|
||||
/** One day of a journey, in the order it happened, with the colour everything else uses for it. */
|
||||
export interface JourneyDay {
|
||||
date: string
|
||||
color: string
|
||||
}
|
||||
|
||||
/**
|
||||
* The days a set of entries spans, and which colour each of them wears.
|
||||
*
|
||||
* The map markers, the card edges and the day bar all have to agree on "day
|
||||
* three is teal", and they used to work it out separately from the same list —
|
||||
* which held only as long as all three filtered that list identically.
|
||||
*/
|
||||
export function journeyDays(entries: { entry_date: string }[]): JourneyDay[] {
|
||||
const dates = [...new Set(entries.map(e => e.entry_date))].sort((a, b) => (a < b ? -1 : a > b ? 1 : 0))
|
||||
return dates.map((date, i) => ({ date, color: DAY_COLORS[i % DAY_COLORS.length] }))
|
||||
}
|
||||
|
||||
/** Look a date's colour up out of `journeyDays`, falling back to the first day's. */
|
||||
export function dayColorOf(days: JourneyDay[], date: string): string {
|
||||
return days.find(d => d.date === date)?.color ?? DAY_COLORS[0]
|
||||
}
|
||||
Reference in New Issue
Block a user