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();
+47
View File
@@ -4821,6 +4821,53 @@ function runMigrations(db: Database.Database): void {
}
if (stays.length > 0) console.log(`[DB] Caught up ${stays.length} booked night(s) missed during the upgrade`);
},
/**
* A suggestion the traveller has waved away.
*
* Skeletons are real rows, and the trip sync decides what to create by asking
* which source places already have one. Deleting a dismissed suggestion would
* therefore bring it straight back on the next sync. So it stays, marked, and
* drops out of every read instead — which also leaves a way back.
*
* Appended LAST: the array is index-addressed against schema_version.
*/
() => {
const cols = db.prepare("SELECT name FROM pragma_table_info('journey_entries')").all() as Array<{ name: string }>;
if (!cols.some((c) => c.name === 'dismissed')) {
db.exec('ALTER TABLE journey_entries ADD COLUMN dismissed INTEGER NOT NULL DEFAULT 0');
}
},
/**
* The country an entry happened in, resolved once from its coordinates.
*
* For the flag on the timeline card. Resolved on write rather than on read
* because the answer never changes and the polygon test should not run on
* every render of every entry.
*/
() => {
const cols = db.prepare("SELECT name FROM pragma_table_info('journey_entries')").all() as Array<{ name: string }>;
if (!cols.some((c) => c.name === 'country_code')) {
db.exec('ALTER TABLE journey_entries ADD COLUMN country_code TEXT');
}
},
/**
* Which of the optional entry fields a journey uses.
*
* Mood, weather and the pros/cons list are the three things that make the
* editor feel like a form. Not everybody journals that way, and a journey
* kept by one person for their family should be able to put them away
* without the fields being taken from everybody else (discussion #2299).
*
* DEFAULT 1: every existing journey keeps all three, which is what it had.
*/
() => {
const cols = db.prepare("SELECT name FROM pragma_table_info('journeys')").all() as Array<{ name: string }>;
for (const col of ['show_verdict', 'show_mood', 'show_weather']) {
if (!cols.some((c) => c.name === col)) {
db.exec(`ALTER TABLE journeys ADD COLUMN ${col} INTEGER NOT NULL DEFAULT 1`);
}
}
},
];
if (currentVersion < migrations.length) {
@@ -230,7 +230,7 @@ export class JourneyDomainService {
if (!journey) return null;
const entries = this.db
.prepare('SELECT * FROM journey_entries WHERE journey_id = ? ORDER BY entry_date ASC, sort_order ASC, id ASC')
.prepare('SELECT * FROM journey_entries WHERE journey_id = ? AND dismissed = 0 ORDER BY entry_date ASC, sort_order ASC, id ASC')
.all(journeyId) as JourneyEntry[];
const photos = this.db
@@ -317,6 +317,13 @@ export class JourneyDomainService {
contributors,
stats: { entries: entryCount, photos: photoCount, places: places.length },
hide_skeletons: !!userPrefs?.hide_skeletons,
// What the eye toggle cannot say: how much was waved away one at a time, and
// so whether a way back is worth any room at all.
dismissed_count: (
this.db
.prepare('SELECT COUNT(*) AS n FROM journey_entries WHERE journey_id = ? AND dismissed = 1')
.get(journeyId) as { n: number }
).n,
my_role: myRole,
};
}
@@ -338,10 +345,20 @@ export class JourneyDomainService {
if (!this.isOwner(journeyId, userId)) return null;
const ALLOWED_STATUSES = ['draft', 'active', 'completed', 'archived'];
const allowed = ['title', 'subtitle', 'cover_gradient', 'cover_image', 'status', 'show_trip_tracks'];
const allowed = [
'title',
'subtitle',
'cover_gradient',
'cover_image',
'status',
'show_trip_tracks',
'show_verdict',
'show_mood',
'show_weather',
];
// Stored as INTEGER, and better-sqlite3 refuses to bind a JS boolean, so the
// one flag on this table is coerced rather than passed through.
const BOOLEAN_FIELDS = new Set(['show_trip_tracks']);
// flags on this table are coerced rather than passed through.
const BOOLEAN_FIELDS = new Set(['show_trip_tracks', 'show_verdict', 'show_mood', 'show_weather']);
const fields: string[] = [];
const values: unknown[] = [];
for (const [key, val] of Object.entries(data)) {
@@ -375,6 +392,23 @@ export class JourneyDomainService {
return { hide_skeletons: !!row.hide_skeletons };
}
/**
* Bring every waved-away suggestion back.
*
* All of them at once rather than one at a time: dismissing is a per-card
* gesture, undoing it is a change of mind about the plan, and a list of
* things you have already said you did not want is not a screen worth
* building. The count comes back so the caller can say what happened.
*/
restoreDismissedSuggestions(journeyId: number, userId: number): { restored: number } | null {
if (!this.canEdit(journeyId, userId)) return null;
const res = this.db
.prepare('UPDATE journey_entries SET dismissed = 0 WHERE journey_id = ? AND dismissed = 1')
.run(journeyId);
if (res.changes > 0) this.broadcastJourneyEvent(journeyId, 'journey:entry:updated', { restored: res.changes });
return { restored: res.changes };
}
deleteJourney(journeyId: number, userId: number): boolean {
if (!this.isOwner(journeyId, userId)) return false;
this.db.prepare('DELETE FROM journeys WHERE id = ?').run(journeyId);
@@ -619,6 +653,22 @@ export class JourneyDomainService {
}
// Shared skeleton INSERT, reused by syncTripPlaces / onPlaceCreated / reconcileTripSkeletons.
/**
* The country a pair of coordinates falls in, or null when there is no pair.
*
* Bundled polygons, no network: the timeline card wants a flag, and a flag is
* not worth a geocoding request per entry. Wrapped because the boundary data is
* an optional asset — an install without it should lose the flag, not the save.
*/
private countryFor(lat: number | null | undefined, lng: number | null | undefined): string | null {
if (typeof lat !== 'number' || typeof lng !== 'number') return null;
try {
return getCountryFromCoords(lat, lng);
} catch {
return null;
}
}
private insertSkeletonEntry(p: {
journeyId: number;
tripId: number;
@@ -635,8 +685,8 @@ export class JourneyDomainService {
}) {
this.db.prepare(
`
INSERT INTO journey_entries (journey_id, source_trip_id, source_place_id, author_id, type, title, entry_date, entry_time, location_name, location_lat, location_lng, sort_order, created_at, updated_at)
VALUES (?, ?, ?, ?, 'skeleton', ?, ?, ?, ?, ?, ?, ?, ?, ?)
INSERT INTO journey_entries (journey_id, source_trip_id, source_place_id, author_id, type, title, entry_date, entry_time, location_name, location_lat, location_lng, country_code, sort_order, created_at, updated_at)
VALUES (?, ?, ?, ?, 'skeleton', ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`,
).run(
p.journeyId,
@@ -649,6 +699,7 @@ export class JourneyDomainService {
p.locationName,
p.lat,
p.lng,
this.countryFor(p.lat, p.lng),
p.sortOrder,
p.now,
p.now,
@@ -903,7 +954,7 @@ export class JourneyDomainService {
SELECT id, title, location_name, location_lat, location_lng, entry_date, source_trip_id,
source_place_id, stats_excluded
FROM journey_entries
WHERE journey_id = ?
WHERE journey_id = ? AND dismissed = 0
ORDER BY entry_date ASC, sort_order ASC, id ASC
`).all(journeyId) as {
id: number; title: string | null; location_name: string | null;
@@ -1120,7 +1171,7 @@ export class JourneyDomainService {
if (!this.canAccessJourney(journeyId, userId)) return null;
const entries = this.db
.prepare('SELECT * FROM journey_entries WHERE journey_id = ? ORDER BY entry_date ASC, sort_order ASC, id ASC')
.prepare('SELECT * FROM journey_entries WHERE journey_id = ? AND dismissed = 0 ORDER BY entry_date ASC, sort_order ASC, id ASC')
.all(journeyId) as JourneyEntry[];
const photos = this.db
@@ -1180,8 +1231,8 @@ export class JourneyDomainService {
const res = this.db
.prepare(
`
INSERT INTO journey_entries (journey_id, author_id, type, title, story, entry_date, entry_time, location_name, location_lat, location_lng, mood, weather, tags, pros_cons, visibility, sort_order, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
INSERT INTO journey_entries (journey_id, author_id, type, title, story, entry_date, entry_time, location_name, location_lat, location_lng, country_code, mood, weather, tags, pros_cons, visibility, sort_order, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`,
)
.run(
@@ -1195,6 +1246,7 @@ export class JourneyDomainService {
data.location_name || null,
data.location_lat ?? null,
data.location_lng ?? null,
this.countryFor(data.location_lat, data.location_lng),
data.mood || null,
data.weather || null,
data.tags?.length ? JSON.stringify(data.tags) : null,
@@ -1233,6 +1285,7 @@ export class JourneyDomainService {
visibility: string;
sort_order: number;
stats_excluded: boolean;
dismissed: boolean;
}>,
sid?: string,
): JourneyEntryWire | null {
@@ -1262,6 +1315,7 @@ export class JourneyDomainService {
'visibility',
'sort_order',
'stats_excluded',
'dismissed',
]);
for (const [key, val] of Object.entries(data)) {
@@ -1273,9 +1327,9 @@ export class JourneyDomainService {
} else if (key === 'pros_cons') {
fields.push('pros_cons = ?');
values.push(val && typeof val === 'object' ? JSON.stringify(val) : val);
} else if (key === 'stats_excluded') {
// INTEGER column, and better-sqlite3 refuses to bind a boolean.
fields.push('stats_excluded = ?');
} else if (key === 'stats_excluded' || key === 'dismissed') {
// INTEGER columns, and better-sqlite3 refuses to bind a boolean.
fields.push(`${key} = ?`);
values.push(val ? 1 : 0);
} else {
fields.push(`${key} = ?`);
@@ -1283,6 +1337,17 @@ export class JourneyDomainService {
}
}
// The pin moved, so the flag has to follow it. Either half of the pair may be
// the one being changed, so the other is read off the stored row — and the
// test is `!== undefined`, not `??`: a null here means "take this entry off the
// map", which has to clear the country rather than fall back to the old one.
if (data.location_lat !== undefined || data.location_lng !== undefined) {
const lat = data.location_lat !== undefined ? data.location_lat : entry.location_lat;
const lng = data.location_lng !== undefined ? data.location_lng : entry.location_lng;
fields.push('country_code = ?');
values.push(this.countryFor(lat, lng));
}
// if adding story to a skeleton, promote to entry
if (entry.type === 'skeleton' && data.story && data.story.trim()) {
fields.push('type = ?');
+5 -3
View File
@@ -26,19 +26,21 @@ import type { JourneyEntry } from '../../types';
* the reason the JSON does, so that a client comparing it with `=== true` is
* comparing against something that can be true on every path.
*/
export interface JourneyEntryWire extends Omit<JourneyEntry, 'tags' | 'pros_cons' | 'stats_excluded'> {
export interface JourneyEntryWire extends Omit<JourneyEntry, 'tags' | 'pros_cons' | 'stats_excluded' | 'dismissed'> {
tags: string[];
pros_cons: { pros: string[]; cons: string[] } | null;
stats_excluded: boolean;
dismissed: boolean;
}
/** Decode a row's JSON columns and its flag. The one place that knows how they are stored. */
/** Decode a row's JSON columns and its flags. The one place that knows how they are stored. */
export function decodeEntryRow(row: JourneyEntry): JourneyEntryWire {
const { tags, pros_cons, stats_excluded, ...rest } = row;
const { tags, pros_cons, stats_excluded, dismissed, ...rest } = row;
return {
...rest,
tags: tags ? JSON.parse(tags) : [],
pros_cons: pros_cons ? JSON.parse(pros_cons) : null,
stats_excluded: !!stats_excluded,
dismissed: !!dismissed,
};
}
@@ -164,7 +164,7 @@ export class JourneyShareService {
// Entries with photos
const entries = this.db.prepare(`
SELECT je.* FROM journey_entries je
WHERE je.journey_id = ? AND je.type != 'skeleton'
WHERE je.journey_id = ? AND je.type != 'skeleton' AND je.dismissed = 0
ORDER BY je.entry_date, je.sort_order
`).all(row.journey_id) as any[];
@@ -694,6 +694,17 @@ export class JourneyController {
return { success: true };
}
// ── Suggestions ─────────────────────────────────────────────────────────
@Post(':id/suggestions/restore')
@HttpCode(200)
restoreSuggestions(@CurrentUser() user: User, @Param('id') id: string) {
const result = this.journey.restoreDismissedSuggestions(Number(id), user.id);
if (!result) {
throw new HttpException({ error: 'Not allowed' }, 403);
}
return result;
}
// ── User Preferences ────────────────────────────────────────────────────
@Patch(':id/preferences')
preferences(@CurrentUser() user: User, @Param('id') id: string, @Body() body: JourneyPreferencesDto) {
+27 -2
View File
@@ -247,21 +247,44 @@ export class JourneyMcp {
title: z.string().min(1).max(200).optional(),
subtitle: z.string().max(300).optional(),
status: z.enum(['draft', 'active', 'completed', 'archived']).optional(),
show_verdict: z.boolean().optional().describe('Whether entries in this journey offer a pros/cons list'),
show_mood: z.boolean().optional().describe('Whether entries in this journey offer a mood'),
show_weather: z.boolean().optional().describe('Whether entries in this journey offer a weather note'),
},
annotations: TOOL_ANNOTATIONS_WRITE,
when: journeyAddonOn,
access: { group: 'journey', mode: 'write' },
})
updateJourney(
{ journeyId, title, subtitle, status }: { journeyId: number; title?: string; subtitle?: string; status?: string },
{ journeyId, ...data }: {
journeyId: number; title?: string; subtitle?: string; status?: string;
show_verdict?: boolean; show_mood?: boolean; show_weather?: boolean;
},
ctx: McpContext,
) {
if (this.auth.isDemoUser(ctx.userId)) return demoDenied();
const journey = this.journey.updateJourney(journeyId, ctx.userId, { title, subtitle, status });
const journey = this.journey.updateJourney(journeyId, ctx.userId, data);
if (!journey) return notFound('Journey not found or access denied.');
return ok({ journey });
}
@Tool({
name: 'restore_journey_suggestions',
description: 'Bring back every trip-derived suggestion that was dismissed from this journey. Answers with how many came back.',
inputSchema: {
journeyId: z.number().int().positive(),
},
annotations: TOOL_ANNOTATIONS_WRITE,
when: journeyAddonOn,
access: { group: 'journey', mode: 'write' },
})
restoreJourneySuggestions({ journeyId }: { journeyId: number }, ctx: McpContext) {
if (this.auth.isDemoUser(ctx.userId)) return demoDenied();
const result = this.journey.restoreDismissedSuggestions(journeyId, ctx.userId);
if (!result) return notFound('Journey not found or access denied.');
return ok(result);
}
@Tool({
name: 'delete_journey',
description: 'Delete a journey. Owner only — this cannot be undone.',
@@ -366,6 +389,7 @@ export class JourneyMcp {
type: ENTRY_TYPE.optional().describe('Promote a trip-derived "skeleton" to a real "entry" once it has been written up'),
sort_order: z.number().int().min(0).optional(),
stats_excluded: z.boolean().optional().describe('True leaves the entry in the journal but takes it off the journey route and out of its distance, countries and step count (see get_journey_stats); false puts it back. For the home airport, a stopover, the place the trip was planned from'),
dismissed: z.boolean().optional().describe('True waves a trip-derived suggestion away: it leaves the journey without being deleted, so the trip sync does not offer it again. restore_journey_suggestions brings every dismissed one back'),
},
annotations: TOOL_ANNOTATIONS_WRITE,
when: journeyAddonOn,
@@ -378,6 +402,7 @@ export class JourneyMcp {
location_lng?: number | null; mood?: string | null; weather?: string | null;
tags?: string[] | null; pros_cons?: { pros: string[]; cons: string[] } | null;
visibility?: EntryVisibility; type?: EntryType; sort_order?: number; stats_excluded?: boolean;
dismissed?: boolean;
},
ctx: McpContext,
) {
@@ -44,6 +44,7 @@ export class JourneyService {
getSuggestions(userId: number) { return this.journey.getSuggestions(userId); }
listUserTrips(userId: number) { return this.journey.listUserTrips(userId); }
updateJourneyPreferences(id: number, userId: number, data: Parameters<typeof this.journey.updateJourneyPreferences>[2]) { return this.journey.updateJourneyPreferences(id, userId, data); }
restoreDismissedSuggestions(id: number, userId: number) { return this.journey.restoreDismissedSuggestions(id, userId); }
// Trips
addTripToJourney(id: number, tripId: number, userId: number) { return this.journey.addTripToJourney(id, tripId, userId); }
+7
View File
@@ -406,6 +406,13 @@ export interface JourneyEntry {
pros_cons?: string | null;
visibility: 'private' | 'shared' | 'public';
sort_order: number;
/** ISO 3166-1 alpha-2, resolved from the coordinates when the entry was written. */
country_code?: string | null;
/**
* 0/1 as the row holds it. A suggestion the traveller waved away: the row stays,
* so the trip sync does not create it again, and every read leaves it out.
*/
dismissed: number;
/**
* 0/1 as the row holds it. Switched on, the entry stays in the journal but
* is left out of the route and the figures Studio prints (discussion #2064).
+21 -1
View File
@@ -49,7 +49,7 @@ vi.mock('../../src/nest/memories/photo-resolver.service', async (importOriginal)
const { jsvc } = vi.hoisted(() => ({
jsvc: {
listJourneys: vi.fn(), createJourney: vi.fn(), getJourneyFull: vi.fn(),
journeyStats: vi.fn(), updateEntry: vi.fn(),
journeyStats: vi.fn(), updateEntry: vi.fn(), restoreDismissedSuggestions: vi.fn(),
},
}));
import { JourneyDomainService } from '../../src/nest/journey/journey-domain.service';
@@ -395,6 +395,26 @@ describe('Journey e2e (real auth guard + temp SQLite)', () => {
expect(res.status).toBe(400);
});
it('restoring suggestions answers 200 with the count, not 201', async () => {
// POST defaults to 201 in Nest, and the route carries @HttpCode(200) to match
// every other action-shaped POST in this controller.
jsvc.restoreDismissedSuggestions.mockReturnValueOnce({ restored: 2 });
const res = await request(server)
.post('/api/journeys/9/suggestions/restore')
.set('Cookie', sessionCookie(1));
expect(res.status).toBe(200);
expect(res.body).toEqual({ restored: 2 });
});
it('restoring suggestions 403 for someone who may not edit the journey', async () => {
jsvc.restoreDismissedSuggestions.mockReturnValueOnce(null);
const res = await request(server)
.post('/api/journeys/9/suggestions/restore')
.set('Cookie', sessionCookie(1));
expect(res.status).toBe(403);
expect(res.body).toEqual({ error: 'Not allowed' });
});
it('public journey 404 for an unknown token', async () => {
sharesvc.getPublicJourney.mockReturnValueOnce(null);
const res = await request(server).get('/api/public/journey/bad');
@@ -2334,3 +2334,186 @@ describe('addTripToJourney guards', () => {
expect(testDb.prepare('SELECT 1 FROM journey_photos WHERE journey_id = ?').all(journey.id)).toHaveLength(0);
});
});
// -- Dismissing a suggestion (discussion #2299) --------------------------------
describe('dismissed suggestions', () => {
it('JOURNEY-SVC-103: a dismissed suggestion leaves every read but keeps its row', () => {
const { user } = createUser(testDb);
const journey = createJourney(testDb, user.id);
const keep = createJourneyEntry(testDb, journey.id, user.id, { type: 'skeleton', title: 'Museum' });
const drop = createJourneyEntry(testDb, journey.id, user.id, { type: 'skeleton', title: 'Aquarium' });
svc.updateEntry(drop.id, user.id, { dismissed: true });
const full = svc.getJourneyFull(journey.id, user.id)!;
expect(full.entries.map((e: { id: number }) => e.id)).toEqual([keep.id]);
expect(svc.listEntries(journey.id, user.id)!.map((e) => e.id)).toEqual([keep.id]);
// The row has to survive, or syncTripPlaces offers the same place again.
expect(testDb.prepare('SELECT dismissed FROM journey_entries WHERE id = ?').get(drop.id)).toEqual({
dismissed: 1,
});
});
it('JOURNEY-SVC-104: the journey reports how many were dismissed', () => {
const { user } = createUser(testDb);
const journey = createJourney(testDb, user.id);
const a = createJourneyEntry(testDb, journey.id, user.id, { type: 'skeleton' });
const b = createJourneyEntry(testDb, journey.id, user.id, { type: 'skeleton' });
createJourneyEntry(testDb, journey.id, user.id, { type: 'skeleton' });
svc.updateEntry(a.id, user.id, { dismissed: true });
svc.updateEntry(b.id, user.id, { dismissed: true });
expect(svc.getJourneyFull(journey.id, user.id)!.dismissed_count).toBe(2);
});
it('JOURNEY-SVC-105: a dismissed place is not offered again by the trip sync', () => {
const { user } = createUser(testDb);
const journey = createJourney(testDb, user.id);
const trip = createTrip(testDb, user.id);
const day = createDay(testDb, trip.id, { day_number: 1, date: '2026-01-15' });
const place = createPlace(testDb, trip.id, { name: 'Aquarium' });
createDayAssignment(testDb, day.id, place.id);
svc.addTripToJourney(journey.id, trip.id, user.id);
const skeleton = svc.listEntries(journey.id, user.id)!.find((e) => e.type === 'skeleton')!;
svc.updateEntry(skeleton.id, user.id, { dismissed: true });
svc.syncTripPlaces(journey.id, trip.id, user.id);
expect(svc.listEntries(journey.id, user.id)!.filter((e) => e.type === 'skeleton')).toHaveLength(0);
});
it('JOURNEY-SVC-106: restoring brings them all back and says how many', () => {
const { user } = createUser(testDb);
const journey = createJourney(testDb, user.id);
const a = createJourneyEntry(testDb, journey.id, user.id, { type: 'skeleton' });
const b = createJourneyEntry(testDb, journey.id, user.id, { type: 'skeleton' });
svc.updateEntry(a.id, user.id, { dismissed: true });
svc.updateEntry(b.id, user.id, { dismissed: true });
expect(svc.restoreDismissedSuggestions(journey.id, user.id)).toEqual({ restored: 2 });
expect(svc.listEntries(journey.id, user.id)).toHaveLength(2);
});
it('JOURNEY-SVC-107: restoring nothing is not an error', () => {
const { user } = createUser(testDb);
const journey = createJourney(testDb, user.id);
expect(svc.restoreDismissedSuggestions(journey.id, user.id)).toEqual({ restored: 0 });
});
it('JOURNEY-SVC-108: a viewer may not restore', () => {
const { user: owner } = createUser(testDb);
const { user: viewer } = createUser(testDb);
const journey = createJourney(testDb, owner.id);
addJourneyContributor(testDb, journey.id, viewer.id, 'viewer');
expect(svc.restoreDismissedSuggestions(journey.id, viewer.id)).toBeNull();
});
});
// -- The country behind an entry's coordinates ---------------------------------
describe('country_code', () => {
it('JOURNEY-SVC-109: a created entry resolves its country from its coordinates', () => {
const { user } = createUser(testDb);
const journey = createJourney(testDb, user.id);
const entry = svc.createEntry(journey.id, user.id, {
entry_date: '2026-01-15',
location_lat: 52.52,
location_lng: 13.405,
})!;
expect(entry.country_code).toBe('DE');
});
it('JOURNEY-SVC-110: an entry with no coordinates has no country', () => {
const { user } = createUser(testDb);
const journey = createJourney(testDb, user.id);
const entry = svc.createEntry(journey.id, user.id, { entry_date: '2026-01-15' })!;
expect(entry.country_code).toBeNull();
});
it('JOURNEY-SVC-111: moving the pin moves the country with it', () => {
const { user } = createUser(testDb);
const journey = createJourney(testDb, user.id);
const entry = svc.createEntry(journey.id, user.id, {
entry_date: '2026-01-15',
location_lat: 52.52,
location_lng: 13.405,
})!;
const moved = svc.updateEntry(entry.id, user.id, { location_lat: 48.8584, location_lng: 2.2945 })!;
expect(moved.country_code).toBe('FR');
});
it('JOURNEY-SVC-112: taking the pin off clears the country', () => {
const { user } = createUser(testDb);
const journey = createJourney(testDb, user.id);
const entry = svc.createEntry(journey.id, user.id, {
entry_date: '2026-01-15',
location_lat: 52.52,
location_lng: 13.405,
})!;
const cleared = svc.updateEntry(entry.id, user.id, {
location_lat: null as unknown as number,
location_lng: null as unknown as number,
})!;
expect(cleared.country_code).toBeNull();
});
it('JOURNEY-SVC-113: an edit that does not touch the pin leaves the country alone', () => {
const { user } = createUser(testDb);
const journey = createJourney(testDb, user.id);
const entry = svc.createEntry(journey.id, user.id, {
entry_date: '2026-01-15',
location_lat: 52.52,
location_lng: 13.405,
})!;
const renamed = svc.updateEntry(entry.id, user.id, { title: 'Berlin' })!;
expect(renamed.country_code).toBe('DE');
});
});
// -- Which optional fields a journey keeps -------------------------------------
describe('entry field switches', () => {
it('JOURNEY-SVC-114: a fresh journey keeps all three', () => {
const { user } = createUser(testDb);
const journey = createJourney(testDb, user.id);
const row = svc.getJourneyFull(journey.id, user.id)! as unknown as Record<string, number>;
expect([row.show_verdict, row.show_mood, row.show_weather]).toEqual([1, 1, 1]);
});
it('JOURNEY-SVC-115: the owner can put one away, and booleans reach the INTEGER column', () => {
const { user } = createUser(testDb);
const journey = createJourney(testDb, user.id);
svc.updateJourney(journey.id, user.id, { show_mood: false, show_weather: false });
const row = svc.getJourneyFull(journey.id, user.id)! as unknown as Record<string, number>;
expect([row.show_verdict, row.show_mood, row.show_weather]).toEqual([1, 0, 0]);
});
it('JOURNEY-SVC-116: an editor may not reshape the journey', () => {
const { user: owner } = createUser(testDb);
const { user: editor } = createUser(testDb);
const journey = createJourney(testDb, owner.id);
addJourneyContributor(testDb, journey.id, editor.id, 'editor');
expect(svc.updateJourney(journey.id, editor.id, { show_mood: false })).toBeNull();
});
});
@@ -356,6 +356,15 @@ describe('JourneyController', () => {
expect(ctl(svc({ reorderEntries: vi.fn().mockReturnValue(true) } as Partial<JourneyService>)).reorderEntries(user, '9', { orderedIds: [3, 1, 2] })).toEqual({ success: true });
});
it('restoreSuggestions answers with the count, and refuses a viewer the same way its siblings do', () => {
const restore = vi.fn().mockReturnValue({ restored: 3 });
expect(ctl(svc({ restoreDismissedSuggestions: restore } as Partial<JourneyService>)).restoreSuggestions(user, '9')).toEqual({ restored: 3 });
expect(restore).toHaveBeenCalledWith(9, 1);
expect(
thrown(() => ctl(svc({ restoreDismissedSuggestions: vi.fn().mockReturnValue(null) } as Partial<JourneyService>)).restoreSuggestions(user, '9')),
).toEqual({ status: 403, body: { error: 'Not allowed' } });
});
it('preferences returns the result on success', () => {
expect(ctl(svc({ updateJourneyPreferences: vi.fn().mockReturnValue({ ok: true }) } as Partial<JourneyService>)).preferences(user, '9', { theme: 'dark' })).toEqual({ ok: true });
});
+15
View File
@@ -623,5 +623,20 @@ const journey: TranslationStrings = {
'journey.editor.statsExcludedHint':
'تبقى المحطة في اليوميات لكنها لا تُحتسب ضمن المسافة أو الدول أو الخريطة في Studio.',
'journey.entry.offRoute': 'خارج المسار',
'journey.suggestions.dismiss': 'تجاهل هذا الاقتراح',
'journey.suggestions.dismissed': 'تم تجاهل الاقتراح',
'journey.suggestions.restore': 'استعادة الاقتراحات المتجاهَلة',
'journey.suggestions.restoreCount': 'الاقتراحات المتجاهَلة ({count})',
'journey.suggestions.restored': 'عادت {count} اقتراحات',
'journey.detail.addOnThisDay': 'إضافة مدخل في هذا اليوم',
'journey.detail.jumpToDay': 'الانتقال إلى {date}',
'journey.detail.searchPlaceholder': 'ابحث في هذه الرحلة',
'journey.detail.searchEmpty': 'لا يوجد مدخل يطابق «{query}»',
'journey.settings.entryFields': 'حقول المدخل',
'journey.settings.entryFieldsHint': 'أوقف ما لا تستخدمه هذه الرحلة. لا يضيع شيء مما كُتب بالفعل.',
'journey.settings.showVerdict': 'الإيجابيات والسلبيات',
'journey.settings.showMood': 'المزاج',
'journey.settings.showWeather': 'الطقس',
'journey.entry.suggestion': 'اقتراح',
};
export default journey;
+15
View File
@@ -626,5 +626,20 @@ const journey: TranslationStrings = {
'journey.editor.statsExcludedHint':
'A parada continua no diário, mas não conta para a distância, os países nem o mapa no Studio.',
'journey.entry.offRoute': 'Fora da rota',
'journey.suggestions.dismiss': 'Descartar esta sugestão',
'journey.suggestions.dismissed': 'Sugestão descartada',
'journey.suggestions.restore': 'Trazer de volta as sugestões descartadas',
'journey.suggestions.restoreCount': 'Sugestões descartadas ({count})',
'journey.suggestions.restored': '{count} sugestões voltaram',
'journey.detail.addOnThisDay': 'Adicionar um registro neste dia',
'journey.detail.jumpToDay': 'Ir para {date}',
'journey.detail.searchPlaceholder': 'Buscar nesta viagem',
'journey.detail.searchEmpty': 'Nenhum registro corresponde a "{query}"',
'journey.settings.entryFields': 'Campos do registro',
'journey.settings.entryFieldsHint': 'Desligue o que esta viagem não usa. Nada do que já foi escrito se perde.',
'journey.settings.showVerdict': 'Prós e contras',
'journey.settings.showMood': 'Humor',
'journey.settings.showWeather': 'Clima',
'journey.entry.suggestion': 'Sugestão',
};
export default journey;
+15
View File
@@ -625,5 +625,20 @@ const journey: TranslationStrings = {
'journey.editor.statsExcludedHint':
'La parada es manté al diari, però no compta per a la distància, els països ni el mapa de Studio.',
'journey.entry.offRoute': 'Fora de ruta',
'journey.suggestions.dismiss': 'Descarta aquest suggeriment',
'journey.suggestions.dismissed': 'Suggeriment descartat',
'journey.suggestions.restore': 'Recupera els suggeriments descartats',
'journey.suggestions.restoreCount': 'Suggeriments descartats ({count})',
'journey.suggestions.restored': '{count} suggeriments han tornat',
'journey.detail.addOnThisDay': 'Afegeix una entrada en aquest dia',
'journey.detail.jumpToDay': 'Ves al {date}',
'journey.detail.searchPlaceholder': 'Cerca en aquest viatge',
'journey.detail.searchEmpty': 'Cap entrada coincideix amb «{query}»',
'journey.settings.entryFields': 'Camps de l\'entrada',
'journey.settings.entryFieldsHint': 'Desactiva el que aquest viatge no faci servir. No es perd res del que ja has escrit.',
'journey.settings.showVerdict': 'Pros i contres',
'journey.settings.showMood': 'Estat d\'ànim',
'journey.settings.showWeather': 'Temps',
'journey.entry.suggestion': 'Suggeriment',
};
export default journey;
+15
View File
@@ -625,5 +625,20 @@ const journey: TranslationStrings = {
'journey.editor.statsExcludedHint':
'Zastávka zůstane v deníku, ale nepočítá se do vzdálenosti, zemí ani do mapy v Studio.',
'journey.entry.offRoute': 'Mimo trasu',
'journey.suggestions.dismiss': 'Zahodit tento návrh',
'journey.suggestions.dismissed': 'Návrh zahozen',
'journey.suggestions.restore': 'Vrátit zahozené návrhy',
'journey.suggestions.restoreCount': 'Zahozené návrhy ({count})',
'journey.suggestions.restored': 'Vrátilo se {count} návrhů',
'journey.detail.addOnThisDay': 'Přidat záznam k tomuto dni',
'journey.detail.jumpToDay': 'Přejít na {date}',
'journey.detail.searchPlaceholder': 'Hledat v této cestě',
'journey.detail.searchEmpty': 'Žádný záznam neodpovídá „{query}“',
'journey.settings.entryFields': 'Pole záznamu',
'journey.settings.entryFieldsHint': 'Vypněte, co tato cesta nepoužívá. Nic z už napsaného se neztratí.',
'journey.settings.showVerdict': 'Pro a proti',
'journey.settings.showMood': 'Nálada',
'journey.settings.showWeather': 'Počasí',
'journey.entry.suggestion': 'Návrh',
};
export default journey;
+15
View File
@@ -626,5 +626,20 @@ const journey: TranslationStrings = {
'journey.editor.statsExcludedHint':
'Die Station bleibt im Tagebuch, zählt aber nicht für Distanz, Länder oder die Karte im Studio.',
'journey.entry.offRoute': 'Nicht auf der Route',
'journey.suggestions.dismiss': 'Diesen Vorschlag verwerfen',
'journey.suggestions.dismissed': 'Vorschlag verworfen',
'journey.suggestions.restore': 'Verworfene Vorschläge zurückholen',
'journey.suggestions.restoreCount': 'Verworfene Vorschläge ({count})',
'journey.suggestions.restored': '{count} Vorschläge sind zurück',
'journey.detail.addOnThisDay': 'Eintrag an diesem Tag hinzufügen',
'journey.detail.jumpToDay': 'Zum {date} springen',
'journey.detail.searchPlaceholder': 'In dieser Reise suchen',
'journey.detail.searchEmpty': 'Kein Eintrag passt zu „{query}“',
'journey.settings.entryFields': 'Felder im Eintrag',
'journey.settings.entryFieldsHint': 'Schalte ab, was diese Reise nicht braucht. Bereits Geschriebenes bleibt erhalten.',
'journey.settings.showVerdict': 'Pro & Contra',
'journey.settings.showMood': 'Stimmung',
'journey.settings.showWeather': 'Wetter',
'journey.entry.suggestion': 'Vorschlag',
};
export default journey;
+15
View File
@@ -626,5 +626,20 @@ const journey: TranslationStrings = {
'journey.editor.statsExcludedHint':
'The stop stays in the journal but does not count toward the distance, the countries or the map in Studio.',
'journey.entry.offRoute': 'Off route',
'journey.suggestions.dismiss': 'Dismiss this suggestion',
'journey.suggestions.dismissed': 'Suggestion dismissed',
'journey.suggestions.restore': 'Bring back dismissed suggestions',
'journey.suggestions.restoreCount': 'Dismissed suggestions ({count})',
'journey.suggestions.restored': '{count} suggestions are back',
'journey.detail.addOnThisDay': 'Add an entry on this day',
'journey.detail.jumpToDay': 'Jump to {date}',
'journey.detail.searchPlaceholder': 'Search this journey',
'journey.detail.searchEmpty': 'No entry matches "{query}"',
'journey.settings.entryFields': 'Entry fields',
'journey.settings.entryFieldsHint': 'Switch off what this journey does not use. Nothing already written is lost.',
'journey.settings.showVerdict': 'Pros & cons',
'journey.settings.showMood': 'Mood',
'journey.settings.showWeather': 'Weather',
'journey.entry.suggestion': 'Suggestion',
};
export default journey;
+15
View File
@@ -626,5 +626,20 @@ const journey: TranslationStrings = {
'journey.editor.statsExcludedHint':
'La parada sigue en el diario, pero no cuenta para la distancia, los países ni el mapa en Studio.',
'journey.entry.offRoute': 'Fuera de ruta',
'journey.suggestions.dismiss': 'Descartar esta sugerencia',
'journey.suggestions.dismissed': 'Sugerencia descartada',
'journey.suggestions.restore': 'Recuperar las sugerencias descartadas',
'journey.suggestions.restoreCount': 'Sugerencias descartadas ({count})',
'journey.suggestions.restored': '{count} sugerencias han vuelto',
'journey.detail.addOnThisDay': 'Añadir una entrada en este día',
'journey.detail.jumpToDay': 'Ir al {date}',
'journey.detail.searchPlaceholder': 'Buscar en este viaje',
'journey.detail.searchEmpty': 'Ninguna entrada coincide con «{query}»',
'journey.settings.entryFields': 'Campos de la entrada',
'journey.settings.entryFieldsHint': 'Desactiva lo que este viaje no use. No se pierde nada de lo ya escrito.',
'journey.settings.showVerdict': 'Pros y contras',
'journey.settings.showMood': 'Ánimo',
'journey.settings.showWeather': 'Tiempo',
'journey.entry.suggestion': 'Sugerencia',
};
export default journey;
+15
View File
@@ -626,5 +626,20 @@ const journey: TranslationStrings = {
'journey.editor.statsExcludedHint':
"L'étape reste dans le journal mais ne compte ni pour la distance, ni pour les pays, ni pour la carte dans Studio.",
'journey.entry.offRoute': 'Hors itinéraire',
'journey.suggestions.dismiss': 'Écarter cette suggestion',
'journey.suggestions.dismissed': 'Suggestion écartée',
'journey.suggestions.restore': 'Récupérer les suggestions écartées',
'journey.suggestions.restoreCount': 'Suggestions écartées ({count})',
'journey.suggestions.restored': '{count} suggestions sont de retour',
'journey.detail.addOnThisDay': 'Ajouter une entrée ce jour-là',
'journey.detail.jumpToDay': 'Aller au {date}',
'journey.detail.searchPlaceholder': 'Rechercher dans ce carnet',
'journey.detail.searchEmpty': 'Aucune entrée ne correspond à « {query} »',
'journey.settings.entryFields': 'Champs de l\'entrée',
'journey.settings.entryFieldsHint': 'Désactivez ce que ce carnet n\'utilise pas. Rien de ce qui est déjà écrit n\'est perdu.',
'journey.settings.showVerdict': 'Pour et contre',
'journey.settings.showMood': 'Humeur',
'journey.settings.showWeather': 'Météo',
'journey.entry.suggestion': 'Suggestion',
};
export default journey;
+15
View File
@@ -628,5 +628,20 @@ const journey: TranslationStrings = {
'journey.editor.statsExcludedHint':
'Η στάση παραμένει στο ημερολόγιο, αλλά δεν μετράει στην απόσταση, τις χώρες ή τον χάρτη στο Studio.',
'journey.entry.offRoute': 'Εκτός διαδρομής',
'journey.suggestions.dismiss': 'Απόρριψη αυτής της πρότασης',
'journey.suggestions.dismissed': 'Η πρόταση απορρίφθηκε',
'journey.suggestions.restore': 'Επαναφορά των απορριμμένων προτάσεων',
'journey.suggestions.restoreCount': 'Απορριμμένες προτάσεις ({count})',
'journey.suggestions.restored': 'Επέστρεψαν {count} προτάσεις',
'journey.detail.addOnThisDay': 'Προσθήκη καταχώρισης σε αυτήν την ημέρα',
'journey.detail.jumpToDay': 'Μετάβαση στις {date}',
'journey.detail.searchPlaceholder': 'Αναζήτηση σε αυτό το ταξίδι',
'journey.detail.searchEmpty': 'Καμία καταχώριση δεν ταιριάζει με «{query}»',
'journey.settings.entryFields': 'Πεδία της καταχώρισης',
'journey.settings.entryFieldsHint': 'Απενεργοποιήστε ό,τι δεν χρησιμοποιεί αυτό το ταξίδι. Τίποτα από όσα έχουν γραφτεί δεν χάνεται.',
'journey.settings.showVerdict': 'Υπέρ και κατά',
'journey.settings.showMood': 'Διάθεση',
'journey.settings.showWeather': 'Καιρός',
'journey.entry.suggestion': 'Πρόταση',
};
export default journey;
+15
View File
@@ -625,5 +625,20 @@ const journey: TranslationStrings = {
'journey.editor.statsExcludedHint':
'A megálló a naplóban marad, de nem számít bele a távolságba, az országokba és a Studio térképébe.',
'journey.entry.offRoute': 'Útvonalon kívül',
'journey.suggestions.dismiss': 'Javaslat elvetése',
'journey.suggestions.dismissed': 'Javaslat elvetve',
'journey.suggestions.restore': 'Elvetett javaslatok visszahozása',
'journey.suggestions.restoreCount': 'Elvetett javaslatok ({count})',
'journey.suggestions.restored': '{count} javaslat visszatért',
'journey.detail.addOnThisDay': 'Bejegyzés hozzáadása ehhez a naphoz',
'journey.detail.jumpToDay': 'Ugrás ide: {date}',
'journey.detail.searchPlaceholder': 'Keresés ebben az útinaplóban',
'journey.detail.searchEmpty': 'Egyetlen bejegyzés sem illik erre: „{query}”',
'journey.settings.entryFields': 'A bejegyzés mezői',
'journey.settings.entryFieldsHint': 'Kapcsold ki, amit ez az útinapló nem használ. A már megírtakból semmi sem vész el.',
'journey.settings.showVerdict': 'Előnyök és hátrányok',
'journey.settings.showMood': 'Hangulat',
'journey.settings.showWeather': 'Időjárás',
'journey.entry.suggestion': 'Javaslat',
};
export default journey;
+15
View File
@@ -625,5 +625,20 @@ const journey: TranslationStrings = {
'journey.editor.statsExcludedHint':
'Perhentian tetap ada di jurnal tetapi tidak dihitung untuk jarak, negara, atau peta di Studio.',
'journey.entry.offRoute': 'Di luar rute',
'journey.suggestions.dismiss': 'Abaikan saran ini',
'journey.suggestions.dismissed': 'Saran diabaikan',
'journey.suggestions.restore': 'Kembalikan saran yang diabaikan',
'journey.suggestions.restoreCount': 'Saran yang diabaikan ({count})',
'journey.suggestions.restored': '{count} saran telah kembali',
'journey.detail.addOnThisDay': 'Tambahkan catatan pada hari ini',
'journey.detail.jumpToDay': 'Lompat ke {date}',
'journey.detail.searchPlaceholder': 'Cari di perjalanan ini',
'journey.detail.searchEmpty': 'Tidak ada catatan yang cocok dengan "{query}"',
'journey.settings.entryFields': 'Bidang catatan',
'journey.settings.entryFieldsHint': 'Matikan yang tidak dipakai perjalanan ini. Tidak ada tulisan yang hilang.',
'journey.settings.showVerdict': 'Kelebihan & kekurangan',
'journey.settings.showMood': 'Suasana hati',
'journey.settings.showWeather': 'Cuaca',
'journey.entry.suggestion': 'Saran',
};
export default journey;
+15
View File
@@ -626,5 +626,20 @@ const journey: TranslationStrings = {
'journey.editor.statsExcludedHint':
'La tappa resta nel diario ma non conta per la distanza, i paesi o la mappa in Studio.',
'journey.entry.offRoute': 'Fuori percorso',
'journey.suggestions.dismiss': 'Scarta questo suggerimento',
'journey.suggestions.dismissed': 'Suggerimento scartato',
'journey.suggestions.restore': 'Recupera i suggerimenti scartati',
'journey.suggestions.restoreCount': 'Suggerimenti scartati ({count})',
'journey.suggestions.restored': '{count} suggerimenti sono tornati',
'journey.detail.addOnThisDay': 'Aggiungi una voce in questo giorno',
'journey.detail.jumpToDay': 'Vai al {date}',
'journey.detail.searchPlaceholder': 'Cerca in questo diario',
'journey.detail.searchEmpty': 'Nessuna voce corrisponde a «{query}»',
'journey.settings.entryFields': 'Campi della voce',
'journey.settings.entryFieldsHint': 'Disattiva ciò che questo diario non usa. Nulla di già scritto va perso.',
'journey.settings.showVerdict': 'Pro e contro',
'journey.settings.showMood': 'Umore',
'journey.settings.showWeather': 'Meteo',
'journey.entry.suggestion': 'Suggerimento',
};
export default journey;
+15
View File
@@ -625,5 +625,20 @@ const journey: TranslationStrings = {
'journey.editor.statsExcluded': 'ルートから除外',
'journey.editor.statsExcludedHint': '立ち寄り地は日記に残りますが、Studio の距離、国、地図には含まれません。',
'journey.entry.offRoute': 'ルート外',
'journey.suggestions.dismiss': 'この提案を見送る',
'journey.suggestions.dismissed': '提案を見送りました',
'journey.suggestions.restore': '見送った提案を戻す',
'journey.suggestions.restoreCount': '見送った提案({count}件)',
'journey.suggestions.restored': '{count} 件の提案が戻りました',
'journey.detail.addOnThisDay': 'この日に記録を追加',
'journey.detail.jumpToDay': '{date} へ移動',
'journey.detail.searchPlaceholder': 'この旅を検索',
'journey.detail.searchEmpty': '「{query}」に一致する記録はありません',
'journey.settings.entryFields': '記録の項目',
'journey.settings.entryFieldsHint': 'この旅で使わないものはオフにできます。すでに書いたものは失われません。',
'journey.settings.showVerdict': '良かった点・気になった点',
'journey.settings.showMood': '気分',
'journey.settings.showWeather': '天気',
'journey.entry.suggestion': '提案',
};
export default journey;
+15
View File
@@ -624,5 +624,20 @@ const journey: TranslationStrings = {
'journey.editor.statsExcluded': '경로에서 제외',
'journey.editor.statsExcludedHint': '경유지는 일기에 남지만 Studio의 거리, 국가, 지도에는 포함되지 않습니다.',
'journey.entry.offRoute': '경로 외',
'journey.suggestions.dismiss': '이 제안 넘기기',
'journey.suggestions.dismissed': '제안을 넘겼습니다',
'journey.suggestions.restore': '넘긴 제안 되돌리기',
'journey.suggestions.restoreCount': '넘긴 제안 ({count}개)',
'journey.suggestions.restored': '제안 {count}개가 돌아왔습니다',
'journey.detail.addOnThisDay': '이 날짜에 기록 추가',
'journey.detail.jumpToDay': '{date}(으)로 이동',
'journey.detail.searchPlaceholder': '이 여행에서 검색',
'journey.detail.searchEmpty': '"{query}"과(와) 일치하는 기록이 없습니다',
'journey.settings.entryFields': '기록 항목',
'journey.settings.entryFieldsHint': '이 여행에서 쓰지 않는 항목은 꺼 두세요. 이미 쓴 내용은 사라지지 않습니다.',
'journey.settings.showVerdict': '좋은 점과 아쉬운 점',
'journey.settings.showMood': '기분',
'journey.settings.showWeather': '날씨',
'journey.entry.suggestion': '제안',
};
export default journey;
+15
View File
@@ -627,5 +627,20 @@ const journey: TranslationStrings = {
'journey.editor.statsExcludedHint':
'De stop blijft in het dagboek maar telt niet mee voor de afstand, de landen of de kaart in Studio.',
'journey.entry.offRoute': 'Buiten de route',
'journey.suggestions.dismiss': 'Deze suggestie verwerpen',
'journey.suggestions.dismissed': 'Suggestie verworpen',
'journey.suggestions.restore': 'Verworpen suggesties terughalen',
'journey.suggestions.restoreCount': 'Verworpen suggesties ({count})',
'journey.suggestions.restored': '{count} suggesties zijn terug',
'journey.detail.addOnThisDay': 'Een item op deze dag toevoegen',
'journey.detail.jumpToDay': 'Naar {date} springen',
'journey.detail.searchPlaceholder': 'Zoeken in deze reis',
'journey.detail.searchEmpty': 'Geen item komt overeen met “{query}”',
'journey.settings.entryFields': 'Velden van het item',
'journey.settings.entryFieldsHint': 'Zet uit wat deze reis niet gebruikt. Wat al geschreven is, blijft bewaard.',
'journey.settings.showVerdict': 'Plus- en minpunten',
'journey.settings.showMood': 'Stemming',
'journey.settings.showWeather': 'Weer',
'journey.entry.suggestion': 'Suggestie',
};
export default journey;
+15
View File
@@ -625,5 +625,20 @@ const journey: TranslationStrings = {
'journey.editor.statsExcludedHint':
'Przystanek zostaje w dzienniku, ale nie liczy się do dystansu, krajów ani mapy w Studio.',
'journey.entry.offRoute': 'Poza trasą',
'journey.suggestions.dismiss': 'Odrzuć tę propozycję',
'journey.suggestions.dismissed': 'Propozycja odrzucona',
'journey.suggestions.restore': 'Przywróć odrzucone propozycje',
'journey.suggestions.restoreCount': 'Odrzucone propozycje ({count})',
'journey.suggestions.restored': 'Wróciło {count} propozycji',
'journey.detail.addOnThisDay': 'Dodaj wpis w tym dniu',
'journey.detail.jumpToDay': 'Przejdź do {date}',
'journey.detail.searchPlaceholder': 'Szukaj w tej podróży',
'journey.detail.searchEmpty': 'Żaden wpis nie pasuje do „{query}”',
'journey.settings.entryFields': 'Pola wpisu',
'journey.settings.entryFieldsHint': 'Wyłącz to, czego ta podróż nie używa. Nic z już napisanego nie ginie.',
'journey.settings.showVerdict': 'Za i przeciw',
'journey.settings.showMood': 'Nastrój',
'journey.settings.showWeather': 'Pogoda',
'journey.entry.suggestion': 'Propozycja',
};
export default journey;
+15
View File
@@ -626,5 +626,20 @@ const journey: TranslationStrings = {
'journey.editor.statsExcludedHint':
'Остановка остаётся в дневнике, но не учитывается в расстоянии, странах и на карте в Studio.',
'journey.entry.offRoute': 'Вне маршрута',
'journey.suggestions.dismiss': 'Отклонить это предложение',
'journey.suggestions.dismissed': 'Предложение отклонено',
'journey.suggestions.restore': 'Вернуть отклонённые предложения',
'journey.suggestions.restoreCount': 'Отклонённые предложения ({count})',
'journey.suggestions.restored': 'Вернулось предложений: {count}',
'journey.detail.addOnThisDay': 'Добавить запись в этот день',
'journey.detail.jumpToDay': 'Перейти к {date}',
'journey.detail.searchPlaceholder': 'Поиск по этому дневнику',
'journey.detail.searchEmpty': 'Ни одна запись не подходит под «{query}»',
'journey.settings.entryFields': 'Поля записи',
'journey.settings.entryFieldsHint': 'Выключите то, что этот дневник не использует. Ничего из уже написанного не пропадёт.',
'journey.settings.showVerdict': 'За и против',
'journey.settings.showMood': 'Настроение',
'journey.settings.showWeather': 'Погода',
'journey.entry.suggestion': 'Предложение',
};
export default journey;
+15
View File
@@ -625,5 +625,20 @@ const journey: TranslationStrings = {
'journey.editor.statsExcludedHint':
'Stoppet finns kvar i dagboken men räknas inte med i sträckan, länderna eller kartan i Studio.',
'journey.entry.offRoute': 'Utanför rutten',
'journey.suggestions.dismiss': 'Avfärda det här förslaget',
'journey.suggestions.dismissed': 'Förslaget avfärdat',
'journey.suggestions.restore': 'Hämta tillbaka avfärdade förslag',
'journey.suggestions.restoreCount': 'Avfärdade förslag ({count})',
'journey.suggestions.restored': '{count} förslag är tillbaka',
'journey.detail.addOnThisDay': 'Lägg till ett inlägg den här dagen',
'journey.detail.jumpToDay': 'Hoppa till {date}',
'journey.detail.searchPlaceholder': 'Sök i den här resan',
'journey.detail.searchEmpty': 'Inget inlägg matchar ”{query}”',
'journey.settings.entryFields': 'Fält i inlägget',
'journey.settings.entryFieldsHint': 'Stäng av det som den här resan inte använder. Inget som redan skrivits går förlorat.',
'journey.settings.showVerdict': 'För- och nackdelar',
'journey.settings.showMood': 'Humör',
'journey.settings.showWeather': 'Väder',
'journey.entry.suggestion': 'Förslag',
};
export default journey;
+15
View File
@@ -625,5 +625,20 @@ const journey: TranslationStrings = {
'journey.editor.statsExcludedHint':
'Durak günlükte kalır ama mesafeye, ülkelere veya Studio içindeki haritaya dahil edilmez.',
'journey.entry.offRoute': 'Rota dışı',
'journey.suggestions.dismiss': 'Bu öneriyi yok say',
'journey.suggestions.dismissed': 'Öneri yok sayıldı',
'journey.suggestions.restore': 'Yok sayılan önerileri geri getir',
'journey.suggestions.restoreCount': 'Yok sayılan öneriler ({count})',
'journey.suggestions.restored': '{count} öneri geri geldi',
'journey.detail.addOnThisDay': 'Bu güne bir kayıt ekle',
'journey.detail.jumpToDay': '{date} tarihine git',
'journey.detail.searchPlaceholder': 'Bu yolculukta ara',
'journey.detail.searchEmpty': '“{query}” ile eşleşen kayıt yok',
'journey.settings.entryFields': 'Kayıt alanları',
'journey.settings.entryFieldsHint': 'Bu yolculuğun kullanmadıklarını kapat. Yazılmış hiçbir şey kaybolmaz.',
'journey.settings.showVerdict': 'Artılar ve eksiler',
'journey.settings.showMood': 'Ruh hâli',
'journey.settings.showWeather': 'Hava durumu',
'journey.entry.suggestion': 'Öneri',
};
export default journey;
+15
View File
@@ -627,5 +627,20 @@ const journey: TranslationStrings = {
'journey.editor.statsExcludedHint':
'Зупинка залишається в щоденнику, але не враховується у відстані, країнах і на карті в Studio.',
'journey.entry.offRoute': 'Поза маршрутом',
'journey.suggestions.dismiss': 'Відхилити цю пропозицію',
'journey.suggestions.dismissed': 'Пропозицію відхилено',
'journey.suggestions.restore': 'Повернути відхилені пропозиції',
'journey.suggestions.restoreCount': 'Відхилені пропозиції ({count})',
'journey.suggestions.restored': 'Повернулося пропозицій: {count}',
'journey.detail.addOnThisDay': 'Додати запис у цей день',
'journey.detail.jumpToDay': 'Перейти до {date}',
'journey.detail.searchPlaceholder': 'Пошук у цьому щоденнику',
'journey.detail.searchEmpty': 'Жоден запис не відповідає «{query}»',
'journey.settings.entryFields': 'Поля запису',
'journey.settings.entryFieldsHint': 'Вимкніть те, чого цей щоденник не використовує. Нічого з написаного не втратиться.',
'journey.settings.showVerdict': 'За і проти',
'journey.settings.showMood': 'Настрій',
'journey.settings.showWeather': 'Погода',
'journey.entry.suggestion': 'Пропозиція',
};
export default journey;
+15
View File
@@ -625,5 +625,20 @@ const journey: TranslationStrings = {
'journey.editor.statsExcludedHint':
'Điểm dừng vẫn nằm trong nhật ký nhưng không được tính vào quãng đường, các quốc gia hay bản đồ trong Studio.',
'journey.entry.offRoute': 'Ngoài lộ trình',
'journey.suggestions.dismiss': 'Bỏ qua gợi ý này',
'journey.suggestions.dismissed': 'Đã bỏ qua gợi ý',
'journey.suggestions.restore': 'Lấy lại các gợi ý đã bỏ qua',
'journey.suggestions.restoreCount': 'Gợi ý đã bỏ qua ({count})',
'journey.suggestions.restored': '{count} gợi ý đã trở lại',
'journey.detail.addOnThisDay': 'Thêm một mục vào ngày này',
'journey.detail.jumpToDay': 'Chuyển đến {date}',
'journey.detail.searchPlaceholder': 'Tìm trong hành trình này',
'journey.detail.searchEmpty': 'Không có mục nào khớp với “{query}”',
'journey.settings.entryFields': 'Các trường của mục',
'journey.settings.entryFieldsHint': 'Tắt những gì hành trình này không dùng. Không mất gì đã viết.',
'journey.settings.showVerdict': 'Ưu và nhược',
'journey.settings.showMood': 'Tâm trạng',
'journey.settings.showWeather': 'Thời tiết',
'journey.entry.suggestion': 'Gợi ý',
};
export default journey;
+15
View File
@@ -621,5 +621,20 @@ const journey: TranslationStrings = {
'journey.editor.statsExcluded': '不計入路線',
'journey.editor.statsExcludedHint': '該停留點仍保留在日誌中,但不計入 Studio 中的距離、國家和地圖。',
'journey.entry.offRoute': '不在路線內',
'journey.suggestions.dismiss': '忽略這則建議',
'journey.suggestions.dismissed': '已忽略該建議',
'journey.suggestions.restore': '找回已忽略的建議',
'journey.suggestions.restoreCount': '已忽略的建議({count})',
'journey.suggestions.restored': '已找回 {count} 則建議',
'journey.detail.addOnThisDay': '在這一天新增紀錄',
'journey.detail.jumpToDay': '跳至 {date}',
'journey.detail.searchPlaceholder': '在這段旅程中搜尋',
'journey.detail.searchEmpty': '沒有紀錄符合「{query}」',
'journey.settings.entryFields': '紀錄欄位',
'journey.settings.entryFieldsHint': '關掉這段旅程用不到的部分。已經寫下的內容不會遺失。',
'journey.settings.showVerdict': '優點與不足',
'journey.settings.showMood': '心情',
'journey.settings.showWeather': '天氣',
'journey.entry.suggestion': '建議',
};
export default journey;
+15
View File
@@ -621,5 +621,20 @@ const journey: TranslationStrings = {
'journey.editor.statsExcluded': '不计入路线',
'journey.editor.statsExcludedHint': '该停留点仍保留在日志中,但不计入 Studio 中的距离、国家和地图。',
'journey.entry.offRoute': '不在路线内',
'journey.suggestions.dismiss': '忽略这条建议',
'journey.suggestions.dismissed': '已忽略该建议',
'journey.suggestions.restore': '找回已忽略的建议',
'journey.suggestions.restoreCount': '已忽略的建议({count})',
'journey.suggestions.restored': '{count} 条建议已找回',
'journey.detail.addOnThisDay': '在这一天添加记录',
'journey.detail.jumpToDay': '跳转到 {date}',
'journey.detail.searchPlaceholder': '在这段旅程中搜索',
'journey.detail.searchEmpty': '没有记录匹配“{query}”',
'journey.settings.entryFields': '记录字段',
'journey.settings.entryFieldsHint': '关掉这段旅程用不到的部分。已经写下的内容不会丢失。',
'journey.settings.showVerdict': '优点与不足',
'journey.settings.showMood': '心情',
'journey.settings.showWeather': '天气',
'journey.entry.suggestion': '建议',
};
export default journey;