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:
Maurice
2026-09-13 18:45:57 +02:00
parent 6edb4462f9
commit c2707b7eff
72 changed files with 2303 additions and 738 deletions
+3
View File
@@ -1022,6 +1022,9 @@ export const journeyApi = {
// Preferences
updatePreferences: (id: number, data: { hide_skeletons?: boolean }) => apiClient.patch(`/journeys/${id}/preferences`, data).then(r => r.data),
/** Bring every waved-away trip suggestion back. Answers with how many returned. */
restoreSuggestions: (id: number): Promise<{ restored: number }> =>
apiClient.post(`/journeys/${id}/suggestions/restore`).then(r => r.data),
// Share
getShareLink: (id: number) => apiClient.get(`/journeys/${id}/share-link`).then(r => r.data),
@@ -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 {
+37 -11
View File
@@ -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 {
+11 -5
View File
@@ -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]
}
+5 -1
View File
@@ -8,6 +8,8 @@ interface MIconBtnProps {
/** Diameter in px (topbar 38, sheet close 34) */
size?: number
className?: string
/** Dimmed and inert. For a button whose position should stay put even when it cannot act. */
disabled?: boolean
children: ReactNode
}
@@ -18,6 +20,7 @@ export default function MIconBtn({
variant = 'glass',
size = 38,
className = '',
disabled = false,
children,
}: MIconBtnProps) {
return (
@@ -25,7 +28,8 @@ export default function MIconBtn({
type="button"
onClick={onClick}
aria-label={ariaLabel}
className={`relative flex flex-none items-center justify-center rounded-full text-m-ink ${
disabled={disabled}
className={`relative flex flex-none items-center justify-center rounded-full text-m-ink disabled:opacity-35 ${
variant === 'glass'
? 'border border-[color:var(--m-gbr)] bg-[color:var(--m-glass)]'
: 'bg-[color:var(--m-ic)]'
@@ -24,7 +24,9 @@ import MSheet from '../../components/MSheet'
import MDancingTrek from '../../components/MDancingTrek'
import MListRow from '../../components/MListRow'
import MToggle from '../../components/MToggle'
import MJourneyEntryCard from './MJourneyEntryCard'
import JourneyEntryCover from '../../../components/Journey/JourneyEntryCover'
import JourneyDayScrubber from '../../../components/Journey/JourneyDayScrubber'
import { dayColorOf, journeyDays } from '../../../components/Journey/journeyCard'
import MJourneyEntrySheet from './MJourneyEntrySheet'
import MJourneySettingsSheet from './MJourneySettingsSheet'
@@ -45,7 +47,8 @@ export default function MJourneyDetail() {
showSettings, setShowSettings,
hideSkeletons, setHideSkeletons,
sidebarMapItems, tracks,
loadJourney, updateEntry, deleteEntry, uploadPhotos,
dismissSuggestion, restoreSuggestions, openAtEntryId,
loadJourney, updateEntry, deleteEntry, reorderEntries, uploadPhotos,
} = useJourneyDetail()
// The dock's FAB is a sibling of this screen: on the Gallery it becomes the
@@ -112,18 +115,69 @@ export default function MJourneyDetail() {
}
}, [entries.length, pickNearestCard])
// Initial focus — give Leaflet time to initialise and fit bounds first.
// Initial focus — give Leaflet time to initialise and fit bounds first. Opens on
// today when today is part of the journey (see openAtEntryId), rather than always
// at the first entry (discussion #2299).
useEffect(() => {
if (entries.length === 0) return
const timer = window.setTimeout(() => syncMapToCard(0), 500)
const target = openAtEntryId ? entries.findIndex(e => String(e.id) === openAtEntryId) : -1
const index = target === -1 ? 0 : target
const timer = window.setTimeout(() => {
setActiveIndex(index)
syncMapToCard(index)
if (index > 0) cardRefs.current.get(index)?.scrollIntoView({ inline: 'center', block: 'nearest' })
}, 500)
return () => window.clearTimeout(timer)
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [entries.length])
}, [entries.length, openAtEntryId])
const scrollCardIntoCenter = useCallback((idx: number) => {
cardRefs.current.get(idx)?.scrollIntoView({ behavior: 'smooth', inline: 'center', block: 'nearest' })
}, [])
const scrubberDays = useMemo(() => journeyDays(entries), [entries])
/**
* Move an entry within its day, from the entry sheet's header.
*
* Same rules as the desktop arrows: suggestions do not take part, because they
* are the trip's order rather than the traveller's, and a day with one entry
* has nothing to reorder. The whole day goes back to the server, which is what
* the reorder endpoint expects.
*/
const moveWithinDay = useCallback(async (entry: JourneyEntry, direction: -1 | 1) => {
if (!current) return
const sameDay = entries.filter(e => e.entry_date === entry.entry_date && e.type !== 'skeleton')
const index = sameDay.findIndex(e => e.id === entry.id)
const target = index + direction
if (index === -1 || target < 0 || target >= sameDay.length) return
const reordered = [...sameDay]
const [moved] = reordered.splice(index, 1)
reordered.splice(target, 0, moved)
try {
await reorderEntries(current.id, reordered.map(e => e.id))
await loadJourney(current.id)
} catch {
toast.error(t('common.errorOccurred'))
}
}, [current, entries, reorderEntries, loadJourney, toast, t])
/** Where in the day's run an entry sits, so the sheet knows which arrows to offer. */
const dayNeighbours = useCallback((entry: JourneyEntry) => {
const sameDay = entries.filter(e => e.entry_date === entry.entry_date && e.type !== 'skeleton')
const index = sameDay.findIndex(e => e.id === entry.id)
return { canMoveUp: index > 0, canMoveDown: index >= 0 && index < sameDay.length - 1 }
}, [entries])
/** The day bar lands on the first entry of that day. */
const jumpToDay = useCallback((date: string) => {
const idx = entries.findIndex(e => e.entry_date === date)
if (idx === -1) return
setActiveIndex(idx)
syncMapToCard(idx)
scrollCardIntoCenter(idx)
}, [entries, scrollCardIntoCenter, syncMapToCard])
const handleMarkerClick = useCallback((markerId: string) => {
const idx = entries.findIndex(e => String(e.id) === markerId)
if (idx === -1) return
@@ -290,7 +344,8 @@ export default function MJourneyDetail() {
activeMarkerId={entries[activeIndex] ? String(entries[activeIndex].id) : null}
onMarkerClick={handleMarkerClick}
fullScreen
paddingBottom={200}
paddingBottom={250}
hideMarkerTooltip
/>
</div>
@@ -428,11 +483,17 @@ export default function MJourneyDetail() {
</MSheet>
)}
{/* Horizontal card timeline */}
{/* Day bar + horizontal card timeline */}
{view === 'timeline' && entries.length > 0 && (
<div className="absolute left-0 right-0 z-[8] bottom-[calc(var(--bottom-nav-h,84px)+16px)]">
<JourneyDayScrubber
days={scrubberDays}
activeDate={entries[activeIndex]?.entry_date ?? null}
onPick={jumpToDay}
/>
<div
ref={carouselRef}
className="absolute left-0 right-0 z-[8] flex gap-[10px] overflow-x-auto px-4 pb-1 bottom-[calc(var(--bottom-nav-h,84px)+16px)] [-webkit-overflow-scrolling:touch] [scrollbar-width:none]"
className="flex items-end gap-[10px] overflow-x-auto px-4 pb-1 [-webkit-overflow-scrolling:touch] [scrollbar-width:none]"
style={{ scrollSnapType: 'x mandatory' }}
>
{entries.map((entry, i) => (
@@ -441,10 +502,19 @@ export default function MJourneyDetail() {
ref={node => { if (node) cardRefs.current.set(i, node); else cardRefs.current.delete(i) }}
style={{ scrollSnapAlign: 'center' }}
>
<MJourneyEntryCard entry={entry} number={i + 1} onClick={() => handleCardTap(entry, i)} />
<JourneyEntryCover
entry={entry}
dayColor={dayColorOf(scrubberDays, entry.entry_date)}
isActive={i === activeIndex}
onClick={() => handleCardTap(entry, i)}
showMood={current.show_mood !== 0}
showWeather={current.show_weather !== 0}
tone="mobile"
/>
</div>
))}
</div>
</div>
)}
<input ref={galleryFileRef} type="file" accept="image/*,video/*" multiple className="hidden" onChange={handleGalleryUpload} />
@@ -493,8 +563,26 @@ export default function MJourneyDetail() {
galleryPhotos={gallery}
quickCapture={editingEntry.id === 0}
readOnly={!canEditEntries}
showVerdict={current.show_verdict !== 0}
showMood={current.show_mood !== 0}
showWeather={current.show_weather !== 0}
userId={useAuthStore.getState().user?.id || 0}
trips={current.trips}
onMoveEarlier={
canEditEntries && editingEntry.id !== 0 && editingEntry.type !== 'skeleton' && dayNeighbours(editingEntry).canMoveUp
? () => { void moveWithinDay(editingEntry, -1) }
: undefined
}
onMoveLater={
canEditEntries && editingEntry.id !== 0 && editingEntry.type !== 'skeleton' && dayNeighbours(editingEntry).canMoveDown
? () => { void moveWithinDay(editingEntry, 1) }
: undefined
}
onDismiss={
canEditEntries && editingEntry.type === 'skeleton' && editingEntry.id !== 0
? () => { setEditingEntry(null); void dismissSuggestion(editingEntry) }
: undefined
}
onClose={() => setEditingEntry(null)}
onSave={async (data, existingEntryId) => {
// existingEntryId is what the sheet already persisted in an earlier
@@ -531,6 +619,7 @@ export default function MJourneyDetail() {
onSaved={() => { setShowSettings(false); loadJourney(Number(id)) }}
onOpenInvite={() => setShowInvite(true)}
onRefresh={() => loadJourney(Number(id))}
onRestoreSuggestions={canEditEntries ? restoreSuggestions : undefined}
/>
)}
@@ -1,56 +0,0 @@
// FE-COMP-MJOURNEYCARD-001 to FE-COMP-MJOURNEYCARD-003
import { describe, it, expect, vi } from 'vitest'
import { render, screen } from '../../../../tests/helpers/render'
import MJourneyEntryCard from './MJourneyEntryCard'
import type { JourneyEntry } from '../../../store/journeyStore'
// #2022: a long entry comment grew the card, and with it the bottom-anchored
// carousel, until the timeline covered the map. jsdom does no layout, so these
// assert the class contract the browser cascade depends on, not pixel heights.
const LONG_STORY = Array.from({ length: 400 }, (_, i) => `wort${i % 7}`).join(' ')
// Tailwind emits the display plugin AFTER lineClamp, so any display utility on
// the same element overrides `line-clamp-*`'s own `-webkit-box` and the clamp
// silently stops working.
const DISPLAY_UTILITIES = ['block', 'inline-block', 'flex', 'inline-flex', 'grid', 'inline-grid', 'flow-root', 'contents', 'inline']
const buildEntry = (overrides: Partial<JourneyEntry> = {}): JourneyEntry => ({
id: 1,
journey_id: 7,
author_id: 1,
type: 'entry',
title: 'Erster Tag',
story: LONG_STORY,
entry_date: '2026-03-03',
visibility: 'private',
sort_order: 0,
photos: [],
created_at: 0,
updated_at: 0,
...overrides,
})
describe('MJourneyEntryCard', () => {
it('FE-COMP-MJOURNEYCARD-001: keeps the story preview clamp free of a competing display utility', () => {
render(<MJourneyEntryCard entry={buildEntry()} number={1} onClick={vi.fn()} />)
const classes = screen.getByText(LONG_STORY).className.split(/\s+/)
expect(classes).toContain('line-clamp-2')
DISPLAY_UTILITIES.forEach(utility => expect(classes).not.toContain(utility))
})
it('FE-COMP-MJOURNEYCARD-002: caps the card height so the timeline cannot grow over the map', () => {
render(<MJourneyEntryCard entry={buildEntry()} number={1} onClick={vi.fn()} />)
const classes = screen.getByRole('button').className.split(/\s+/)
expect(classes.some(c => /^max-h-\[\d+px\]$/.test(c))).toBe(true)
expect(classes).toContain('overflow-hidden')
})
it('FE-COMP-MJOURNEYCARD-003: renders no preview for an entry without a story', () => {
render(<MJourneyEntryCard entry={buildEntry({ story: null })} number={2} onClick={vi.fn()} />)
expect(screen.queryByText(LONG_STORY)).not.toBeInTheDocument()
expect(screen.getByText('Erster Tag')).toBeInTheDocument()
})
})
@@ -1,101 +0,0 @@
import { MapPin, RouteOff } from 'lucide-react'
import { useTranslation } from '../../../i18n'
import { formatLocationName } from '../../../utils/formatters'
import { stripMarkdown } from '../../../components/Journey/stripMarkdown'
import type { JourneyEntry } from '../../../store/journeyStore'
import { moodMeta, weatherMeta } from './mobileJourneyMeta'
interface MJourneyEntryCardProps {
entry: JourneyEntry
number: number
onClick: () => void
}
/** One 280px card of the horizontal journey timeline (photo column, number badge, mood/weather dots). */
export default function MJourneyEntryCard({ entry, number, onClick }: MJourneyEntryCardProps) {
const { t, locale } = useTranslation()
const firstPhoto = entry.photos?.[0]
const mood = moodMeta(entry.mood)
const weather = weatherMeta(entry.weather)
const location = formatLocationName(entry.location_name)
const dateLabel = new Date(entry.entry_date + 'T00:00:00').toLocaleDateString(locale, {
day: 'numeric',
month: 'short',
})
const storyPreview = entry.story ? stripMarkdown(entry.story) : ''
const title = entry.title || (entry.type === 'checkin' ? t('journey.detail.journeyTab') : t('journey.editor.titlePlaceholder'))
// The timeline this card sits in is bottom-anchored with no height of its own, so a card
// that grows with its story climbs over the map and swallows its touches (#2022). Cap the
// card at the height the old journey timeline pins its active card to.
return (
<button
type="button"
onClick={onClick}
className="flex max-h-[140px] w-[280px] flex-none gap-[11px] overflow-hidden rounded-[20px] bg-[color:var(--m-sheet)] p-[11px] text-left shadow-[0_16px_40px_-18px_rgba(0,0,0,.5)]"
>
{firstPhoto && (
<span className="flex w-16 flex-none flex-col gap-[5px]">
<img
src={`/api/photos/${firstPhoto.photo_id}/thumbnail`}
alt=""
loading="lazy"
className="h-16 w-16 rounded-[13px] object-cover"
/>
{location && (
<span className="inline-flex items-center justify-center gap-[3px] overflow-hidden truncate whitespace-nowrap rounded-full bg-[color:var(--m-ic)] px-[6px] py-[2px] font-geist text-[0.53125rem] font-bold text-m-muted">
<MapPin size={8} strokeWidth={2.4} className="flex-none" />
<span className="truncate">{location}</span>
</span>
)}
</span>
)}
<span className="min-w-0 flex-1">
<span className="flex items-center gap-[6px]">
<span className="flex h-5 w-5 flex-none items-center justify-center rounded-full bg-[#4A5BD4] text-[0.625rem] font-extrabold text-white">
{number}
</span>
<span className="whitespace-nowrap rounded-full bg-[color:var(--m-ic)] px-2 py-[2px] font-geist text-[0.5625rem] font-bold text-m-muted">
{dateLabel}
</span>
{/* Switched off the printed route (#2064). Shrinks before the date does. */}
{entry.stats_excluded && (
<span className="inline-flex min-w-0 items-center gap-[3px] overflow-hidden truncate whitespace-nowrap rounded-full bg-[color:var(--m-ic)] px-2 py-[2px] font-geist text-[0.5625rem] font-bold text-m-muted">
<RouteOff size={9} strokeWidth={2.4} className="flex-none" />
<span className="truncate">{t('journey.entry.offRoute')}</span>
</span>
)}
<span className="ml-auto flex gap-1">
{mood && (
<span
className="flex h-[18px] w-[18px] items-center justify-center rounded-full"
style={{ background: `${mood.color}22`, color: mood.color }}
>
<mood.icon size={11} strokeWidth={2.2} />
</span>
)}
{weather && (
<span className="flex h-[18px] w-[18px] items-center justify-center rounded-full bg-[color:var(--m-ic)] text-m-muted">
<weather.icon size={11} strokeWidth={2.2} />
</span>
)}
</span>
</span>
<span className="mt-1 block truncate text-[0.875rem] font-extrabold">{title}</span>
{/* No display utility on the preview: Tailwind emits it after line-clamp-2 and would kill the clamp. */}
{storyPreview && (
<span className="mt-[2px] font-geist text-[0.65625rem] leading-[1.4] text-m-muted line-clamp-2">
{storyPreview}
</span>
)}
{!firstPhoto && location && (
<span className="mt-[6px] inline-flex max-w-full items-center gap-1 rounded-full bg-[color:var(--m-ic)] px-2 py-[2px] font-geist text-[0.5625rem] font-bold text-m-muted">
<MapPin size={9} strokeWidth={2.4} className="flex-none" />
<span className="truncate">{location}</span>
</span>
)}
</span>
</button>
)
}
@@ -1,6 +1,6 @@
import { useEffect, useMemo, useRef, useState } from 'react'
import { localIsoDate } from '../../../utils/localDate'
import { Camera, Plus, Image, Images, X, MapPin, Locate, Trash2, CheckCircle2, MinusCircle } from 'lucide-react'
import { Camera, Plus, Image, Images, X, MapPin, Locate, Trash2, CheckCircle2, MinusCircle, ChevronUp, ChevronDown, EyeOff } from 'lucide-react'
import MSheet from '../../components/MSheet'
import MIconBtn from '../../components/MIconBtn'
import MToggle from '../../components/MToggle'
@@ -39,6 +39,22 @@ interface MJourneyEntrySheetProps {
readOnly?: boolean
userId?: number
trips?: JourneyTrip[]
/** The optional fields this journey still keeps (discussion #2299). Values are never cleared. */
showVerdict?: boolean
showMood?: boolean
showWeather?: boolean
/**
* Move this entry within its day.
*
* The desktop feed has arrows beside every card; the phone had no way to
* reorder at all, so a day's stops stayed in whatever order they were written
* (discussion #2299). Absent when the entry is alone on its day, is a
* suggestion, or the reader cannot edit.
*/
onMoveEarlier?: () => void
onMoveLater?: () => void
/** Wave a trip-derived suggestion away. Only ever passed for a skeleton. */
onDismiss?: () => void
onClose: () => void
onSave: (data: Record<string, unknown>, existingEntryId?: number) => Promise<number>
onUploadPhotos: (entryId: number, files: File[], cbs?: { onProgress?: (p: UploadProgress) => void }) => Promise<ResilientResult<JourneyPhoto>>
@@ -54,10 +70,13 @@ interface MJourneyEntrySheetProps {
*/
export default function MJourneyEntrySheet({
entry, galleryPhotos, quickCapture = false, readOnly = false, userId = 0, trips = [],
showVerdict = true, showMood = true, showWeather = true, onMoveEarlier, onMoveLater, onDismiss,
onClose, onSave, onUploadPhotos, onAddProviderPhotos, onDelete, onDone,
}: MJourneyEntrySheetProps) {
const { t, language } = useTranslation()
const toast = useToast()
// Which verdict row to hand the caret after the next render — see addVerdictRow.
const verdictFocusRef = useRef<string | null>(null)
const [title, setTitle] = useState(entry.title || '')
const [story, setStory] = useState(entry.story || '')
@@ -199,6 +218,29 @@ export default function MJourneyEntrySheet({
pendingLinkIds.length > 0 ||
pendingProviderGroups.length > 0
/**
* Enter opens the next pro or con, directly below the one you are in.
*
* Same behaviour and same reason as the desktop editor: a list of short things
* should not need a button press between every item (discussion #2299). On a
* phone keyboard the return key is right there, which is more of a gain than
* it is with a mouse.
*/
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}`
}
const verdictRowRef = (key: string) => (el: HTMLInputElement | null) => {
if (el && verdictFocusRef.current === key) {
verdictFocusRef.current = null
el.focus()
}
}
const handleClose = () => {
if (!captureOnly && !readOnly && isDirty && !window.confirm(t('journey.editor.discardChangesConfirm'))) return
onClose()
@@ -353,13 +395,28 @@ export default function MJourneyEntrySheet({
<span className="flex-1 text-[1.0625rem] font-bold">
{entry.id === 0 ? t('journey.detail.newEntry') : t('journey.detail.editEntry')}
</span>
{(onMoveEarlier || onMoveLater) && (
<span className="mr-1 flex items-center gap-1">
<MIconBtn variant="neutral" size={34} onClick={() => onMoveEarlier?.()} disabled={!onMoveEarlier} ariaLabel={t('dayplan.moveUp')}>
<ChevronUp size={15} strokeWidth={2.4} />
</MIconBtn>
<MIconBtn variant="neutral" size={34} onClick={() => onMoveLater?.()} disabled={!onMoveLater} ariaLabel={t('dayplan.moveDown')}>
<ChevronDown size={15} strokeWidth={2.4} />
</MIconBtn>
</span>
)}
<MIconBtn variant="neutral" size={34} onClick={handleClose} ariaLabel={t('common.cancel')}>
<X size={15} strokeWidth={2.2} />
</MIconBtn>
</div>
<div className="min-h-0 flex-1 overflow-y-auto px-[18px] py-3">
{!captureOnly && (readOnly ? (
{/* Quick capture had no title field at all: it asked for a note and nothing
else, so every entry caught on the move arrived nameless and the day's
list read as a column of identical placeholders (discussion #2299). The
name is the one thing that makes an entry findable later, and it is one
line to type, so it comes first here too. */}
{readOnly ? (
<div className="pb-[10px] pt-1 text-[1.25rem] font-extrabold">{title || t('journey.editor.titlePlaceholder')}</div>
) : (
<input
@@ -368,7 +425,7 @@ export default function MJourneyEntrySheet({
placeholder={t('journey.editor.titlePlaceholder')}
className="w-full bg-transparent pb-[10px] pt-1 text-[1.25rem] font-extrabold text-m-ink outline-none placeholder:text-m-faint"
/>
))}
)}
{!readOnly && (
<>
@@ -646,8 +703,8 @@ export default function MJourneyEntrySheet({
/>
)}
{/* Pros & Cons */}
{(!readOnly || pros.length > 0 || cons.length > 0) && (
{/* Pros & Cons — gone when the journey has put the verdict away (#2299) */}
{showVerdict && (!readOnly || pros.length > 0 || cons.length > 0) && (
<div className="mt-3 rounded-2xl border border-[color:var(--m-rowbr)] bg-[color:var(--m-ic)] p-[13px]">
<div className={`${eyebrow} mb-2`}>{t('journey.editor.prosCons')}</div>
<div className="flex gap-[10px]">
@@ -660,9 +717,11 @@ export default function MJourneyEntrySheet({
<div key={i} className="mb-[6px] flex items-center gap-[6px] rounded-[10px] border border-[color:var(--m-rowbr)] bg-m-sheetop px-2 py-[6px]">
<span className="h-[5px] w-[5px] flex-none rounded-full" style={{ background: PRO_COLOR }} />
<input
ref={verdictRowRef(`pros-${i}`)}
value={p}
readOnly={readOnly}
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="min-w-0 flex-1 bg-transparent font-geist text-[0.6875rem] font-semibold text-m-ink outline-none placeholder:text-m-faint"
/>
@@ -676,7 +735,7 @@ export default function MJourneyEntrySheet({
{!readOnly && (
<button
type="button"
onClick={() => setPros([...pros, ''])}
onClick={() => addVerdictRow('pros', pros.length - 1)}
className="block w-full rounded-[10px] border border-dashed py-[9px] text-center font-geist text-[0.6875rem] font-semibold"
style={{ borderColor: 'rgba(47,163,122,.35)', color: PRO_COLOR }}
>
@@ -693,9 +752,11 @@ export default function MJourneyEntrySheet({
<div key={i} className="mb-[6px] flex items-center gap-[6px] rounded-[10px] border border-[color:var(--m-rowbr)] bg-m-sheetop px-2 py-[6px]">
<span className="h-[5px] w-[5px] flex-none rounded-full" style={{ background: CON_COLOR }} />
<input
ref={verdictRowRef(`cons-${i}`)}
value={c}
readOnly={readOnly}
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="min-w-0 flex-1 bg-transparent font-geist text-[0.6875rem] font-semibold text-m-ink outline-none placeholder:text-m-faint"
/>
@@ -709,7 +770,7 @@ export default function MJourneyEntrySheet({
{!readOnly && (
<button
type="button"
onClick={() => setCons([...cons, ''])}
onClick={() => addVerdictRow('cons', cons.length - 1)}
className="block w-full rounded-[10px] border border-dashed py-[9px] text-center font-geist text-[0.6875rem] font-semibold"
style={{ borderColor: 'rgba(214,39,59,.35)', color: CON_COLOR }}
>
@@ -811,7 +872,7 @@ export default function MJourneyEntrySheet({
)}
{/* Mood */}
{!captureOnly && <>
{showMood && !captureOnly && <>
<div className={`${eyebrow} mb-[6px] mt-3`}>{t('journey.editor.mood')}</div>
<div className="flex flex-wrap gap-[6px]">
{MOBILE_MOODS.map(m => {
@@ -836,6 +897,7 @@ export default function MJourneyEntrySheet({
</>}
{/* Weather */}
{showWeather && <>
<div className={`${eyebrow} mb-[6px] mt-3`}>{t('journey.editor.weather')}</div>
<div className="flex flex-wrap gap-[6px]">
{MOBILE_WEATHERS.map(w => {
@@ -858,6 +920,7 @@ export default function MJourneyEntrySheet({
)
})}
</div>
</>}
{/* Tags */}
{!captureOnly && (!readOnly || tags.length > 0) && (
@@ -905,6 +968,18 @@ export default function MJourneyEntrySheet({
{t('common.delete')}
</button>
)}
{/* A suggestion is not deleted, it is put down: the row survives so the trip
sync does not offer the same place again (discussion #2299). */}
{!readOnly && onDismiss && (
<button
type="button"
onClick={onDismiss}
className="flex items-center gap-[5px] text-[0.75rem] font-bold text-m-muted"
>
<EyeOff size={13} strokeWidth={2} />
{t('journey.suggestions.dismiss')}
</button>
)}
{!readOnly && captureOnly && (
<button
type="button"
@@ -2,7 +2,7 @@ import { useEffect, useRef, useState } from 'react'
import { useNavigate } from 'react-router'
import {
X, Image, Plus, Trash2, UserPlus, Link as LinkIcon,
List, Grid3x3, MapPin, Route, Archive, ArchiveRestore,
List, Grid3x3, MapPin, Route, Archive, ArchiveRestore, Undo2,
} from 'lucide-react'
import MSheet from '../../components/MSheet'
import MIconBtn from '../../components/MIconBtn'
@@ -38,6 +38,8 @@ interface MJourneySettingsSheetProps {
onSaved: () => void
onOpenInvite: () => void
onRefresh: () => void
/** Bring back every suggestion waved away card by card. Absent for a viewer. */
onRestoreSuggestions?: () => Promise<void> | void
}
/**
@@ -45,7 +47,7 @@ interface MJourneySettingsSheetProps {
* with per-section toggles (timeline / gallery / map), archive and delete.
*/
export default function MJourneySettingsSheet({
journey, onClose, onSaved, onOpenInvite, onRefresh,
journey, onClose, onSaved, onOpenInvite, onRefresh, onRestoreSuggestions,
}: MJourneySettingsSheetProps) {
const { t } = useTranslation()
const toast = useToast()
@@ -115,6 +117,26 @@ export default function MJourneySettingsSheet({
}
}
/**
* Turn one of the optional entry fields off for this journey (discussion #2299).
*
* Written on the spot and through onRefresh, for the same two reasons the tracks
* switch above is. Nothing stored is erased: the form stops asking, the values
* stay, and switching back on brings them into view.
*/
const [savingField, setSavingField] = useState<string | null>(null)
const handleFieldToggle = async (field: 'show_verdict' | 'show_mood' | 'show_weather', next: boolean) => {
setSavingField(field)
try {
await updateJourney(journey.id, { [field]: next })
onRefresh()
} catch {
toast.error(t('journey.settings.saveFailed'))
} finally {
setSavingField(null)
}
}
const openAddTrip = async () => {
setAddTripOpen(v => !v)
if (availableTrips.length === 0) {
@@ -269,6 +291,42 @@ export default function MJourneySettingsSheet({
/>
</div>
{/* The three fields a journey may put away (discussion #2299) */}
<div className={`${eyebrow} mb-[6px] mt-[14px]`}>{t('journey.settings.entryFields')}</div>
<div className="mb-[6px] font-geist text-[0.625rem] leading-[1.45] text-m-muted">{t('journey.settings.entryFieldsHint')}</div>
{([
['show_verdict', t('journey.settings.showVerdict')],
['show_mood', t('journey.settings.showMood')],
['show_weather', t('journey.settings.showWeather')],
] as const).map(([field, label]) => (
<div key={field} className="mb-[6px] flex items-center gap-[11px] rounded-[14px] bg-[color:var(--m-ic)] px-3 py-[10px]">
<div className="min-w-0 flex-1 text-[0.8125rem] font-bold">{label}</div>
<MToggle
checked={journey[field] !== 0}
onChange={next => handleFieldToggle(field, next)}
disabled={savingField !== null}
ariaLabel={label}
/>
</div>
))}
{/* Only when there is something to bring back */}
{onRestoreSuggestions && (journey.dismissed_count ?? 0) > 0 && (
<button
type="button"
onClick={() => { void onRestoreSuggestions() }}
className="mb-[6px] flex w-full items-center gap-[11px] rounded-[14px] bg-[color:var(--m-ic)] px-3 py-[10px] text-left"
>
<Undo2 size={16} strokeWidth={2} className="flex-none text-m-muted" />
<div className="min-w-0 flex-1">
<div className="text-[0.8125rem] font-bold">{t('journey.suggestions.restore')}</div>
<div className="font-geist text-[0.625rem] text-m-muted">
{t('journey.suggestions.restoreCount', { count: String(journey.dismissed_count) })}
</div>
</div>
</button>
)}
{/* Synced trips */}
<div className={`${eyebrow} mb-[6px] mt-[14px]`}>{t('journey.detail.syncedTrips')}</div>
{journey.trips.map(trip => (
+25 -19
View File
@@ -327,18 +327,26 @@ describe('JourneyDetailPage', () => {
// ── FE-PAGE-JOURNEYDETAIL-008 ──────────────────────────────────────────
describe('FE-PAGE-JOURNEYDETAIL-008: Shows VerdictSection when entry has pros/cons', () => {
it('renders the Pros & Cons section header', async () => {
// The verdict moved behind the story's fold in discussion #2299: it belongs to
// one entry, and a feed of open pro/con tables reads as a spreadsheet.
async function openTheFold() {
const user = userEvent.setup();
await renderAndWait();
await user.click(screen.getAllByText('Show more')[0]);
}
it('renders the Pros & Cons section header once the entry is opened', async () => {
await openTheFold();
expect(screen.getByText('Pros & Cons')).toBeInTheDocument();
});
it('renders pro items', async () => {
await renderAndWait();
await openTheFold();
expect(screen.getByText('Great food')).toBeInTheDocument();
});
it('renders con items', async () => {
await renderAndWait();
await openTheFold();
expect(screen.getByText('Crowded')).toBeInTheDocument();
});
});
@@ -843,14 +851,11 @@ describe('JourneyDetailPage', () => {
// ── Helper: open entry editor ───────────────────────────────────────────
async function openEntryEditor(user: ReturnType<typeof userEvent.setup>) {
// The + button is inside the view controls row, after the tab group
// Structure: div.justify-between > [div(tabs), button(+)]
// The tab group div contains the Timeline/Gallery/Map buttons
const tabGroup = screen.getByRole('button', { name: /timeline/i }).parentElement!;
// The + button is the next sibling of the tab group
const addBtn = tabGroup.nextElementSibling as HTMLElement;
expect(addBtn).toBeTruthy();
expect(addBtn.tagName).toBe('BUTTON');
// By name rather than by position: the controls row also carries the search
// box now, so "next sibling of the tab group" stopped being the + button
// (discussion #2299). The day headers' own plus buttons are labelled "Add an
// entry on this day" and do not answer to this.
const addBtn = screen.getByRole('button', { name: /^add entry$/i });
await user.click(addBtn);
@@ -922,10 +927,11 @@ describe('JourneyDetailPage', () => {
await renderAndWait();
await openEntryEditor(user);
// The editor shows "Pros & Cons" label (displayed uppercase via CSS class)
// The timeline view already shows "Pros & Cons" from the first entry, so use getAllByText
// The editor shows "Pros & Cons" label (displayed uppercase via CSS class).
// Exactly one: the timeline's copy sits behind the entry's fold now, and this
// test never opens it (discussion #2299).
const prosConsLabels = screen.getAllByText('Pros & Cons');
expect(prosConsLabels.length).toBeGreaterThanOrEqual(2);
expect(prosConsLabels).toHaveLength(1);
// It also shows sub-labels Pros and Cons
expect(screen.getByText('Pros')).toBeInTheDocument();
expect(screen.getByText('Cons')).toBeInTheDocument();
@@ -2197,13 +2203,13 @@ describe('JourneyDetailPage', () => {
const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime });
await openGalleryWithProvider(user);
// Footer shows "0 selected" initially
// The count lives on the Add button alone now: the separate "N selected" pill
// beside it said the same thing twice and cost the grid a row (#2299).
await waitFor(() => {
expect(screen.getByText('selected')).toBeInTheDocument();
expect(screen.queryByText('selected')).toBeNull();
});
// Add button shows "Add" (disabled when 0 selected)
const addBtn = screen.getByRole('button', { name: /^Add/ });
const addBtn = screen.getByRole('button', { name: /^Add$/ });
expect(addBtn).toBeDisabled();
});
});
@@ -2754,7 +2760,7 @@ describe('JourneyDetailPage', () => {
// After selection, the Add button should show count
await waitFor(() => {
const addBtn = screen.getByRole('button', { name: /^Add/ });
const addBtn = screen.getByRole('button', { name: /^Add \(\d+\)$/ });
expect(addBtn.textContent).toContain('1');
});
});
+66 -8
View File
@@ -10,7 +10,7 @@ import EmptyState from '../components/shared/EmptyState'
import { Outlet } from 'react-router'
import {
ArrowLeft, MoreHorizontal, List, Grid, MapPin,
Plus, ChevronUp, ChevronDown, Eye, EyeOff, BookOpen, Image,
Plus, ChevronUp, ChevronDown, Eye, EyeOff, BookOpen, Image, Search, X,
} from 'lucide-react'
import MobileMapTimeline from '../components/Journey/MobileMapTimeline'
import DawarichSuggestionsPanel from '../components/Dawarich/DawarichSuggestionsPanel'
@@ -18,7 +18,7 @@ import MobileEntryView from '../components/Journey/MobileEntryView'
import { useJourneyStore } from '../store/journeyStore'
import { computeJourneyLifecycle } from '../utils/journeyLifecycle'
import { useJourneyDetail } from './journeyDetail/useJourneyDetail'
import { createDraftJourneyEntry, pickGradient, groupByDate, formatDate, photoUrl } from './journeyDetail/JourneyDetailPage.helpers'
import { createDraftJourneyEntry, pickGradient, groupByDate, formatDate, photoUrl, matchJourneyEntries } from './journeyDetail/JourneyDetailPage.helpers'
import { EntryCard, SkeletonCard, CheckinCard } from '../components/Journey/JourneyDetailPageEntryCard'
import { GalleryView } from '../components/Journey/JourneyDetailPageGalleryView'
import { EntryEditor } from '../components/Journey/JourneyDetailPageEntryEditor'
@@ -45,6 +45,7 @@ function JourneyDetailPageDesktop() {
showInvite, setShowInvite, showAddTrip, setShowAddTrip,
unlinkTrip, setUnlinkTrip, showSettings, setShowSettings,
hideSkeletons, setHideSkeletons,
query, setQuery, dismissSuggestion, restoreSuggestions, openAtEntryId,
mapRef, fullMapRef, galleryUploadRef, galleryProviders, setGalleryProviders, galleryBrowseRef,
activeLocationId, handleMarkerClick, handleLocationClick,
mapEntries, sidebarMapItems, tripDates, isMobile, tracks,
@@ -63,7 +64,10 @@ function JourneyDetailPageDesktop() {
)
}
const timelineEntries = current.entries.filter(e => (!hideSkeletons || e.type !== 'skeleton'))
const timelineEntries = matchJourneyEntries(
current.entries.filter(e => (!hideSkeletons || e.type !== 'skeleton')),
query,
)
const dayGroups = groupByDate(timelineEntries)
const sortedDates = [...dayGroups.keys()].sort((a, b) => (a < b ? -1 : a > b ? 1 : 0))
@@ -133,6 +137,9 @@ function JourneyDetailPageDesktop() {
onAddEntry={canEditEntries ? () => {
setEditingEntry(createDraftJourneyEntry(current.id))
} : undefined}
showMood={current.show_mood !== 0}
showWeather={current.show_weather !== 0}
initialEntryId={openAtEntryId}
/>
)}
@@ -362,6 +369,30 @@ function JourneyDetailPageDesktop() {
</button>
))}
</div>
{/* Search. A journey kept over a season is a very long scroll, and the
wheel was the only way through it (discussion #2299). Grows on focus
rather than standing open at full width, so it costs the row nothing
until it is used. */}
{view === 'timeline' && !isMobile && (
<div
className="inline-flex items-center gap-1.5 h-9 rounded-full px-3 transition-[width] duration-200"
style={{ background: 'var(--vg-surf2)', border: '1px solid var(--vg-line)', width: query ? 240 : 150 }}
>
<Search size={14} style={{ color: 'var(--vg-ink3)' }} className="flex-shrink-0" />
<input
value={query}
onChange={e => setQuery(e.target.value)}
placeholder={t('journey.detail.searchPlaceholder')}
className="min-w-0 flex-1 bg-transparent text-[12.5px] outline-none"
style={{ color: 'var(--vg-ink)' }}
/>
{query && (
<button type="button" onClick={() => setQuery('')} aria-label={t('common.clear')} className="flex-shrink-0">
<X size={13} style={{ color: 'var(--vg-ink3)' }} />
</button>
)}
</div>
)}
{canEditEntries && view === 'timeline' && (
<button type="button"
onClick={() => {
@@ -394,7 +425,10 @@ function JourneyDetailPageDesktop() {
)}
{sortedDates.length === 0 && (
<EmptyState scene="journey" title={t('journey.detail.noEntries')} />
<EmptyState
scene="journey"
title={query ? t('journey.detail.searchEmpty', { query }) : t('journey.detail.noEntries')}
/>
)}
{sortedDates.map((date, dayIdx) => {
@@ -411,7 +445,23 @@ function JourneyDetailPageDesktop() {
</div>
<h3 className="text-[14px] font-semibold capitalize" style={{ color: 'var(--vg-ink)' }}>{new Date(date + 'T00:00:00').toLocaleDateString(undefined, { weekday: 'long', day: 'numeric', month: 'long' })}</h3>
</div>
<span className="inline-flex items-center gap-1.5 px-2.5 py-1 rounded-full text-[10px] font-bold uppercase tracking-[0.07em]" style={{ background: 'var(--vg-surf2)', color: 'var(--vg-ink3)' }}><MapPin size={12} /> {entries.length} {t('journey.synced.places')}</span>
<div className="flex items-center gap-2">
<span className="inline-flex items-center gap-1.5 px-2.5 py-1 rounded-full text-[10px] font-bold uppercase tracking-[0.07em]" style={{ background: 'var(--vg-surf2)', color: 'var(--vg-ink3)' }}><MapPin size={12} /> {entries.length} {t('journey.synced.places')}</span>
{/* The only Add button used to be at the very top and always
started on today, so putting something into an earlier day
meant correcting the date by hand (discussion #2299). */}
{canEditEntries && (
<button type="button"
onClick={() => setEditingEntry(createDraftJourneyEntry(current.id, new Date(), date))}
aria-label={t('journey.detail.addOnThisDay')}
title={t('journey.detail.addOnThisDay')}
className="w-7 h-7 rounded-full flex items-center justify-center transition-colors hover:bg-surface-hover"
style={{ background: 'var(--vg-surf2)', color: 'var(--vg-ink)' }}
>
<Plus size={14} strokeWidth={2.6} />
</button>
)}
</div>
</div>
{entries.map((entry, idx) => {
@@ -438,7 +488,7 @@ function JourneyDetailPageDesktop() {
type="button"
onClick={() => move(-1)}
disabled={idx === 0}
aria-label="Move up"
aria-label={t('dayplan.moveUp')}
className="w-7 h-7 rounded-full bg-white dark:bg-zinc-800 border border-zinc-200 dark:border-zinc-700 shadow-sm text-zinc-600 dark:text-zinc-300 flex items-center justify-center hover:bg-zinc-50 dark:hover:bg-zinc-700 disabled:opacity-40 disabled:cursor-not-allowed transition-colors"
>
<ChevronUp size={14} />
@@ -447,7 +497,7 @@ function JourneyDetailPageDesktop() {
type="button"
onClick={() => move(1)}
disabled={idx === entries.length - 1}
aria-label="Move down"
aria-label={t('dayplan.moveDown')}
className="w-7 h-7 rounded-full bg-white dark:bg-zinc-800 border border-zinc-200 dark:border-zinc-700 shadow-sm text-zinc-600 dark:text-zinc-300 flex items-center justify-center hover:bg-zinc-50 dark:hover:bg-zinc-700 disabled:opacity-40 disabled:cursor-not-allowed transition-colors"
>
<ChevronDown size={14} />
@@ -456,7 +506,11 @@ function JourneyDetailPageDesktop() {
)}
<div className={canReorder ? 'flex-1 min-w-0' : ''}>
{entry.type === 'skeleton' ? (
<SkeletonCard entry={entry} onClick={canEditEntries ? () => setEditingEntry(entry) : undefined} />
<SkeletonCard
entry={entry}
onClick={canEditEntries ? () => setEditingEntry(entry) : undefined}
onDismiss={canEditEntries ? () => dismissSuggestion(entry) : undefined}
/>
) : entry.type === 'checkin' ? (
<CheckinCard entry={entry} onClick={canEditEntries ? () => setEditingEntry(entry) : undefined} />
) : (
@@ -585,6 +639,9 @@ function JourneyDetailPageDesktop() {
onUploadPhotos={async (entryId, files, cbs) => {
return await uploadPhotos(entryId, files, cbs)
}}
showVerdict={current.show_verdict !== 0}
showMood={current.show_mood !== 0}
showWeather={current.show_weather !== 0}
onAddProviderPhotos={async (entryId, group) => {
await journeyApi.addProviderPhotos(entryId, group.provider, group.assetIds, undefined, group.passphrase, group.mediaTypes)
}}
@@ -603,6 +660,7 @@ function JourneyDetailPageDesktop() {
onSaved={() => { setShowSettings(false); loadJourney(Number(id)) }}
onOpenInvite={() => { setShowInvite(true) }}
onRefresh={() => loadJourney(Number(id))}
onRestoreSuggestions={canEditEntries ? restoreSuggestions : undefined}
/>
)}
@@ -98,6 +98,8 @@ function buildHook(over: Record<string, unknown> = {}): Record<string, unknown>
unlinkTrip: null, setUnlinkTrip: vi.fn(),
showSettings: false, setShowSettings: vi.fn(),
hideSkeletons: false, setHideSkeletons: vi.fn(),
query: '', setQuery: vi.fn(), dismissSuggestion: vi.fn(async () => {}),
restoreSuggestions: vi.fn(async () => {}), openAtEntryId: null,
mapRef: { current: null }, fullMapRef: { current: null }, galleryUploadRef: { current: null },
galleryProviders: [], setGalleryProviders: vi.fn(), galleryBrowseRef: { current: null },
activeLocationId: null, handleMarkerClick: vi.fn(), handleLocationClick: vi.fn(),
@@ -231,21 +233,21 @@ describe('JourneyDetailPage wiring', () => {
it('FE-JRN-DETWIRE-011: the reorder arrows move an entry within its day', async () => {
const { hook } = setup();
const down = screen.getAllByRole('button', { name: 'Move down' })[0];
const upFirst = screen.getAllByRole('button', { name: 'Move up' })[0];
const down = screen.getAllByRole('button', { name: 'dayplan.moveDown' })[0];
const upFirst = screen.getAllByRole('button', { name: 'dayplan.moveUp' })[0];
expect(upFirst).toBeDisabled();
fireEvent.click(down);
await waitFor(() => expect(hook.reorderEntries).toHaveBeenCalledWith(7, [2, 1]));
fireEvent.click(screen.getAllByRole('button', { name: 'Move up' })[1]);
fireEvent.click(screen.getAllByRole('button', { name: 'dayplan.moveUp' })[1]);
expect(hook.reorderEntries).toHaveBeenLastCalledWith(7, [2, 1]);
});
it('FE-JRN-DETWIRE-012: a failing reorder is reported to the user', async () => {
const reorderEntries = vi.fn(async () => { throw new Error('conflict'); });
setup({ reorderEntries });
fireEvent.click(screen.getAllByRole('button', { name: 'Move down' })[0]);
fireEvent.click(screen.getAllByRole('button', { name: 'dayplan.moveDown' })[0]);
await waitFor(() => expect(toast.error).toHaveBeenCalledWith('common.errorOccurred'));
});
+2
View File
@@ -828,6 +828,8 @@ export default function JourneyPublicPage() {
publicPhotoUrl={(photoId) => `/api/public/journey/${token}/photos/${photoId}/original`}
carouselBottom="calc(env(safe-area-inset-bottom, 16px) + 8px)"
cartoApiKey={cartoApiKey}
showMood={journey.show_mood !== 0}
showWeather={journey.show_weather !== 0}
/>
)}
@@ -1,5 +1,11 @@
import { describe, expect, it } from 'vitest';
import { distanceBetweenGeoPoints, groupPhotosByDate, sortProviderPhotos } from './JourneyDetailPage.helpers';
import {
createDraftJourneyEntry,
distanceBetweenGeoPoints,
groupPhotosByDate,
matchJourneyEntries,
sortProviderPhotos,
} from './JourneyDetailPage.helpers';
describe('Journey provider photo ranking', () => {
it('places GPS photos nearest the selected Journey location and keeps photos without GPS', () => {
@@ -61,3 +67,49 @@ describe('Journey provider photo grouping', () => {
expect(groupPhotosByDate(photos).map((g) => g.date)).toEqual(['2026-03-16', '__unknown__']);
});
});
describe('createDraftJourneyEntry', () => {
it('starts on today when nobody says otherwise', () => {
const draft = createDraftJourneyEntry(7, new Date('2026-03-15T14:30:00'));
expect(draft.entry_date).toBe('2026-03-15');
expect(draft.entry_time).toBe('14:30');
});
it('takes the day it was started from, so a plus in an earlier day header lands there', () => {
// The whole point of the day-header plus: it used to open on today no matter
// where in the journal you pressed it (discussion #2299).
const draft = createDraftJourneyEntry(7, new Date('2026-03-15T14:30:00'), '2026-03-09');
expect(draft.entry_date).toBe('2026-03-09');
expect(draft.entry_time).toBe('14:30');
});
});
describe('matchJourneyEntries', () => {
const entries = [
{ id: 1, title: 'Mercado da Ribeira', story: 'Ate too much', location_name: 'Lisbon', tags: ['food'] },
{ id: 2, title: 'Tram 28', story: null, location_name: 'Lisbon', tags: [] },
{ id: 3, title: null, story: 'Long drive north', location_name: 'Café Central, Porto', tags: ['drive'] },
];
it('an empty query is not a filter', () => {
expect(matchJourneyEntries(entries, '')).toBe(entries);
expect(matchJourneyEntries(entries, ' ')).toBe(entries);
});
it('matches the words a reader would remember: title, story, place and tags', () => {
expect(matchJourneyEntries(entries, 'mercado').map((e) => e.id)).toEqual([1]);
expect(matchJourneyEntries(entries, 'drive').map((e) => e.id)).toEqual([3]);
expect(matchJourneyEntries(entries, 'lisbon').map((e) => e.id)).toEqual([1, 2]);
expect(matchJourneyEntries(entries, 'food').map((e) => e.id)).toEqual([1]);
});
it('is blind to case and to accents, so a query typed plainly still finds the place', () => {
expect(matchJourneyEntries(entries, 'cafe').map((e) => e.id)).toEqual([3]);
expect(matchJourneyEntries(entries, 'CAFÉ').map((e) => e.id)).toEqual([3]);
});
it('answers with nothing rather than everything when nothing matches', () => {
expect(matchJourneyEntries(entries, 'reykjavik')).toEqual([]);
});
});
@@ -29,8 +29,18 @@ export function groupByDate(entries: JourneyEntry[]): Map<string, JourneyEntry[]
return groups;
}
export function createDraftJourneyEntry(journeyId: number, now = new Date()): JourneyEntry {
const entryDate = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}-${String(now.getDate()).padStart(2, '0')}`;
/**
* A blank entry for the editor to open on.
*
* `onDate` is what the plus button in a day header hands in. Without it every new
* entry landed on today no matter where in the journal you started it, so
* writing up the third day of a trip you got back from last week meant correcting
* the date by hand every time (discussion #2299). The clock still comes from now:
* a day has one date but no one obvious hour, and the picker is right there.
*/
export function createDraftJourneyEntry(journeyId: number, now = new Date(), onDate?: string): JourneyEntry {
const entryDate = onDate
|| `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}-${String(now.getDate()).padStart(2, '0')}`;
const entryTime = `${String(now.getHours()).padStart(2, '0')}:${String(now.getMinutes()).padStart(2, '0')}`;
return {
id: 0,
@@ -167,3 +177,33 @@ export function sortProviderPhotos<T extends ProviderPhotoAsset>(photos: T[], lo
})
.map((item) => item.photo);
}
/**
* The entries a search box's text still lets through.
*
* A journey kept over months is a long scroll with no way in but the wheel: the
* only handle on "where was that meal in Lisbon" was remembering roughly which
* week it was (discussion #2299). Matching runs over the words the reader would
* actually remember — the title, the story, the place and the tags — and is
* accent- and case-blind, so `cafe` finds `Café`.
*
* An empty query is not a filter: it returns the list untouched, same reference.
*/
export function matchJourneyEntries<T extends {
title?: string | null;
story?: string | null;
location_name?: string | null;
tags?: string[];
}>(entries: T[], query: string): T[] {
const needle = query ? foldForSearch(query) : '';
if (!needle) return entries;
return entries.filter((entry) => {
const haystack = [entry.title, entry.story, entry.location_name, ...(entry.tags ?? [])];
return haystack.some((part) => part && foldForSearch(part).includes(needle));
});
}
/** Lowercase and strip diacritics, so a query typed without accents still matches. */
function foldForSearch(value: string): string {
return value.trim().toLowerCase().normalize('NFD').replace(/\p{Diacritic}/gu, '');
}
@@ -71,6 +71,7 @@ export function useJourneyDetail() {
const [unlinkTrip, setUnlinkTrip] = useState<{ trip_id: number; title: string } | null>(null)
const [showSettings, setShowSettings] = useState(false)
const [hideSkeletons, setHideSkeletons] = useState(false)
const [query, setQuery] = useState('')
useEffect(() => {
if (id) loadJourney(Number(id)).catch(() => {})
@@ -269,6 +270,34 @@ export function useJourneyDetail() {
el.scrollTo({ top: edge === 'top' ? 0 : el.scrollHeight, behavior: 'smooth' })
}, [])
/**
* Wave one trip-derived suggestion away.
*
* Optimistic in effect rather than in code: the server drops the row from every
* read, and the store's own update already removes an entry the response no
* longer describes — so a reload is all the confirmation the list needs.
*/
const dismissSuggestion = useCallback(async (entry: JourneyEntry) => {
try {
await updateEntry(entry.id, { dismissed: true })
if (current) await loadJourney(current.id)
toast.success(t('journey.suggestions.dismissed'))
} catch {
toast.error(t('common.errorOccurred'))
}
}, [updateEntry, loadJourney, current, toast, t])
const restoreSuggestions = useCallback(async () => {
if (!current) return
try {
const { restored } = await journeyApi.restoreSuggestions(current.id)
await loadJourney(current.id)
toast.success(t('journey.suggestions.restored', { count: String(restored) }))
} catch {
toast.error(t('common.errorOccurred'))
}
}, [current, loadJourney, toast, t])
const handleMarkerClick = useCallback((entryId: string) => {
const el = document.querySelector(`[data-entry-id="${entryId}"]`)
if (!el) return
@@ -351,6 +380,53 @@ export function useJourneyDetail() {
})
}, [mapEntries, current?.entries])
/**
* Where a journey should open: on today, when today is part of it.
*
* Every load used to start at the first entry, so keeping a journal on a trip
* meant scrolling past everything already written before you could add to it
* (discussion #2299). "Today" means the last day at or before now — a rest day
* with nothing written still lands you at yesterday rather than at the start.
*
* Only while the journey is running, though. A journey that ended last spring
* is something you read, and reading starts at the beginning; opening it at the
* last page would be the worse of the two mistakes.
*/
const openAtEntryId = useMemo(() => {
const entries = current?.entries ?? []
if (entries.length === 0) return null
const dated = [...entries].sort((a, b) => a.entry_date.localeCompare(b.entry_date))
const today = new Date()
const todayKey = `${today.getFullYear()}-${String(today.getMonth() + 1).padStart(2, '0')}-${String(today.getDate()).padStart(2, '0')}`
const first = dated[0]!.entry_date
const last = dated[dated.length - 1]!.entry_date
if (todayKey < first || todayKey > last) return null
const reached = dated.filter(e => e.entry_date <= todayKey)
const target = reached[reached.length - 1] ?? dated[0]!
// The first entry OF that day, not the last one before now: a day is read
// from its beginning.
const dayStart = dated.find(e => e.entry_date === target.entry_date) ?? target
return String(dayStart.id)
}, [current?.entries])
// Desktop only: scroll it into the feed once per journey, after the list is up.
// The scroll-sync primes at 300ms and would otherwise pick a winner from the top
// of the feed and pin the map there.
const openedAtRef = useRef<number | null>(null)
useEffect(() => {
if (isMobile || !current || view !== 'timeline') return
if (openedAtRef.current === current.id) return
if (!openAtEntryId) { openedAtRef.current = current.id; return }
const timer = window.setTimeout(() => {
const el = document.querySelector(`[data-entry-id="${openAtEntryId}"]`)
if (!el) return
openedAtRef.current = current.id
el.scrollIntoView({ block: 'center' })
setActiveEntryId(openAtEntryId)
}, 350)
return () => window.clearTimeout(timer)
}, [current, openAtEntryId, isMobile, view])
const locatedEntryIdsRef = useRef(new Set<string>())
useEffect(() => {
locatedEntryIdsRef.current = new Set(sidebarMapItems.map(m => m.id))
@@ -423,6 +499,8 @@ export function useJourneyDetail() {
showInvite, setShowInvite, showAddTrip, setShowAddTrip,
unlinkTrip, setUnlinkTrip, showSettings, setShowSettings,
hideSkeletons, setHideSkeletons,
query, setQuery, dismissSuggestion, restoreSuggestions,
openAtEntryId,
mapRef, fullMapRef, galleryUploadRef, galleryProviders, setGalleryProviders, galleryBrowseRef,
activeLocationId, handleMarkerClick, handleLocationClick,
mapEntries, sidebarMapItems, tripDates, isMobile, tracks: mapTracks, dawarichTrail,
+16
View File
@@ -21,6 +21,16 @@ export interface Journey {
* boolean would make `=== true` compile and never hold.
*/
show_trip_tracks?: number
/**
* Which of the optional entry fields this journey uses (discussion #2299).
*
* Same wire shape and same reason as `show_trip_tracks` above: INTEGER columns
* read with SELECT *, so 0 or 1 arrives, and `undefined` on a cached journey
* from before the migration — which reads as on, matching the column default.
*/
show_verdict?: number
show_mood?: number
show_weather?: number
created_at: number
updated_at: number
}
@@ -40,6 +50,8 @@ export interface JourneyEntry {
location_name?: string | null
location_lat?: number | null
location_lng?: number | null
/** ISO 3166-1 alpha-2, resolved server-side from the coordinates. Drives the card's flag. */
country_code?: string | null
mood?: string | null
weather?: string | null
tags?: string[]
@@ -49,6 +61,8 @@ export interface JourneyEntry {
// Switched off by hand: the stop stays in the journal but is left out of the
// route, the distance and the countries that Studio prints.
stats_excluded?: boolean
/** A trip-derived suggestion the traveller waved away. Never sent by the server; the read paths drop it. */
dismissed?: boolean
photos: JourneyPhoto[]
created_at: number
updated_at: number
@@ -123,6 +137,8 @@ export interface JourneyDetail extends Journey {
contributors: JourneyContributor[]
stats: { entries: number; photos: number; places: number }
hide_skeletons?: boolean
/** How many suggestions were waved away one at a time, so the settings sheet can offer them back. */
dismissed_count?: number
my_role?: 'owner' | 'editor' | 'viewer'
}
@@ -1,57 +0,0 @@
import React from 'react';
import { describe, it, expect, vi } from 'vitest';
import { render, screen, fireEvent } from '../../../helpers/render';
import MJourneyEntryCard from '../../../../src/mobile/screens/journey/MJourneyEntryCard';
import type { JourneyEntry } from '../../../../src/store/journeyStore';
// FE-MOB-JRN-004 onwards
function makeEntry(overrides: Partial<JourneyEntry> = {}): JourneyEntry {
return {
id: 5,
journey_id: 1,
author_id: 1,
type: 'entry',
title: 'Sunrise Pier',
story: 'A **peaceful** morning at the pier.',
entry_date: '2026-05-04',
location_name: 'Ginza, Japan',
mood: 'amazing',
weather: 'cloudy',
visibility: 'private',
sort_order: 0,
photos: [],
created_at: 0,
updated_at: 0,
...overrides,
} as JourneyEntry;
}
describe('MJourneyEntryCard', () => {
it('FE-MOB-JRN-004: renders number badge, title, stripped story preview and location pill', () => {
render(<MJourneyEntryCard entry={makeEntry()} number={1} onClick={() => {}} />);
expect(screen.getByText('1')).toBeInTheDocument();
expect(screen.getByText('Sunrise Pier')).toBeInTheDocument();
// Markdown is stripped for the 2-line preview.
expect(screen.getByText(/A peaceful morning/)).toBeInTheDocument();
expect(screen.getByText(/Ginza/)).toBeInTheDocument();
});
it('FE-MOB-JRN-005: tapping the card fires onClick', () => {
const onClick = vi.fn();
render(<MJourneyEntryCard entry={makeEntry()} number={2} onClick={onClick} />);
fireEvent.click(screen.getByText('Sunrise Pier'));
expect(onClick).toHaveBeenCalledTimes(1);
});
// #2064: a stop switched off the printed route says so beside its date.
it('FE-MOB-JRN-006: marks an entry left out of the route', () => {
render(<MJourneyEntryCard entry={makeEntry({ stats_excluded: true })} number={1} onClick={() => {}} />);
expect(screen.getByText('Off route')).toBeInTheDocument();
});
it('FE-MOB-JRN-007: carries no such mark on an ordinary entry', () => {
render(<MJourneyEntryCard entry={makeEntry()} number={1} onClick={() => {}} />);
expect(screen.queryByText('Off route')).not.toBeInTheDocument();
});
});
@@ -127,7 +127,10 @@ describe('MJourneyEntrySheet quick capture', () => {
});
setup();
expect(screen.queryByPlaceholderText('Give this moment a name...')).not.toBeInTheDocument();
// Quick capture asks for a name now: without one every entry caught on the
// move arrived nameless and the day read as a column of placeholders
// (discussion #2299). The story field is what Add details still unlocks.
expect(screen.getByPlaceholderText('Give this moment a name...')).toBeInTheDocument();
fireEvent.click(screen.getByRole('button', { name: 'Add details' }));
expect(screen.getByPlaceholderText('Give this moment a name...')).toBeInTheDocument();
expect(screen.getByPlaceholderText('Write your story...')).toBeInTheDocument();