From c2707b7eff5b1953785df14f6bf643907fcc9d77 Mon Sep 17 00:00:00 2001 From: Maurice Date: Sun, 13 Sep 2026 18:45:57 +0200 Subject: [PATCH] feat(journey): the enhancements from discussion #2299 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- client/src/api/client.ts | 3 + .../Journey/JourneyDayScrubber.test.tsx | 54 ++++++ .../components/Journey/JourneyDayScrubber.tsx | 74 +++++++ .../JourneyDetailPageEntryCard.test.tsx | 65 ++++++- .../Journey/JourneyDetailPageEntryCard.tsx | 42 +++- .../Journey/JourneyDetailPageEntryEditor.tsx | 100 +++++++++- .../JourneyDetailPageExpandableStory.tsx | 26 ++- .../JourneyDetailPageProviderPicker.tsx | 21 +- .../JourneyDetailPageSettingsDialog.tsx | 81 +++++++- .../Journey/JourneyEntryCover.test.tsx | 112 +++++++++++ .../components/Journey/JourneyEntryCover.tsx | 154 +++++++++++++++ .../components/Journey/JourneyMap.test.tsx | 20 +- client/src/components/Journey/JourneyMap.tsx | 48 +++-- .../src/components/Journey/JourneyMapAuto.tsx | 1 + .../components/Journey/JourneyMapGL.test.tsx | 20 +- .../src/components/Journey/JourneyMapGL.tsx | 16 +- .../Journey/MobileEntryCard.test.tsx | 183 ------------------ .../components/Journey/MobileEntryCard.tsx | 163 ---------------- .../Journey/MobileMapTimeline.test.tsx | 34 +++- .../components/Journey/MobileMapTimeline.tsx | 79 +++++--- .../components/Journey/journeyCard.test.ts | 87 +++++++++ client/src/components/Journey/journeyCard.ts | 96 +++++++++ client/src/mobile/components/MIconBtn.tsx | 6 +- .../mobile/screens/journey/MJourneyDetail.tsx | 107 +++++++++- .../journey/MJourneyEntryCard.test.tsx | 56 ------ .../screens/journey/MJourneyEntryCard.tsx | 101 ---------- .../screens/journey/MJourneyEntrySheet.tsx | 91 ++++++++- .../screens/journey/MJourneySettingsSheet.tsx | 62 +++++- client/src/pages/JourneyDetailPage.test.tsx | 44 +++-- client/src/pages/JourneyDetailPage.tsx | 74 ++++++- .../pages/JourneyDetailPage.wiring.test.tsx | 10 +- client/src/pages/JourneyPublicPage.tsx | 2 + .../JourneyDetailPage.helpers.test.ts | 54 +++++- .../JourneyDetailPage.helpers.ts | 44 ++++- .../pages/journeyDetail/useJourneyDetail.ts | 78 ++++++++ client/src/store/journeyStore.ts | 16 ++ .../mobile/journey/MJourneyEntryCard.test.tsx | 57 ------ .../journey/MJourneyEntrySheet.test.tsx | 5 +- server/src/db/migrations.ts | 47 +++++ .../nest/journey/journey-domain.service.ts | 91 +++++++-- server/src/nest/journey/journey-entry-row.ts | 8 +- .../src/nest/journey/journey-share.service.ts | 2 +- server/src/nest/journey/journey.controller.ts | 11 ++ server/src/nest/journey/journey.mcp.ts | 29 ++- server/src/nest/journey/journey.service.ts | 1 + server/src/types.ts | 7 + server/tests/e2e/journey.e2e.test.ts | 22 ++- .../unit/nest/journey-domain.service.test.ts | 183 ++++++++++++++++++ .../unit/nest/journey.controller.test.ts | 9 + shared/src/i18n/ar/journey.ts | 15 ++ shared/src/i18n/br/journey.ts | 15 ++ shared/src/i18n/ca/journey.ts | 15 ++ shared/src/i18n/cs/journey.ts | 15 ++ shared/src/i18n/de/journey.ts | 15 ++ shared/src/i18n/en/journey.ts | 15 ++ shared/src/i18n/es/journey.ts | 15 ++ shared/src/i18n/fr/journey.ts | 15 ++ shared/src/i18n/gr/journey.ts | 15 ++ shared/src/i18n/hu/journey.ts | 15 ++ shared/src/i18n/id/journey.ts | 15 ++ shared/src/i18n/it/journey.ts | 15 ++ shared/src/i18n/ja/journey.ts | 15 ++ shared/src/i18n/ko/journey.ts | 15 ++ shared/src/i18n/nl/journey.ts | 15 ++ shared/src/i18n/pl/journey.ts | 15 ++ shared/src/i18n/ru/journey.ts | 15 ++ shared/src/i18n/sv/journey.ts | 15 ++ shared/src/i18n/tr/journey.ts | 15 ++ shared/src/i18n/uk/journey.ts | 15 ++ shared/src/i18n/vi/journey.ts | 15 ++ shared/src/i18n/zh-TW/journey.ts | 15 ++ shared/src/i18n/zh/journey.ts | 15 ++ 72 files changed, 2303 insertions(+), 738 deletions(-) create mode 100644 client/src/components/Journey/JourneyDayScrubber.test.tsx create mode 100644 client/src/components/Journey/JourneyDayScrubber.tsx create mode 100644 client/src/components/Journey/JourneyEntryCover.test.tsx create mode 100644 client/src/components/Journey/JourneyEntryCover.tsx delete mode 100644 client/src/components/Journey/MobileEntryCard.test.tsx delete mode 100644 client/src/components/Journey/MobileEntryCard.tsx create mode 100644 client/src/components/Journey/journeyCard.test.ts create mode 100644 client/src/components/Journey/journeyCard.ts delete mode 100644 client/src/mobile/screens/journey/MJourneyEntryCard.test.tsx delete mode 100644 client/src/mobile/screens/journey/MJourneyEntryCard.tsx delete mode 100644 client/tests/unit/mobile/journey/MJourneyEntryCard.test.tsx diff --git a/client/src/api/client.ts b/client/src/api/client.ts index b477380ba..d55a3be27 100644 --- a/client/src/api/client.ts +++ b/client/src/api/client.ts @@ -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), diff --git a/client/src/components/Journey/JourneyDayScrubber.test.tsx b/client/src/components/Journey/JourneyDayScrubber.test.tsx new file mode 100644 index 000000000..661371ec8 --- /dev/null +++ b/client/src/components/Journey/JourneyDayScrubber.test.tsx @@ -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( {}} />) + + expect(screen.getAllByRole('button')).toHaveLength(3) + }) + + it('FE-JRN-SCRUBBER-002: says which day you are on, and how far in', () => { + render( {}} />) + + 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( {}} />) + + 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() + + 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( + {}} />, + ) + + expect(container).toBeEmptyDOMElement() + }) +}) diff --git a/client/src/components/Journey/JourneyDayScrubber.tsx b/client/src/components/Journey/JourneyDayScrubber.tsx new file mode 100644 index 000000000..562aceacc --- /dev/null +++ b/client/src/components/Journey/JourneyDayScrubber.tsx @@ -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 ( +
+
+ + {long(shown.date)} + + {activeIndex >= 0 && ( + + {t('journey.detail.day', { number: String(activeIndex + 1) })} + + )} +
+ {/* 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. */} +
+ {days.map(day => { + const on = day.date === shown.date + return ( +
+
+ ) +} diff --git a/client/src/components/Journey/JourneyDetailPageEntryCard.test.tsx b/client/src/components/Journey/JourneyDetailPageEntryCard.test.tsx index 3d4c1383e..b3bf306f8 100644 --- a/client/src/components/Journey/JourneyDetailPageEntryCard.test.tsx +++ b/client/src/components/Journey/JourneyDetailPageEntryCard.test.tsx @@ -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( + {}} + 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( + {}} + 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() + + 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() + + expect(screen.queryByRole('button', { name: 'Dismiss this suggestion' })).toBeNull() + }) +}) diff --git a/client/src/components/Journey/JourneyDetailPageEntryCard.tsx b/client/src/components/Journey/JourneyDetailPageEntryCard.tsx index 4bdc1a323..f2d0cc21a 100644 --- a/client/src/components/Journey/JourneyDetailPageEntryCard.tsx +++ b/client/src/components/Journey/JourneyDetailPageEntryCard.tsx @@ -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 && (
)} - {entry.story && ( - - )} - - {/* Pros & Cons — "Pros & Cons" style */} - {hasProscons && ( - + {/* 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 ? ( + + {hasProscons && } + + ) : ( + hasProscons && )} {(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 (
{t('journey.detail.addEntry')} )} + {onDismiss && ( + + )}
) } diff --git a/client/src/components/Journey/JourneyDetailPageEntryEditor.tsx b/client/src/components/Journey/JourneyDetailPageEntryEditor.tsx index 27f4108d5..9d0870811 100644 --- a/client/src/components/Journey/JourneyDetailPageEntryEditor.tsx +++ b/client/src/components/Journey/JourneyDetailPageEntryEditor.tsx @@ -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 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, existingEntryId?: number) => Promise onUploadPhotos: (entryId: number, files: File[], cbs?: { onProgress?: (p: UploadProgress) => void }) => Promise> @@ -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(null) const storyRef = useRef(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(null) const persistedEntryIdRef = useRef(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(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, {t('journey.editor.fromGallery')} )} + {/* 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. */} @@ -530,7 +608,8 @@ export function EntryEditor({ entry, journeyId, tripDates, galleryPhotos, trips,
- {/* Pros & Cons */} + {/* Pros & Cons — gone entirely when the journey does not keep a verdict */} + {showVerdict && (
{t('journey.editor.prosCons')} @@ -549,8 +628,10 @@ export function EntryEditor({ entry, journeyId, tripDates, galleryPhotos, trips,
{ 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,
))}
+ )} {/* 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, )} + {showMood && (
@@ -739,7 +824,9 @@ export function EntryEditor({ entry, journeyId, tripDates, galleryPhotos, trips, })}
+ )} + {showWeather && (
@@ -758,6 +845,7 @@ export function EntryEditor({ entry, journeyId, tripDates, galleryPhotos, trips, })}
+ )} diff --git a/client/src/components/Journey/JourneyDetailPageExpandableStory.tsx b/client/src/components/Journey/JourneyDetailPageExpandableStory.tsx index 26ca3d7f9..b559e67f2 100644 --- a/client/src/components/Journey/JourneyDetailPageExpandableStory.tsx +++ b/client/src/components/Journey/JourneyDetailPageExpandableStory.tsx @@ -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 = clamped || expanded + const hasFold = clamped || !!children + const toggle: HTMLAttributes = 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' : ''}`} > - {clamped && !expanded && ( + {expanded && children} + {hasFold && !expanded && ( + {/* The three fields a journey may put away (discussion #2299) */} +
+ +

{t('journey.settings.entryFieldsHint')}

+
+ {([ + ['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 ( + + ) + })} +
+
+ + {/* 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 && ( + + )} + {/* Right column */} diff --git a/client/src/components/Journey/JourneyEntryCover.test.tsx b/client/src/components/Journey/JourneyEntryCover.test.tsx new file mode 100644 index 000000000..0d758377e --- /dev/null +++ b/client/src/components/Journey/JourneyEntryCover.test.tsx @@ -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 { + 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( {}} />) + + 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( {}} />) + + 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( + {}} />, + ) + expect(screen.getByText('🇵🇹')).toBeInTheDocument() + + rerender( + {}} />, + ) + expect(screen.queryByText('🇵🇹')).toBeNull() + }) + + it('FE-JRN-COVER-004: a suggestion says so, so it is not mistaken for somewhere you have been', () => { + render( + {}} + />, + ) + + // 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( + {}} />, + ) + const withBoth = container.querySelectorAll('svg').length + + rerender( + {}} 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( + {}} />, + ) + expect(container.querySelector('button')!.className).toContain('w-[136px]') + + rerender( {}} />) + 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( + {}} + 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() + + await userEvent.click(screen.getByRole('button')) + + expect(onClick).toHaveBeenCalledOnce() + }) +}) diff --git a/client/src/components/Journey/JourneyEntryCover.tsx b/client/src/components/Journey/JourneyEntryCover.tsx new file mode 100644 index 000000000..21f3839db --- /dev/null +++ b/client/src/components/Journey/JourneyEntryCover.tsx @@ -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 ( + + ) +} diff --git a/client/src/components/Journey/JourneyMap.test.tsx b/client/src/components/Journey/JourneyMap.test.tsx index 38529236b..80662d186 100644 --- a/client/src/components/Journey/JourneyMap.test.tsx +++ b/client/src/components/Journey/JourneyMap.test.tsx @@ -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(); render(); 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(); render(); - 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(); - 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(); - 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 { diff --git a/client/src/components/Journey/JourneyMap.tsx b/client/src/components/Journey/JourneyMap.tsx index 33b0e1066..c2a1fd5ce 100644 --- a/client/src/components/Journey/JourneyMap.tsx +++ b/client/src/components/Journey/JourneyMap.tsx @@ -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) } diff --git a/client/src/components/Journey/JourneyMapAuto.tsx b/client/src/components/Journey/JourneyMapAuto.tsx index 4f0599862..e5ac331dd 100644 --- a/client/src/components/Journey/JourneyMapAuto.tsx +++ b/client/src/components/Journey/JourneyMapAuto.tsx @@ -34,6 +34,7 @@ interface Props { onMarkerClick?: (id: string, type?: string) => void fullScreen?: boolean paddingBottom?: number + hideMarkerTooltip?: boolean } function JourneyMapAuto({ ref, ...props }: Props) { diff --git a/client/src/components/Journey/JourneyMapGL.test.tsx b/client/src/components/Journey/JourneyMapGL.test.tsx index ce4f473ce..d9e2ea776 100644 --- a/client/src/components/Journey/JourneyMapGL.test.tsx +++ b/client/src/components/Journey/JourneyMapGL.test.tsx @@ -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() render() 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() render() - 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() - 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 { diff --git a/client/src/components/Journey/JourneyMapGL.tsx b/client/src/components/Journey/JourneyMapGL.tsx index c84f6ff07..e84cec2a4 100644 --- a/client/src/components/Journey/JourneyMapGL.tsx +++ b/client/src/components/Journey/JourneyMapGL.tsx @@ -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, }) diff --git a/client/src/components/Journey/MobileEntryCard.test.tsx b/client/src/components/Journey/MobileEntryCard.test.tsx deleted file mode 100644 index b18c1c41e..000000000 --- a/client/src/components/Journey/MobileEntryCard.test.tsx +++ /dev/null @@ -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[0]['entry'] - -function buildEntry(overrides: Partial> = {}): 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( - , - ) - 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]') - }) -}) diff --git a/client/src/components/Journey/MobileEntryCard.tsx b/client/src/components/Journey/MobileEntryCard.tsx deleted file mode 100644 index da35d65de..000000000 --- a/client/src/components/Journey/MobileEntryCard.tsx +++ /dev/null @@ -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 = { - amazing: Laugh, - good: Smile, - neutral: Meh, - rough: Frown, -} - -const MOOD_COLORS: Record = { - amazing: 'text-pink-500', - good: 'text-amber-500', - neutral: 'text-zinc-400', - rough: 'text-violet-500', -} - -const WEATHER_ICONS: Record = { - 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 ( - - ) -} diff --git a/client/src/components/Journey/MobileMapTimeline.test.tsx b/client/src/components/Journey/MobileMapTimeline.test.tsx index b3a1b4339..7cb454163 100644 --- a/client/src/components/Journey/MobileMapTimeline.test.tsx +++ b/client/src/components/Journey/MobileMapTimeline.test.tsx @@ -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', () => { diff --git a/client/src/components/Journey/MobileMapTimeline.tsx b/client/src/components/Journey/MobileMapTimeline.tsx index 05cd1a0d7..8b95c429f 100644 --- a/client/src/components/Journey/MobileMapTimeline.tsx +++ b/client/src/components/Journey/MobileMapTimeline.tsx @@ -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(null) const carouselRef = useRef(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() - 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>(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 */}
+
{ if (node) cardRefs.current.set(i, node); else cardRefs.current.delete(i); }} style={{ scrollSnapAlign: 'center' }} > - handleCardTap(entry, i)} - publicPhotoUrl={publicPhotoUrl} + showMood={showMood} + showWeather={showWeather} + photoUrlFor={publicPhotoUrl} />
))} @@ -247,7 +276,7 @@ export default function MobileMapTimeline({ {!readOnly && onAddEntry && (
@@ -428,11 +483,17 @@ export default function MJourneyDetail() { )} - {/* Horizontal card timeline */} + {/* Day bar + horizontal card timeline */} {view === 'timeline' && entries.length > 0 && ( +
+
{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' }} > - handleCardTap(entry, i)} /> + handleCardTap(entry, i)} + showMood={current.show_mood !== 0} + showWeather={current.show_weather !== 0} + tone="mobile" + />
))}
+
)} @@ -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} /> )} diff --git a/client/src/mobile/screens/journey/MJourneyEntryCard.test.tsx b/client/src/mobile/screens/journey/MJourneyEntryCard.test.tsx deleted file mode 100644 index 1a2426172..000000000 --- a/client/src/mobile/screens/journey/MJourneyEntryCard.test.tsx +++ /dev/null @@ -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 => ({ - 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() - - 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() - - 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() - - expect(screen.queryByText(LONG_STORY)).not.toBeInTheDocument() - expect(screen.getByText('Erster Tag')).toBeInTheDocument() - }) -}) diff --git a/client/src/mobile/screens/journey/MJourneyEntryCard.tsx b/client/src/mobile/screens/journey/MJourneyEntryCard.tsx deleted file mode 100644 index 01ba2d8f1..000000000 --- a/client/src/mobile/screens/journey/MJourneyEntryCard.tsx +++ /dev/null @@ -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 ( - - ) -} diff --git a/client/src/mobile/screens/journey/MJourneyEntrySheet.tsx b/client/src/mobile/screens/journey/MJourneyEntrySheet.tsx index 938d1a3e5..8af53a955 100644 --- a/client/src/mobile/screens/journey/MJourneyEntrySheet.tsx +++ b/client/src/mobile/screens/journey/MJourneyEntrySheet.tsx @@ -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, existingEntryId?: number) => Promise onUploadPhotos: (entryId: number, files: File[], cbs?: { onProgress?: (p: UploadProgress) => void }) => Promise> @@ -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(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({ {entry.id === 0 ? t('journey.detail.newEntry') : t('journey.detail.editEntry')} + {(onMoveEarlier || onMoveLater) && ( + + onMoveEarlier?.()} disabled={!onMoveEarlier} ariaLabel={t('dayplan.moveUp')}> + + + onMoveLater?.()} disabled={!onMoveLater} ariaLabel={t('dayplan.moveDown')}> + + + + )}
- {!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 ? (
{title || t('journey.editor.titlePlaceholder')}
) : ( - ))} + )} {!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) && (
{t('journey.editor.prosCons')}
@@ -660,9 +717,11 @@ export default function MJourneyEntrySheet({
{ 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 && ( )} + {/* 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 && ( + + )} {!readOnly && captureOnly && (
+ {/* The three fields a journey may put away (discussion #2299) */} +
{t('journey.settings.entryFields')}
+
{t('journey.settings.entryFieldsHint')}
+ {([ + ['show_verdict', t('journey.settings.showVerdict')], + ['show_mood', t('journey.settings.showMood')], + ['show_weather', t('journey.settings.showWeather')], + ] as const).map(([field, label]) => ( +
+
{label}
+ handleFieldToggle(field, next)} + disabled={savingField !== null} + ariaLabel={label} + /> +
+ ))} + + {/* Only when there is something to bring back */} + {onRestoreSuggestions && (journey.dismissed_count ?? 0) > 0 && ( + + )} + {/* Synced trips */}
{t('journey.detail.syncedTrips')}
{journey.trips.map(trip => ( diff --git a/client/src/pages/JourneyDetailPage.test.tsx b/client/src/pages/JourneyDetailPage.test.tsx index d51da214a..e86418ebc 100644 --- a/client/src/pages/JourneyDetailPage.test.tsx +++ b/client/src/pages/JourneyDetailPage.test.tsx @@ -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) { - // 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'); }); }); diff --git a/client/src/pages/JourneyDetailPage.tsx b/client/src/pages/JourneyDetailPage.tsx index 0d555daf3..c9eab6512 100644 --- a/client/src/pages/JourneyDetailPage.tsx +++ b/client/src/pages/JourneyDetailPage.tsx @@ -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() { ))}
+ {/* 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 && ( +
+ + 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 && ( + + )} +
+ )} {canEditEntries && view === 'timeline' && (

{new Date(date + 'T00:00:00').toLocaleDateString(undefined, { weekday: 'long', day: 'numeric', month: 'long' })}

- {entries.length} {t('journey.synced.places')} +
+ {entries.length} {t('journey.synced.places')} + {/* 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 && ( + + )} +
{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" > @@ -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" > @@ -456,7 +506,11 @@ function JourneyDetailPageDesktop() { )}
{entry.type === 'skeleton' ? ( - setEditingEntry(entry) : undefined} /> + setEditingEntry(entry) : undefined} + onDismiss={canEditEntries ? () => dismissSuggestion(entry) : undefined} + /> ) : entry.type === 'checkin' ? ( 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} /> )} diff --git a/client/src/pages/JourneyDetailPage.wiring.test.tsx b/client/src/pages/JourneyDetailPage.wiring.test.tsx index 3d4d6bdf5..e21f62b8c 100644 --- a/client/src/pages/JourneyDetailPage.wiring.test.tsx +++ b/client/src/pages/JourneyDetailPage.wiring.test.tsx @@ -98,6 +98,8 @@ function buildHook(over: Record = {}): Record 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')); }); diff --git a/client/src/pages/JourneyPublicPage.tsx b/client/src/pages/JourneyPublicPage.tsx index 032ca45e3..a6e1f474b 100644 --- a/client/src/pages/JourneyPublicPage.tsx +++ b/client/src/pages/JourneyPublicPage.tsx @@ -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} /> )} diff --git a/client/src/pages/journeyDetail/JourneyDetailPage.helpers.test.ts b/client/src/pages/journeyDetail/JourneyDetailPage.helpers.test.ts index 42258516e..f67631427 100644 --- a/client/src/pages/journeyDetail/JourneyDetailPage.helpers.test.ts +++ b/client/src/pages/journeyDetail/JourneyDetailPage.helpers.test.ts @@ -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([]); + }); +}); diff --git a/client/src/pages/journeyDetail/JourneyDetailPage.helpers.ts b/client/src/pages/journeyDetail/JourneyDetailPage.helpers.ts index 607c2fe69..c0c188a1d 100644 --- a/client/src/pages/journeyDetail/JourneyDetailPage.helpers.ts +++ b/client/src/pages/journeyDetail/JourneyDetailPage.helpers.ts @@ -29,8 +29,18 @@ export function groupByDate(entries: JourneyEntry[]): Map(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(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, ''); +} diff --git a/client/src/pages/journeyDetail/useJourneyDetail.ts b/client/src/pages/journeyDetail/useJourneyDetail.ts index 36ea0fa7d..d905ec2e1 100644 --- a/client/src/pages/journeyDetail/useJourneyDetail.ts +++ b/client/src/pages/journeyDetail/useJourneyDetail.ts @@ -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(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()) 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, diff --git a/client/src/store/journeyStore.ts b/client/src/store/journeyStore.ts index 830a8e188..3fc6c6db2 100644 --- a/client/src/store/journeyStore.ts +++ b/client/src/store/journeyStore.ts @@ -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' } diff --git a/client/tests/unit/mobile/journey/MJourneyEntryCard.test.tsx b/client/tests/unit/mobile/journey/MJourneyEntryCard.test.tsx deleted file mode 100644 index a23868d33..000000000 --- a/client/tests/unit/mobile/journey/MJourneyEntryCard.test.tsx +++ /dev/null @@ -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 { - 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( {}} />); - 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(); - 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( {}} />); - expect(screen.getByText('Off route')).toBeInTheDocument(); - }); - - it('FE-MOB-JRN-007: carries no such mark on an ordinary entry', () => { - render( {}} />); - expect(screen.queryByText('Off route')).not.toBeInTheDocument(); - }); -}); diff --git a/client/tests/unit/mobile/journey/MJourneyEntrySheet.test.tsx b/client/tests/unit/mobile/journey/MJourneyEntrySheet.test.tsx index 4406f13a8..efd23b143 100644 --- a/client/tests/unit/mobile/journey/MJourneyEntrySheet.test.tsx +++ b/client/tests/unit/mobile/journey/MJourneyEntrySheet.test.tsx @@ -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(); diff --git a/server/src/db/migrations.ts b/server/src/db/migrations.ts index ac40c095d..f762e9704 100644 --- a/server/src/db/migrations.ts +++ b/server/src/db/migrations.ts @@ -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) { diff --git a/server/src/nest/journey/journey-domain.service.ts b/server/src/nest/journey/journey-domain.service.ts index 1b6a20df1..bfa9660df 100644 --- a/server/src/nest/journey/journey-domain.service.ts +++ b/server/src/nest/journey/journey-domain.service.ts @@ -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 = ?'); diff --git a/server/src/nest/journey/journey-entry-row.ts b/server/src/nest/journey/journey-entry-row.ts index 2f77855d8..6ee9545d6 100644 --- a/server/src/nest/journey/journey-entry-row.ts +++ b/server/src/nest/journey/journey-entry-row.ts @@ -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 { +export interface JourneyEntryWire extends Omit { 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, }; } diff --git a/server/src/nest/journey/journey-share.service.ts b/server/src/nest/journey/journey-share.service.ts index b80d62660..2c048a4a7 100644 --- a/server/src/nest/journey/journey-share.service.ts +++ b/server/src/nest/journey/journey-share.service.ts @@ -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[]; diff --git a/server/src/nest/journey/journey.controller.ts b/server/src/nest/journey/journey.controller.ts index dce099593..8f202456d 100644 --- a/server/src/nest/journey/journey.controller.ts +++ b/server/src/nest/journey/journey.controller.ts @@ -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) { diff --git a/server/src/nest/journey/journey.mcp.ts b/server/src/nest/journey/journey.mcp.ts index 47d3b99be..57a1aa719 100644 --- a/server/src/nest/journey/journey.mcp.ts +++ b/server/src/nest/journey/journey.mcp.ts @@ -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, ) { diff --git a/server/src/nest/journey/journey.service.ts b/server/src/nest/journey/journey.service.ts index 12d45e069..79f78c3d8 100644 --- a/server/src/nest/journey/journey.service.ts +++ b/server/src/nest/journey/journey.service.ts @@ -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[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); } diff --git a/server/src/types.ts b/server/src/types.ts index fedda2ffc..548b3bac0 100644 --- a/server/src/types.ts +++ b/server/src/types.ts @@ -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). diff --git a/server/tests/e2e/journey.e2e.test.ts b/server/tests/e2e/journey.e2e.test.ts index 954ed2b2d..f7434ca55 100644 --- a/server/tests/e2e/journey.e2e.test.ts +++ b/server/tests/e2e/journey.e2e.test.ts @@ -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'); diff --git a/server/tests/unit/nest/journey-domain.service.test.ts b/server/tests/unit/nest/journey-domain.service.test.ts index f0ee58146..eca64bc1e 100644 --- a/server/tests/unit/nest/journey-domain.service.test.ts +++ b/server/tests/unit/nest/journey-domain.service.test.ts @@ -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; + + 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; + 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(); + }); +}); diff --git a/server/tests/unit/nest/journey.controller.test.ts b/server/tests/unit/nest/journey.controller.test.ts index 704669fd1..10044b6d4 100644 --- a/server/tests/unit/nest/journey.controller.test.ts +++ b/server/tests/unit/nest/journey.controller.test.ts @@ -356,6 +356,15 @@ describe('JourneyController', () => { expect(ctl(svc({ reorderEntries: vi.fn().mockReturnValue(true) } as Partial)).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)).restoreSuggestions(user, '9')).toEqual({ restored: 3 }); + expect(restore).toHaveBeenCalledWith(9, 1); + expect( + thrown(() => ctl(svc({ restoreDismissedSuggestions: vi.fn().mockReturnValue(null) } as Partial)).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)).preferences(user, '9', { theme: 'dark' })).toEqual({ ok: true }); }); diff --git a/shared/src/i18n/ar/journey.ts b/shared/src/i18n/ar/journey.ts index 0e85cbd1a..0f8aabe54 100644 --- a/shared/src/i18n/ar/journey.ts +++ b/shared/src/i18n/ar/journey.ts @@ -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; diff --git a/shared/src/i18n/br/journey.ts b/shared/src/i18n/br/journey.ts index dccac328a..28ba7f85b 100644 --- a/shared/src/i18n/br/journey.ts +++ b/shared/src/i18n/br/journey.ts @@ -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; diff --git a/shared/src/i18n/ca/journey.ts b/shared/src/i18n/ca/journey.ts index a6de506f6..baea47698 100644 --- a/shared/src/i18n/ca/journey.ts +++ b/shared/src/i18n/ca/journey.ts @@ -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; diff --git a/shared/src/i18n/cs/journey.ts b/shared/src/i18n/cs/journey.ts index c76e7372d..ae680e033 100644 --- a/shared/src/i18n/cs/journey.ts +++ b/shared/src/i18n/cs/journey.ts @@ -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; diff --git a/shared/src/i18n/de/journey.ts b/shared/src/i18n/de/journey.ts index 78b8db400..dc0221505 100644 --- a/shared/src/i18n/de/journey.ts +++ b/shared/src/i18n/de/journey.ts @@ -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; diff --git a/shared/src/i18n/en/journey.ts b/shared/src/i18n/en/journey.ts index e302aa350..724675e14 100644 --- a/shared/src/i18n/en/journey.ts +++ b/shared/src/i18n/en/journey.ts @@ -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; diff --git a/shared/src/i18n/es/journey.ts b/shared/src/i18n/es/journey.ts index 6dcaf4011..10f3f511b 100644 --- a/shared/src/i18n/es/journey.ts +++ b/shared/src/i18n/es/journey.ts @@ -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; diff --git a/shared/src/i18n/fr/journey.ts b/shared/src/i18n/fr/journey.ts index 023963891..086a23274 100644 --- a/shared/src/i18n/fr/journey.ts +++ b/shared/src/i18n/fr/journey.ts @@ -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; diff --git a/shared/src/i18n/gr/journey.ts b/shared/src/i18n/gr/journey.ts index cfce69bbf..20a19022f 100644 --- a/shared/src/i18n/gr/journey.ts +++ b/shared/src/i18n/gr/journey.ts @@ -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; diff --git a/shared/src/i18n/hu/journey.ts b/shared/src/i18n/hu/journey.ts index 858115e0f..53a412a82 100644 --- a/shared/src/i18n/hu/journey.ts +++ b/shared/src/i18n/hu/journey.ts @@ -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; diff --git a/shared/src/i18n/id/journey.ts b/shared/src/i18n/id/journey.ts index 126eb709e..46bc60849 100644 --- a/shared/src/i18n/id/journey.ts +++ b/shared/src/i18n/id/journey.ts @@ -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; diff --git a/shared/src/i18n/it/journey.ts b/shared/src/i18n/it/journey.ts index 4f954ef0b..1e6ca4af0 100644 --- a/shared/src/i18n/it/journey.ts +++ b/shared/src/i18n/it/journey.ts @@ -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; diff --git a/shared/src/i18n/ja/journey.ts b/shared/src/i18n/ja/journey.ts index 6b7c2de89..7c14956c7 100644 --- a/shared/src/i18n/ja/journey.ts +++ b/shared/src/i18n/ja/journey.ts @@ -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; diff --git a/shared/src/i18n/ko/journey.ts b/shared/src/i18n/ko/journey.ts index 163d04dc8..eaeebcf94 100644 --- a/shared/src/i18n/ko/journey.ts +++ b/shared/src/i18n/ko/journey.ts @@ -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; diff --git a/shared/src/i18n/nl/journey.ts b/shared/src/i18n/nl/journey.ts index 0dfe8e363..351a18afc 100644 --- a/shared/src/i18n/nl/journey.ts +++ b/shared/src/i18n/nl/journey.ts @@ -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; diff --git a/shared/src/i18n/pl/journey.ts b/shared/src/i18n/pl/journey.ts index ef51d73c4..f823f5aa5 100644 --- a/shared/src/i18n/pl/journey.ts +++ b/shared/src/i18n/pl/journey.ts @@ -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; diff --git a/shared/src/i18n/ru/journey.ts b/shared/src/i18n/ru/journey.ts index 47ef71dd2..589f51c01 100644 --- a/shared/src/i18n/ru/journey.ts +++ b/shared/src/i18n/ru/journey.ts @@ -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; diff --git a/shared/src/i18n/sv/journey.ts b/shared/src/i18n/sv/journey.ts index b4c68b50d..eb4a81d94 100644 --- a/shared/src/i18n/sv/journey.ts +++ b/shared/src/i18n/sv/journey.ts @@ -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; diff --git a/shared/src/i18n/tr/journey.ts b/shared/src/i18n/tr/journey.ts index e3b59143c..0e4dd59c9 100644 --- a/shared/src/i18n/tr/journey.ts +++ b/shared/src/i18n/tr/journey.ts @@ -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; diff --git a/shared/src/i18n/uk/journey.ts b/shared/src/i18n/uk/journey.ts index 235688519..257553c77 100644 --- a/shared/src/i18n/uk/journey.ts +++ b/shared/src/i18n/uk/journey.ts @@ -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; diff --git a/shared/src/i18n/vi/journey.ts b/shared/src/i18n/vi/journey.ts index 5b360ec9f..044f32323 100644 --- a/shared/src/i18n/vi/journey.ts +++ b/shared/src/i18n/vi/journey.ts @@ -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; diff --git a/shared/src/i18n/zh-TW/journey.ts b/shared/src/i18n/zh-TW/journey.ts index f4aac669d..e584f814c 100644 --- a/shared/src/i18n/zh-TW/journey.ts +++ b/shared/src/i18n/zh-TW/journey.ts @@ -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; diff --git a/shared/src/i18n/zh/journey.ts b/shared/src/i18n/zh/journey.ts index ea6902bda..320594365 100644 --- a/shared/src/i18n/zh/journey.ts +++ b/shared/src/i18n/zh/journey.ts @@ -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;