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;