diff --git a/client/src/components/Journey/JourneyDetailPageEntryCard.test.tsx b/client/src/components/Journey/JourneyDetailPageEntryCard.test.tsx index fad61473d..3d4c1383e 100644 --- a/client/src/components/Journey/JourneyDetailPageEntryCard.test.tsx +++ b/client/src/components/Journey/JourneyDetailPageEntryCard.test.tsx @@ -189,6 +189,23 @@ describe('EntryCard', () => { expect(await screen.findByRole('link', { name: 'https://book.example' })).toBeInTheDocument() }) + + // #2064: a stop switched off the route says so on its card, so a journal + // that reads fine still shows which of its days the printed map skips. + it('FE-JRN-CARD-018: marks an entry left out of the route, on the header and on the hero', () => { + const { unmount } = mountCard(buildEntry({ stats_excluded: true })) + expect(screen.getByText('Off route')).toBeInTheDocument() + unmount() + + mountCard(buildEntry({ stats_excluded: true, photos: [buildPhoto(100)] })) + expect(screen.getByText('Off route')).toBeInTheDocument() + }) + + it('FE-JRN-CARD-019: carries no such mark on an ordinary entry', () => { + mountCard(buildEntry({ photos: [buildPhoto(100)] })) + + expect(screen.queryByText('Off route')).not.toBeInTheDocument() + }) }) describe('SkeletonCard', () => { diff --git a/client/src/components/Journey/JourneyDetailPageEntryCard.tsx b/client/src/components/Journey/JourneyDetailPageEntryCard.tsx index 0bad18349..4bdc1a323 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 } from 'lucide-react' +import { MapPin, Clock, MoreHorizontal, Pencil, Trash2, Plus, RouteOff } from 'lucide-react' import { formatLocationName } from '../../utils/formatters' import { useTranslation } from '../../i18n' import { pluginsApi } from '../../api/client' @@ -68,6 +68,14 @@ export function EntryCard({ entry, readOnly, onEdit, onDelete, onPhotoClick }: { {entry.entry_time} )} + {/* Switched off the route (#2064): the day is still in the journal, + the printed map and the distance skip it, and the card says so. */} + {entry.stats_excluded && ( + + + {t('journey.entry.offRoute')} + + )} {/* Menu top-right */} @@ -110,6 +118,11 @@ export function EntryCard({ entry, readOnly, onEdit, onDelete, onPhotoClick }: { {entry.entry_time} )} + {entry.stats_excluded && ( + + {t('journey.entry.offRoute')} + + )} {!readOnly && (
diff --git a/client/src/components/Journey/JourneyDetailPageEntryEditor.test.tsx b/client/src/components/Journey/JourneyDetailPageEntryEditor.test.tsx index b560134e1..141e966df 100644 --- a/client/src/components/Journey/JourneyDetailPageEntryEditor.test.tsx +++ b/client/src/components/Journey/JourneyDetailPageEntryEditor.test.tsx @@ -965,4 +965,67 @@ describe('EntryEditor', () => { expect(library[0]).toHaveAttribute('multiple') expect(camera[0]).not.toHaveAttribute('multiple') }) + + // #2064: a home airport written up as an entry is a stop on the printed + // route. The editor offers the same switch the Studio panel has, but only + // to an entry that is (or was) a stop: one without a point never counted, + // and a new entry is not on the route yet. + it('FE-JRN-EDITOR-051: offers to leave a located entry out of the route and saves it', async () => { + const user = userEvent.setup() + const { onSave } = mountEditor(buildEntry({ + id: 10, location_name: 'Keflavík', location_lat: 63.98, location_lng: -22.6, + })) + + const toggle = screen.getByRole('button', { name: 'Leave out of the route' }) + expect(toggle).toHaveAttribute('aria-pressed', 'false') + await user.click(toggle) + expect(toggle).toHaveAttribute('aria-pressed', 'true') + await user.click(screen.getByRole('button', { name: 'Save' })) + + await waitFor(() => expect(onSave).toHaveBeenCalled()) + expect(onSave.mock.calls[0][0]).toMatchObject({ stats_excluded: true }) + }) + + it('FE-JRN-EDITOR-052: keeps the switch away from a new entry, point or no point', () => { + mountEditor(buildEntry({ location_lat: 63.98, location_lng: -22.6 })) + + expect(screen.queryByRole('button', { name: 'Leave out of the route' })).not.toBeInTheDocument() + }) + + it('FE-JRN-EDITOR-053: keeps the switch away from an entry without a point, and sends nothing for it', async () => { + const user = userEvent.setup() + const { onSave } = mountEditor(buildEntry({ id: 10 })) + + expect(screen.queryByRole('button', { name: 'Leave out of the route' })).not.toBeInTheDocument() + await user.click(screen.getByRole('button', { name: 'Save' })) + + await waitFor(() => expect(onSave).toHaveBeenCalled()) + expect(onSave.mock.calls[0][0].stats_excluded).toBeUndefined() + }) + + it('FE-JRN-EDITOR-054: still offers the switch to a left-out entry, so it can be put back', async () => { + const user = userEvent.setup() + const { onSave } = mountEditor(buildEntry({ id: 10, stats_excluded: true })) + + const toggle = screen.getByRole('button', { name: 'Leave out of the route' }) + expect(toggle).toHaveAttribute('aria-pressed', 'true') + await user.click(toggle) + await user.click(screen.getByRole('button', { name: 'Save' })) + + await waitFor(() => expect(onSave).toHaveBeenCalled()) + expect(onSave.mock.calls[0][0]).toMatchObject({ stats_excluded: false }) + }) + + it('FE-JRN-EDITOR-055: flipping the switch is a change worth warning about', async () => { + const confirmSpy = vi.spyOn(window, 'confirm').mockReturnValue(false) + const user = userEvent.setup() + const { onClose } = mountEditor(buildEntry({ id: 10, location_lat: 63.98, location_lng: -22.6 })) + + await user.click(screen.getByRole('button', { name: 'Leave out of the route' })) + await user.click(screen.getByRole('button', { name: 'Cancel' })) + + expect(confirmSpy).toHaveBeenCalledWith('You have unsaved changes. Discard them?') + expect(onClose).not.toHaveBeenCalled() + confirmSpy.mockRestore() + }) }) diff --git a/client/src/components/Journey/JourneyDetailPageEntryEditor.tsx b/client/src/components/Journey/JourneyDetailPageEntryEditor.tsx index d504a30bc..a7d3e4668 100644 --- a/client/src/components/Journey/JourneyDetailPageEntryEditor.tsx +++ b/client/src/components/Journey/JourneyDetailPageEntryEditor.tsx @@ -14,6 +14,7 @@ import { photoUrl, isValidGeoPoint, geoOnceErrorKey } from '../../pages/journeyD import MarkdownToolbar from './MarkdownToolbar' import { DatePicker } from './JourneyDetailPageDatePicker' import CustomTimePicker from '../shared/CustomTimePicker' +import ToggleSwitch from '../Settings/ToggleSwitch' import { ProviderPicker, type ProviderPhotoGroup } from './JourneyDetailPageProviderPicker' type PendingProviderGroup = ProviderPhotoGroup & { provider: string } @@ -48,6 +49,7 @@ export function EntryEditor({ entry, journeyId, tripDates, galleryPhotos, trips, const locationTimerRef = useRef | null>(null) const [mood, setMood] = useState(entry.mood || '') const [weather, setWeather] = useState(entry.weather || '') + const [statsExcluded, setStatsExcluded] = useState(entry.stats_excluded ?? false) const [pros, setPros] = useState(entry.pros_cons?.pros?.length ? entry.pros_cons.pros : ['']) const [cons, setCons] = useState(entry.pros_cons?.cons?.length ? entry.pros_cons.cons : ['']) const [saving, setSaving] = useState(false) @@ -87,6 +89,7 @@ export function EntryEditor({ entry, journeyId, tripDates, galleryPhotos, trips, (locationLng ?? null) !== (entry.location_lng ?? null) || mood !== (entry.mood || '') || weather !== (entry.weather || '') || + statsExcluded !== (entry.stats_excluded ?? false) || pros.filter(p => p.trim()).join('\n') !== originalPros || cons.filter(c => c.trim()).join('\n') !== originalCons || pendingFiles.length > 0 || @@ -146,6 +149,7 @@ export function EntryEditor({ entry, journeyId, tripDates, galleryPhotos, trips, location_name: locationName || null, location_lat: locationLat, location_lng: locationLng, + stats_excluded: offersStatsToggle ? statsExcluded : undefined, mood: mood || null, weather: weather || null, pros_cons: { pros: pros.filter(p => p.trim()), cons: cons.filter(c => c.trim()) }, @@ -214,6 +218,10 @@ export function EntryEditor({ entry, journeyId, tripDates, galleryPhotos, trips, ? { lat: locationLat!, lng: locationLng!, name: locationName || undefined } : null + // The route switch belongs to an entry that is a stop, or was one: an entry + // without a point was never on the route, and a new one is not on it yet. + const offersStatsToggle = entry.id > 0 && (contextLocation != null || !!entry.stats_excluded) + const handleUseCurrentLocation = async () => { if (locating) return setLocating(true) @@ -695,6 +703,19 @@ export function EntryEditor({ entry, journeyId, tripDates, galleryPhotos, trips,
+ {/* Every located entry is a stop on the route Studio prints, the home + airport included. This is the entry's own way off it, the same + switch the Studio travel panel offers (#2064). */} + {offersStatsToggle && ( +
+
+
{t('journey.editor.statsExcluded')}
+
{t('journey.editor.statsExcludedHint')}
+
+ setStatsExcluded(v => !v)} label={t('journey.editor.statsExcluded')} /> +
+ )} +
diff --git a/client/src/components/Studio/StudioCanvas.tsx b/client/src/components/Studio/StudioCanvas.tsx index 5c0007d3e..11363b63d 100644 --- a/client/src/components/Studio/StudioCanvas.tsx +++ b/client/src/components/Studio/StudioCanvas.tsx @@ -65,6 +65,8 @@ export function StudioCanvas({ dropLabel, cursors, onCursor, + onDropFiles, + fileDropLabel, }: { spread: BookSpread | null spreadIndex: number @@ -73,6 +75,14 @@ export function StudioCanvas({ pxPerMm: number bookView: boolean dropLabel: string + /** + * Files from outside the browser, let go over the sheet. Absent for a + * viewer, and then a file drag is left to the browser, which opens it. + * `targetFrameId` is the photo frame under the pointer, when there is one. + */ + onDropFiles?: (files: File[], at: { x: number; y: number }, targetFrameId: string | null) => void + /** What the veil says while a file drag is over the sheet. */ + fileDropLabel?: string /** The other editors' pointers, on this spread and elsewhere. */ cursors?: PeerCursor[] /** Where this one is, in the spread's millimetres. Null once it leaves. */ @@ -87,6 +97,8 @@ export function StudioCanvas({ const commit = useStudioStore(s => s.commit) const addElement = useStudioStore(s => s.addElement) const [dropTarget, setDropTarget] = useState(null) + /** A file drag from outside the browser is over the sheet. */ + const [fileOver, setFileOver] = useState(false) const [editing, setEditing] = useState(null) /** Where on the spread, in millimetres, a drop landed. */ @@ -166,22 +178,34 @@ export function StudioCanvas({ onPointerUp={finish} onPointerCancel={finish} onDragOver={e => { - if (!e.dataTransfer.types.includes('application/x-trek-photo')) return + const fromPanel = e.dataTransfer.types.includes('application/x-trek-photo') + // A file drag is only claimed when somebody is there to take it; for a + // viewer the browser keeps its default and opens the picture itself. + const fromDisk = !!onDropFiles && e.dataTransfer.types.includes('Files') + if (!fromPanel && !fromDisk) return e.preventDefault() e.dataTransfer.dropEffect = 'copy' const p = pointInMm(e) setDropTarget(frameUnder(p.x, p.y)?.id ?? null) + setFileOver(fromDisk && !fromPanel) }} - onDragLeave={() => setDropTarget(null)} + onDragLeave={() => { setDropTarget(null); setFileOver(false) }} onDrop={e => { const raw = e.dataTransfer.getData('application/x-trek-photo') - if (!raw) return + const files = Array.from(e.dataTransfer.files ?? []) + if (!raw && !(files.length && onDropFiles)) return e.preventDefault() setDropTarget(null) + setFileOver(false) + + const p = pointInMm(e) + if (!raw) { + onDropFiles!(files, p, frameUnder(p.x, p.y)?.id ?? null) + return + } const photoId = Number(raw) if (!Number.isFinite(photoId)) return - const p = pointInMm(e) const target = frameUnder(p.x, p.y) if (target) { // Dropped onto a frame: fill it. That is the whole point of an empty @@ -211,6 +235,17 @@ export function StudioCanvas({ select([id]) }} > + {/* + Over the stage rather than inside the sheet, which is scaled: at 40% + a label drawn on the page would be unreadable. It says what letting go + will do, because a page is not an obvious drop target for a file the + way an upload box is. + */} + {fileOver && fileDropLabel && ( +
+ {fileDropLabel} +
+ )}
) => update(spreadIndex, el.id, patch) + /** + * Stretch the picture over the page it is on, or over both pages, bleed + * included. + * + * One commit rather than `set` followed by `raise`: the frame, the rotation + * put back to zero (a turned picture leaves bare corners), the cover crop and + * the move to the back are one press, and one press comes back with one + * undo. Which page is the one under the frame's centre; a cover has only + * the one. + */ + const fill = (scope: 'page' | 'spread') => commit(d => ({ + ...d, + spreads: d.spreads.map((sp, i) => { + if (i !== spreadIndex) return sp + const bleed = page.bleed + const onRight = sp.role === 'inner' && scope === 'page' + && el.frame.x + el.frame.w / 2 >= page.pageWidth + const frame = { + x: onRight ? page.pageWidth - bleed : -bleed, + y: -bleed, + w: (scope === 'spread' ? 2 : 1) * page.pageWidth + 2 * bleed, + h: page.pageHeight + 2 * bleed, + } + const filled = { ...el, frame, rotation: 0, fit: 'cover' } as BookElement + return { ...sp, elements: [filled, ...sp.elements.filter(e => e.id !== el.id)] } + }), + })) + return (
+ {/* + The picture as the page. + + A full-bleed photograph is the commonest layout in any photo book + and the hardest to make by hand: the frame has to reach exactly to + the bleed on every side, and a millimetre short leaves a white + hairline on the trimmed page. So it is one press. It goes to the + back as well, since a page-sized picture in front of everything is + a page with nothing else on it. + */} + {one && el.kind === 'photo' && ( +
+ + {spread.role === 'inner' && ( + + )} +
+ )}
diff --git a/client/src/components/Studio/StudioShell.tsx b/client/src/components/Studio/StudioShell.tsx index 20dd4ce41..dd25d9f65 100644 --- a/client/src/components/Studio/StudioShell.tsx +++ b/client/src/components/Studio/StudioShell.tsx @@ -89,6 +89,9 @@ export default function StudioShell() { path={s.path} t={s.t} locale={s.locale} + canEdit={s.canEdit} + onUpload={s.uploadPhotos} + onToggleStop={s.setStopExcluded} /> s.moveCursor(s.activeSpread, x, y)} + onDropFiles={s.canEdit ? s.dropFiles : undefined} + fileDropLabel={s.t('journey.studio.dropFilesHere')} /> + {/* + Pictures dropped on the sheet are on their way. The page keeps + rendering underneath; the veil only says that something is happening + where the pointer let go, so the second or two before the frame + appears does not read as the drop having been ignored. + */} + {s.canvasUpload && ( +
+ {s.t('journey.studio.uploading', { done: s.canvasUpload.done, total: s.canvasUpload.total })} +
+ )}
diff --git a/client/src/components/Studio/StudioSidebar.tsx b/client/src/components/Studio/StudioSidebar.tsx index f852046b1..44ab7e916 100644 --- a/client/src/components/Studio/StudioSidebar.tsx +++ b/client/src/components/Studio/StudioSidebar.tsx @@ -1,9 +1,13 @@ -import { useMemo, useRef, useState } from 'react' -import { ChevronDown, ChevronUp, Compass, Copy, FileDown, Files, ImageIcon, LayoutTemplate, Plus, Search, Shapes, Trash2, X } from 'lucide-react' +import { useEffect, useMemo, useRef, useState } from 'react' +import { + Camera, Check, ChevronDown, ChevronUp, Compass, Copy, FileDown, Files, ImageIcon, LayoutTemplate, + Plus, Search, Shapes, Trash2, Upload, X, +} from 'lucide-react' import type { BookElement, BookPageSetup, JourneyStats } from '@trek/shared' import { useElementSize } from '../../hooks/useElementSize' import { useStudioStore } from '../../store/studioStore' import { formatDate } from '../../utils/formatters' +import { useToast } from '../shared/Toast' import { SpreadFold, SpreadView } from './SpreadView' import { photoSrc } from './bookRender' import { formatBookCoords, formatBookDate, type CoordFormat } from './entryText' @@ -14,6 +18,7 @@ import { PanelHead as Head } from './StudioPanelHead' import { StudioElementsPanel } from './StudioElementsPanel' import { StudioTravelPanel } from './StudioTravelPanel' import { MAX_SPREAD_FILE_BYTES, importSpread } from './spreadFile' +import type { StudioUploader } from './studioUpload' /** * The left side of Studio: a narrow rail of sections and one wide panel showing @@ -42,12 +47,18 @@ export interface JourneySource { weather: string | null pros: string[] cons: string[] + /** The pictures attached to this entry, as trek_photos ids. */ + photoIds: number[] }[] - photos: { photoId: number; caption?: string | null }[] + /** `entryIds` is empty for a picture that sits only in the gallery. */ + photos: { photoId: number; caption?: string | null; entryIds: number[] }[] /** photoId to the words of the entry it belongs to, lower-cased. */ photoEntries: Record } +/** The translator, with the parameters some of the strings here take. */ +export type StudioT = (k: string, params?: Record) => string + const uid = (p: string) => `${p}-${Math.random().toString(36).slice(2, 9)}` /** @@ -72,6 +83,7 @@ const THUMB_CHROME = (2 + 3) * 2 export function StudioSidebar({ page, pxPerMm, bookView, source, stats, path, t, locale, + canEdit, onUpload, onToggleStop, }: { page: BookPageSetup pxPerMm: number @@ -81,8 +93,14 @@ export function StudioSidebar({ stats: JourneyStats | null /** The roads the trip took, for a map that draws them. Empty when it has none. */ path: [number, number][][] - t: (k: string) => string + t: StudioT locale: string + /** False for a viewer: no upload, no switching stops, nothing that writes. */ + canEdit: boolean + /** Pictures into the gallery, or into one entry. See studioUpload.ts. */ + onUpload: StudioUploader + /** Switch a stop on or off in the figures. Resolves false when it did not land. */ + onToggleStop: (entryId: number, excluded: boolean) => Promise }) { const [section, setSection] = useState
('pages') @@ -113,9 +131,21 @@ export function StudioSidebar({ @@ -290,18 +320,46 @@ function PagesPanel({ ) } +/** + * Which of the journey's pictures the browser shows. + * + * `recent` holds ids rather than a flag on the photo: what just arrived is a + * fact about this sitting, not about the picture, and the journey store has no + * reason to remember it. + */ +type PhotoFilter = + | { kind: 'all' } + | { kind: 'entry'; id: number } + | { kind: 'loose' } + | { kind: 'recent'; photoIds: number[] } + /** * The journey's own material. * * This is what separates a book maker from a drawing program: the pictures and * the words are already written, and the job is putting them on pages. Clicking * an item drops it on the current spread, centred, at a sensible size. + * + * Pictures can also come in here. A book is where you notice the one that + * never made it into the journal, and going back to the journey to add it, then + * finding the page again, was the one round trip Studio still asked for. What + * comes in goes through the journey store like any other upload, so the journal + * sees it the moment the panel does. */ function ContentPanel({ - source, page, t, locale, -}: { source: JourneySource; page: BookPageSetup; t: (k: string) => string; locale: string }) { + source, page, t, locale, canEdit, onUpload, +}: { + source: JourneySource + page: BookPageSetup + t: StudioT + locale: string + canEdit: boolean + onUpload: StudioUploader +}) { const [tab, setTab] = useState<'photos' | 'text'>('photos') const [query, setQuery] = useState('') + const [filter, setFilter] = useState({ kind: 'all' }) + const toast = useToast() /* * Filtering, not a search index: a journey holds tens or hundreds of items, and @@ -316,16 +374,144 @@ function ContentPanel({ [e.title, e.story, e.location, ...e.pros, ...e.cons] .some(v => v && v.toLowerCase().includes(q))) : source.entries + + /* + * The filter narrows first and the words second, so an entry filter with a + * search typed into it means "these pictures, with these words". Which entry + * a picture belongs to is read off the journal's own junction rather than + * off its words, which is what lets "not in an entry" mean exactly that. + */ + const byFilter = (() => { + switch (filter.kind) { + case 'entry': return source.photos.filter(p => p.entryIds.includes(filter.id)) + case 'loose': return source.photos.filter(p => !p.entryIds.length) + case 'recent': { + // In the order they were sent, which is the order they were chosen in, + // not wherever the gallery sorted them. + const byId = new Map(source.photos.map(p => [p.photoId, p] as const)) + return filter.photoIds.flatMap(id => { const p = byId.get(id); return p ? [p] : [] }) + } + default: return source.photos + } + })() const photos = q - ? source.photos.filter(p => + ? byFilter.filter(p => (p.caption && p.caption.toLowerCase().includes(q)) || (source.photoEntries[p.photoId] || '').includes(q)) - : source.photos + : byFilter + + const looseCount = source.photos.filter(p => !p.entryIds.length).length + const withPhotos = source.entries.filter(e => e.photoIds.length > 0) + const nameOf = (e: { title: string | null; location: string | null } | undefined) => + e?.title || e?.location || t('journey.studio.untitled') + const filterLabel = filter.kind === 'loose' ? t('journey.studio.filterLoose') + : filter.kind === 'recent' ? t('journey.studio.filterRecent') + : filter.kind === 'entry' ? nameOf(source.entries.find(e => e.id === filter.id)) + : t('journey.studio.filterAll') + + /* + * Why the grid is empty, which is four different answers. "No photos" on a + * journey with three hundred of them, because the filter is on a day with + * none, reads as the pictures having gone. + */ + const emptyKey = !source.photos.length ? 'journey.studio.noPhotos' + : byFilter.length ? 'journey.studio.noMatches' + : filter.kind === 'entry' ? 'journey.studio.noEntryPhotos' + : filter.kind === 'loose' ? 'journey.studio.noLoosePhotos' + : 'journey.studio.noMatches' + + const [menuOpen, setMenuOpen] = useState(false) + const menuBox = useRef(null) + useEffect(() => { + if (!menuOpen) return + const onDown = (e: PointerEvent) => { + if (!menuBox.current?.contains(e.target as Node)) setMenuOpen(false) + } + const onKey = (e: KeyboardEvent) => { + // Swallowed, as the format picker does it: the shell's Escape closes Studio. + if (e.key === 'Escape') { e.stopPropagation(); setMenuOpen(false) } + } + document.addEventListener('pointerdown', onDown) + document.addEventListener('keydown', onKey, true) + return () => { + document.removeEventListener('pointerdown', onDown) + document.removeEventListener('keydown', onKey, true) + } + }, [menuOpen]) + const pick = (f: PhotoFilter) => { setFilter(f); setMenuOpen(false) } + const addElement = useStudioStore(s => s.addElement) const active = useStudioStore(s => s.activeSpread) const doc = useStudioStore(s => s.doc) const spread = doc?.spreads[active] + /* + * The page on the sheet belongs to a day, so its pictures are one press + * away. Offered only while the filter is not already there: a chip for the + * state you are in is a chip that does nothing. + */ + const pageEntry = spread?.entryId != null ? withPhotos.find(e => e.id === spread.entryId) : undefined + const offerThisPage = pageEntry && !(filter.kind === 'entry' && filter.id === pageEntry.id) + + const fileInput = useRef(null) + const [upload, setUpload] = useState<{ done: number; total: number } | null>(null) + /* A ref as well as the state: two drops in one second would both read the + state before either had set it. */ + const sending = useRef(false) + const [dropOver, setDropOver] = useState(false) + + /** + * Into the entry the browser is filtered to, otherwise into the gallery. The + * filter is the one thing on screen that names an entry, so it is also the + * one thing that can say where a picture belongs; the line under it says so + * before the press. Afterwards the new pictures get a filter of their own, + * because the gallery sorts them to its end, and twelve pictures that arrive + * out of sight read as twelve that did not arrive. + */ + const send = async (files: File[]) => { + if (sending.current || !files.length) return + sending.current = true + setUpload({ done: 0, total: files.length }) + try { + const entryId = filter.kind === 'entry' ? filter.id : null + const sent = await onUpload(files, entryId, p => setUpload({ done: p.done, total: p.total })) + if (sent.photoIds.length) { + setFilter({ kind: 'recent', photoIds: sent.photoIds }) + setQuery('') + toast.success(t('journey.photosUploaded', { count: sent.photoIds.length })) + } + } finally { + sending.current = false + setUpload(null) + } + } + + /* + * The whole scrolling area takes a drop, not just the dashed cell: a drag + * from the desktop arrives with the pointer wherever it arrives, and a target + * seventy pixels square is one you miss. Only a file drag is claimed; the + * panel's own photo drags carry another type and go to the sheet. + */ + const dropZone = canEdit && tab === 'photos' ? { + onDragOver: (e: React.DragEvent) => { + if (!e.dataTransfer.types.includes('Files')) return + e.preventDefault() + e.dataTransfer.dropEffect = 'copy' + setDropOver(true) + }, + onDragLeave: (e: React.DragEvent) => { + // Moving onto a child of the box is not leaving the box. + if (!e.currentTarget.contains(e.relatedTarget as Node | null)) setDropOver(false) + }, + onDrop: (e: React.DragEvent) => { + const files = Array.from(e.dataTransfer.files ?? []) + if (!files.length) return + e.preventDefault() + setDropOver(false) + void send(files) + }, + } : {} + const centre = (w: number, h: number) => { const W = spread && spread.role !== 'inner' ? page.pageWidth : page.pageWidth * 2 return { x: (W - w) / 2, y: (page.pageHeight - h) / 2, w, h } @@ -444,33 +630,142 @@ function ContentPanel({
-
- {tab === 'photos' ? ( -
- {photos.map(p => ( + {/* + Which entry, as a row of its own under the tabs rather than a control + inside the search box: the search answers "which words" and this + answers "which day", and the two combine. The chip opens the same + popover the bar uses for formats, hung from its left edge because the + panel has no room to the right of it. + */} + {tab === 'photos' && ( +
+
+ - ))} - {!photos.length && ( -

{t(q ? 'journey.studio.noMatches' : 'journey.studio.noPhotos')}

+ {filter.kind !== 'all' && ( + + )} +
+ + {menuOpen && ( +
+ pick({ kind: 'all' })} + /> + pick({ kind: 'loose' })} + /> + {withPhotos.length > 0 &&
} + {withPhotos.map(e => ( + pick({ kind: 'entry', id: e.id })} + /> + ))} +
)}
+ + {offerThisPage && ( + + )} +
+ )} + +
+ {tab === 'photos' ? ( + <> + {canEdit && ( +

+ {t(filter.kind === 'entry' ? 'journey.studio.uploadToEntry' : 'journey.studio.uploadToGallery')} +

+ )} +
+ {/* + The first cell rather than a button above the grid: it is the + size and shape of what it produces, and it stays where the eye + already is. Disabled, not hidden, while a batch is on its way, + so the count has somewhere to be read. + */} + {canEdit && ( + + )} + {photos.map(p => ( + + ))} + {!photos.length &&

{t(emptyKey)}

} +
+ {canEdit && ( + { + const files = Array.from(e.target.files ?? []) + // Cleared straight away, as the spread import does, so the + // same pictures can be chosen twice in a row. + e.target.value = '' + void send(files) + }} + /> + )} + ) : (
{entries.map(e => ( @@ -556,6 +851,22 @@ function ContentPanel({ {e.pros.length + e.cons.length} )} + {/* + Not a drop like the others: this one takes you to the + entry's pictures. The words of a day and its pictures were + two tabs apart with nothing joining them, and the day is + how anyone thinks of the pictures. + */} + {e.photoIds.length > 0 && ( + + )}
))} @@ -567,6 +878,31 @@ function ContentPanel({ ) } +/** One line of the photo filter: a name, a count, and a tick on the one in force. */ +function FilterItem({ on, label, dim, count, onPick }: { + on: boolean + label: string + dim?: string + count: number + onPick: () => void +}) { + return ( + + ) +} + /** * Layouts for the current spread. * diff --git a/client/src/components/Studio/StudioTravelPanel.tsx b/client/src/components/Studio/StudioTravelPanel.tsx index d0cbf9a64..a77ec7e0e 100644 --- a/client/src/components/Studio/StudioTravelPanel.tsx +++ b/client/src/components/Studio/StudioTravelPanel.tsx @@ -1,7 +1,8 @@ -import { useEffect, useRef } from 'react' +import { useEffect, useRef, useState } from 'react' import type { BookElement, BookMetric, BookPageSetup, JourneyStats } from '@trek/shared' import { BOOK_METRICS } from '@trek/shared' import { useStudioStore } from '../../store/studioStore' +import { formatDate } from '../../utils/formatters' import { PanelHead } from './StudioPanelHead' import { TravelPreview } from './TravelPreview' import { formatBookCoords } from './entryText' @@ -38,6 +39,21 @@ import { fetchRoads } from './roadRoute' const uid = (p: string) => `${p}-${Math.random().toString(36).slice(2, 9)}` +/** + * One row of the stops list: a point of the route or a stop switched off. + * + * `entryId` is null for a stop the server took straight from a trip's places, + * which is what a journey has before anyone wrote an entry. Such a stop has no + * switch, because there is no entry to put it on. + */ +type StopRow = { + entryId: number | null + label: string + date: string | null + country: string | null + excluded: boolean +} + /* * How many cards share a row. * @@ -80,7 +96,7 @@ function Card({ label, onClick, children, recommended = false, title }: { } export function StudioTravelPanel({ - page, stats, path, t, locale, + page, stats, path, t, locale, canEdit, onToggleStop, }: { page: BookPageSetup stats: JourneyStats | null @@ -88,6 +104,10 @@ export function StudioTravelPanel({ path: [number, number][][] t: (k: string) => string locale: string + /** False for a viewer: the stops are still listed, none of them can be switched. */ + canEdit: boolean + /** Switch one stop on or off in the figures. Resolves false when it did not land. */ + onToggleStop: (entryId: number, excluded: boolean) => Promise }) { const addElement = useStudioStore(s => s.addElement) const updateElement = useStudioStore(s => s.updateElement) @@ -109,6 +129,17 @@ export function StudioTravelPanel({ roadJobs.current.clear() }, []) + /* + * The stops whose switch is on its way to the server. + * + * No optimistic flip: which stop is furthest and which countries remain is + * the server's sum, so the row reads its state from the stats the hook + * fetches once the change has landed. Until then the chip is only held, + * so a second click cannot send the opposite of what the first one is + * still delivering. + */ + const [pending, setPending] = useState>(() => new Set()) + const centre = (w: number, h: number) => { const W = single ? page.pageWidth : page.pageWidth * 2 return { x: (W - w) / 2, y: (page.pageHeight - h) / 2, w, h } @@ -183,6 +214,42 @@ export function StudioTravelPanel({ /** The linked trips that actually put stops on the route. */ const tripsWithRoute = (stats.trips ?? []).filter(t => t.points > 0) + /* + * The route as rows, counting and left-out stops together. + * + * Every point is listed, so the list reads as the route. The left-out + * stops sit among the counting ones in date order rather than in a list of + * their own, because the question the section answers is "which of these + * count?", and that is only answerable with all of them in one sequence. + * Undated stops go last, then by name. + */ + const stops: StopRow[] = [ + ...stats.points.map(p => ({ + entryId: p.entryId, label: p.label, date: p.date, country: p.country, excluded: false, + })), + ...(stats.excluded ?? []).map(x => ({ + entryId: x.entryId, label: x.label, date: x.date, country: null, excluded: true, + })), + ].sort((a, b) => { + if (a.date !== b.date) { + if (a.date == null) return 1 + if (b.date == null) return -1 + return a.date < b.date ? -1 : 1 + } + return a.label.localeCompare(b.label) + }) + + const toggleStop = async (entryId: number, excluded: boolean) => { + setPending(prev => new Set(prev).add(entryId)) + // A refusal is the hook's to report; here it only frees the chip again. + await onToggleStop(entryId, excluded) + setPending(prev => { + const next = new Set(prev) + next.delete(entryId) + return next + }) + } + /* Satellite first: the recommendation should not be the second thing read. */ const imagery = sources .filter(s => s.id === 'relief' || s.id === 'satellite') @@ -383,6 +450,52 @@ export function StudioTravelPanel({
+ {/* + The switch is here, beside the maps and the figures it changes, and + not only in the entry editor: the moment somebody notices the home + airport on the printed route is the moment they are looking at this + panel. The rows carry no preview because they are not things to + place; they are the input every tile below reads. + */} + {stops.length > 0 && ( +
+
{t('journey.studio.stops')}
+

{t('journey.studio.stopsHint')}

+
    + {stops.map((stop, i) => { + const entryId = stop.entryId + const on = !stop.excluded + const state = t(on ? 'journey.studio.stopOn' : 'journey.studio.stopOff') + const chip = `st-chip is-pick ${on ? 'is-on' : ''}` + return ( +
  • +
    + {stop.label || t('journey.studio.untitled')} + + {stop.date && {formatDate(stop.date, locale)}} + {stop.country && {countryName(stop.country)}} + +
    + {canEdit && entryId != null ? ( + + ) : ( + {state} + )} +
  • + ) + })} +
+
+ )} +
{t('journey.studio.routeMap')}
{stats.points.length ? ( diff --git a/client/src/components/Studio/studioUpload.ts b/client/src/components/Studio/studioUpload.ts new file mode 100644 index 000000000..471751603 --- /dev/null +++ b/client/src/components/Studio/studioUpload.ts @@ -0,0 +1,54 @@ +import { useJourneyStore } from '../../store/journeyStore' +import { normalizeImageFiles } from '../../utils/convertHeic' +import { isVideoFile } from '../../utils/videoPoster' +import type { UploadProgress } from '../../utils/uploadQueue' + +export interface StudioUploadOutcome { + /** trek_photos ids of what arrived, in the order it was sent. */ + photoIds: number[] + failed: number + /** Left out before anything was sent, see below. */ + skippedVideos: number +} + +export type StudioUploader = ( + files: File[], + entryId: number | null, + onProgress?: (p: UploadProgress) => void, +) => Promise + +/** + * Pictures into the journey without leaving Studio. + * + * Into an entry when one is named, into the gallery otherwise. The journey + * store is what changes either way, so the content browser sees the new + * pictures the way it sees any other; there is no second list to keep in + * step. HEIC is converted first, exactly as the journal's own upload does it. + * + * Videos are left out rather than refused. A drop that carries a clip usually + * carries pictures too, and failing the lot over one file would teach the + * wrong lesson; a book holds pictures, and the count of what was skipped goes + * back so the panel can say so. + */ +export async function uploadStudioPhotos( + journeyId: number, + files: File[], + entryId: number | null, + onProgress?: (p: UploadProgress) => void, +): Promise { + const images = files.filter(f => !isVideoFile(f)) + const skippedVideos = files.length - images.length + if (!images.length) return { photoIds: [], failed: 0, skippedVideos } + + const normalized = await normalizeImageFiles(images) + const store = useJourneyStore.getState() + const sent = entryId != null + ? await store.uploadPhotos(entryId, normalized, { onProgress }) + : await store.uploadGalleryPhotos(journeyId, normalized, { onProgress }) + + return { + photoIds: sent.succeeded.map(p => p.photo_id), + failed: sent.failed.length, + skippedVideos, + } +} diff --git a/client/src/mobile/screens/journey/MJourneyEntryCard.tsx b/client/src/mobile/screens/journey/MJourneyEntryCard.tsx index e12ac6e61..01ba2d8f1 100644 --- a/client/src/mobile/screens/journey/MJourneyEntryCard.tsx +++ b/client/src/mobile/screens/journey/MJourneyEntryCard.tsx @@ -1,4 +1,4 @@ -import { MapPin } from 'lucide-react' +import { MapPin, RouteOff } from 'lucide-react' import { useTranslation } from '../../../i18n' import { formatLocationName } from '../../../utils/formatters' import { stripMarkdown } from '../../../components/Journey/stripMarkdown' @@ -59,6 +59,13 @@ export default function MJourneyEntryCard({ entry, number, onClick }: MJourneyEn {dateLabel} + {/* Switched off the printed route (#2064). Shrinks before the date does. */} + {entry.stats_excluded && ( + + + {t('journey.entry.offRoute')} + + )} {mood && ( | null>(null) const [mood, setMood] = useState(entry.mood || '') const [weather, setWeather] = useState(entry.weather || '') + const [statsExcluded, setStatsExcluded] = useState(entry.stats_excluded ?? false) const [pros, setPros] = useState(entry.pros_cons?.pros ?? []) const [cons, setCons] = useState(entry.pros_cons?.cons ?? []) const [tags, setTags] = useState(entry.tags ?? []) @@ -144,6 +146,10 @@ export default function MJourneyEntrySheet({ ? { lat: locationLat!, lng: locationLng!, name: locationName || undefined } : null + // The route switch belongs to an entry that is a stop, or was one: an entry + // without a point was never on the route, and a new one is not on it yet. + const offersStatsToggle = entry.id > 0 && (contextLocation != null || !!entry.stats_excluded) + useEffect(() => { if (!quickCapture || readOnly || entry.location_lat != null || entry.location_lng != null) return @@ -184,6 +190,7 @@ export default function MJourneyEntrySheet({ locationName !== (entry.location_name || '') || mood !== (entry.mood || '') || weather !== (entry.weather || '') || + statsExcluded !== (entry.stats_excluded ?? false) || pros.filter(p => p.trim()).join('\n') !== (entry.pros_cons?.pros ?? []).join('\n') || cons.filter(c => c.trim()).join('\n') !== (entry.pros_cons?.cons ?? []).join('\n') || tags.join('\n') !== (entry.tags ?? []).join('\n') || @@ -207,6 +214,7 @@ export default function MJourneyEntrySheet({ location_name: locationName || null, location_lat: locationLat, location_lng: locationLng, + stats_excluded: offersStatsToggle ? statsExcluded : undefined, mood: mood || null, weather: weather || null, tags: tags.filter(tag => tag.trim()), @@ -792,6 +800,19 @@ export default function MJourneyEntrySheet({ {locationError &&
{locationError}
}
+ {/* Off the route: the same switch the desktop editor and the Studio + travel panel offer (#2064). Read-only keeps it visible but locked, + like the date and the mood above and below it. */} + {offersStatsToggle && ( +
+
+
{t('journey.editor.statsExcluded')}
+
{t('journey.editor.statsExcludedHint')}
+
+ +
+ )} + {/* Mood */} {!captureOnly && <>
{t('journey.editor.mood')}
diff --git a/client/src/pages/journeyStudio/useJourneyStudio.ts b/client/src/pages/journeyStudio/useJourneyStudio.ts index fe560577a..f120c1d0a 100644 --- a/client/src/pages/journeyStudio/useJourneyStudio.ts +++ b/client/src/pages/journeyStudio/useJourneyStudio.ts @@ -1,10 +1,12 @@ import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react' import { useLocation, useNavigate, useParams } from 'react-router' -import type { BookPageNumbers, JourneyStats } from '@trek/shared' +import type { BookElement, BookPageNumbers, JourneyStats } from '@trek/shared' import { bookPageSetupSchema } from '@trek/shared' import { journeyApi } from '../../api/client' import { useJourneyStore, type GalleryPhoto, type JourneyEntry, type JourneyPhoto } from '../../store/journeyStore' import { useStudioStore } from '../../store/studioStore' +import { useToast } from '../../components/shared/Toast' +import { uploadStudioPhotos, type StudioUploader } from '../../components/Studio/studioUpload' import { useBookStore } from '../../components/Studio/useBookStore' import { useBookPresence } from '../../components/Studio/useBookPresence' import { useTranslation } from '../../i18n' @@ -167,45 +169,54 @@ export function useJourneyStudio() { const journey = current && current.id === journeyId ? current : null /** The journey's own material, for the content browser. */ - const source = useMemo(() => ({ - entries: (journey?.entries || []) - .filter((e: JourneyEntry) => !!( - e.title || e.story || e.location_name || e.mood || e.weather - || e.pros_cons?.pros?.length || e.pros_cons?.cons?.length - // A stop that was only checked into still has two things a page can - // use: the day it happened and where on earth it was. - || e.entry_date || (e.location_lat != null && e.location_lng != null) - )) - .map((e: JourneyEntry) => ({ - id: e.id, - title: e.title ?? null, - story: e.story ?? null, - location: e.location_name ?? null, - date: e.entry_date ?? null, - lat: e.location_lat ?? null, - lng: e.location_lng ?? null, - // What the entry recorded beyond its story: how the day felt, what the - // weather did, and what was worth and not worth it. - mood: e.mood ?? null, - weather: e.weather ?? null, - pros: e.pros_cons?.pros?.filter(Boolean) ?? [], - cons: e.pros_cons?.cons?.filter(Boolean) ?? [], - })), - photos: (journey?.gallery || []).map((p: GalleryPhoto) => ({ - photoId: p.photo_id, - caption: p.caption ?? null, - })), - // Which entry a photo hangs on, so a search for a place finds its pictures + const source = useMemo(() => { + const entries = (journey?.entries || []) as JourneyEntry[] + // Which entries a picture hangs on. Usually one; the junction allows more, + // and a picture linked to two days belongs to both of their pages. + const entriesByPhoto: Record = {} + // And the words of that entry, so a search for a place finds its pictures // even though a picture carries no words of its own. - photoEntries: (() => { - const map: Record = {} - for (const e of (journey?.entries || []) as JourneyEntry[]) { - const words = [e.title, e.location_name].filter(Boolean).join(' ').toLowerCase() - for (const p of e.photos || []) map[p.photo_id] = words + const photoEntries: Record = {} + for (const e of entries) { + const words = [e.title, e.location_name].filter(Boolean).join(' ').toLowerCase() + for (const p of e.photos || []) { + ;(entriesByPhoto[p.photo_id] ||= []).push(e.id) + photoEntries[p.photo_id] = words } - return map - })(), - }), [journey]) + } + return { + entries: entries + .filter((e: JourneyEntry) => !!( + e.title || e.story || e.location_name || e.mood || e.weather + || e.pros_cons?.pros?.length || e.pros_cons?.cons?.length + // A stop that was only checked into still has two things a page can + // use: the day it happened and where on earth it was. + || e.entry_date || (e.location_lat != null && e.location_lng != null) + )) + .map((e: JourneyEntry) => ({ + id: e.id, + title: e.title ?? null, + story: e.story ?? null, + location: e.location_name ?? null, + date: e.entry_date ?? null, + lat: e.location_lat ?? null, + lng: e.location_lng ?? null, + // What the entry recorded beyond its story: how the day felt, what the + // weather did, and what was worth and not worth it. + mood: e.mood ?? null, + weather: e.weather ?? null, + pros: e.pros_cons?.pros?.filter(Boolean) ?? [], + cons: e.pros_cons?.cons?.filter(Boolean) ?? [], + photoIds: (e.photos || []).map((p: JourneyPhoto) => p.photo_id), + })), + photos: (journey?.gallery || []).map((p: GalleryPhoto) => ({ + photoId: p.photo_id, + caption: p.caption ?? null, + entryIds: entriesByPhoto[p.photo_id] ?? [], + })), + photoEntries, + } + }, [journey]) useEffect(() => { if (!Number.isFinite(journeyId)) return @@ -629,6 +640,110 @@ export function useJourneyStudio() { }, [selection, select, close, undo, redo]) + /* + * A viewer may open the book and change nothing: not the pages, not the + * journey behind them. The server refuses either anyway; this keeps the + * controls that would only ever answer with a 403 off the screen. + */ + const canEdit = (journey?.my_role ?? 'owner') !== 'viewer' + const toast = useToast() + + /** + * Switch a stop on or off, and read the figures again. + * + * The switch lives on the journal entry, so it goes through the journey + * store like any other edit and the timeline sees it too. The figures are + * fetched again rather than adjusted here: which stop is furthest, and which + * countries remain, is the server's sum to do. The frozen layout input is + * patched as well, so a relayout after the change draws the route the change + * produced. True when both landed, false (with the toast shown) when not. + */ + const setStopExcluded = useCallback(async (entryId: number, excluded: boolean): Promise => { + try { + await useJourneyStore.getState().updateEntry(entryId, { stats_excluded: excluded }) + const fresh = await journeyApi.stats(journeyId) + setStats(fresh) + if (autoInput.current) autoInput.current = { ...autoInput.current, stats: fresh } + return true + } catch { + toast.error(t('journey.studio.stopsFailed')) + return false + } + }, [journeyId, toast, t]) + + /** + * Pictures into the journey without leaving Studio. + * + * What went wrong is said here, once, so neither caller has to: the content + * browser and the page both hand files in, and both would otherwise grow the + * same three toasts. + */ + const uploadPhotos = useCallback(async (files, entryId, onProgress) => { + try { + const outcome = await uploadStudioPhotos(journeyId, files, entryId, onProgress) + if (outcome.failed > 0) toast.error(t('journey.photosUploadFailed')) + if (outcome.skippedVideos > 0) toast.info(t('journey.studio.videosSkipped', { count: outcome.skippedVideos })) + return outcome + } catch { + toast.error(t('journey.photosUploadFailed')) + return { photoIds: [], failed: files.length, skippedVideos: 0 } + } + }, [journeyId, toast, t]) + + /** How far a drop onto the page has got, for the veil over the sheet. */ + const [canvasUpload, setCanvasUpload] = useState<{ done: number; total: number } | null>(null) + + /** + * Pictures dropped straight on the page. + * + * Into the gallery, never into an entry: the page may belong to one, but a + * texture dropped as a background is not a photograph of that day, and a + * wrong guess here would show up in the journal. The content browser is + * where a picture goes into an entry on purpose. + * + * Placed where they were let go, the first into the frame under the + * pointer when there is one, the rest fanned out from the point, as a + * single undo step, so a drop of five is taken back with one press. + */ + const dropFiles = useCallback(async (files: File[], at: { x: number; y: number }, targetFrameId: string | null) => { + if (!canEdit || !files.length) return + const spreadIndex = useStudioStore.getState().activeSpread + setCanvasUpload({ done: 0, total: files.length }) + let photoIds: number[] = [] + try { + photoIds = (await uploadPhotos(files, null, p => setCanvasUpload({ done: p.done, total: p.total }))).photoIds + } finally { + setCanvasUpload(null) + } + if (!photoIds.length) return + + const placed: string[] = [] + commit(d => { + const sp = d.spreads[spreadIndex] + if (!sp) return d + const w = Math.min(d.page.pageWidth, d.page.pageHeight) * 0.5 + const h = w * 0.72 + const target = targetFrameId ? sp.elements.find(el => el.id === targetFrameId && el.kind === 'photo') : null + const filled = target + ? sp.elements.map(el => (el.id === target.id ? { ...el, photoId: photoIds[0] } : el)) + : sp.elements + if (target) placed.push(target.id) + const fresh = (target ? photoIds.slice(1) : photoIds).map((photoId, i) => ({ + id: `p-${Math.random().toString(36).slice(2, 9)}`, kind: 'photo', + frame: { x: at.x - w / 2 + i * 6, y: at.y - h / 2 + i * 6, w, h }, + rotation: 0, opacity: 1, locked: false, + photoId, fit: 'cover', focalX: 0.5, focalY: 0.5, radius: 0, filter: 'none', + mask: null, frameStyle: 'none', + } as BookElement)) + placed.push(...fresh.map(el => el.id)) + return { + ...d, + spreads: d.spreads.map((s, i) => (i === spreadIndex ? { ...s, elements: [...filled, ...fresh] } : s)), + } + }) + select(placed) + }, [canEdit, uploadPhotos, commit, select]) + const coverUrl = journey?.cover_image ? (journey.cover_image.startsWith('/uploads/') ? journey.cover_image : `/uploads/${journey.cover_image}`) : null @@ -650,6 +765,13 @@ export function useJourneyStudio() { /** The travelled way, ready to freeze into a map element. */ path, + /** Whether this user may change the book and the journey behind it. */ + canEdit, + setStopExcluded, + uploadPhotos, + dropFiles, + canvasUpload, + doc, page, preset, diff --git a/client/src/store/journeyStore.ts b/client/src/store/journeyStore.ts index bde04a5ed..2c128b52e 100644 --- a/client/src/store/journeyStore.ts +++ b/client/src/store/journeyStore.ts @@ -46,6 +46,9 @@ export interface JourneyEntry { pros_cons?: { pros: string[]; cons: string[] } | null visibility: string sort_order: number + // 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 photos: JourneyPhoto[] created_at: number updated_at: number @@ -120,6 +123,7 @@ export interface JourneyDetail extends Journey { contributors: JourneyContributor[] stats: { entries: number; photos: number; places: number } hide_skeletons?: boolean + my_role?: 'owner' | 'editor' | 'viewer' } interface JourneyState { @@ -263,9 +267,24 @@ export const useJourneyStore = create((set, get) => ({ fd.append('photos', file) const data = await journeyApi.uploadPhotos(entryId, fd, opts) const photos: JourneyPhoto[] = data.photos || [] - const gallery: GalleryPhoto[] = data.gallery || [] set(s => { if (!s.current) return s + const journeyId = s.current.id + const known = new Set((s.current.gallery || []).map(p => p.id)) + /* + * A picture put on an entry is in the gallery too, because that is + * where the row lives: the entry only holds a link to it. The route + * answers with the entry's view of the row and, when it has one, the + * gallery's; the derived rows below are for the case where it does + * not, so that a picture added here shows up wherever the gallery is + * read without waiting for the journey to be fetched again. + */ + const gallery: GalleryPhoto[] = [ + ...(data.gallery || []), + ...photos + .filter(p => !known.has(p.id) && !(data.gallery || []).some(g => g.id === p.id)) + .map(({ entry_id: _entry, ...rest }) => ({ ...rest, journey_id: journeyId })), + ] return { current: { ...s.current, diff --git a/client/src/styles/studio.css b/client/src/styles/studio.css index 5159bead9..f69be9b15 100644 --- a/client/src/styles/studio.css +++ b/client/src/styles/studio.css @@ -1929,3 +1929,236 @@ margin: 0 2px; background: var(--line-2); } + +/* stops */ + +/* + * The route as rows, in the Travel panel. + * + * Not tiles: a stop is not something to place, it is the input every map and + * figure below reads. A quiet row with the name, the day and the country, and + * the switch on the right, so the eye runs down the switches to find the one + * that is off. A left-out row is dimmed as a whole rather than struck through: + * it is still a day of the journal, just not a leg of the route. + */ +.trek-dash .st-stops { + display: flex; + flex-direction: column; + gap: 4px; + margin: 4px 0 0; + padding: 0; + list-style: none; +} +.trek-dash .st-stop { + display: flex; + align-items: center; + gap: 8px; + min-width: 0; + padding: 7px 8px 7px 10px; + border-radius: var(--r-sm); + background: color-mix(in oklch, var(--surface) 70%, transparent); + border: 1px solid var(--line); + transition: opacity .16s; +} +.trek-dash .st-stop.is-off { opacity: .55; } +.trek-dash .st-stop-body { flex: 1 1 auto; min-width: 0; } +.trek-dash .st-stop-label { + display: block; + font-size: 12px; + font-weight: 600; + letter-spacing: -0.01em; + color: var(--ink); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} +.trek-dash .st-stop-meta { display: flex; align-items: center; gap: 5px; margin-top: 3px; min-width: 0; } +/* The day keeps its width, as on the entry cards; the country gives way. */ +.trek-dash .st-stop-meta > .st-badge { flex: none; } +.trek-dash .st-stop-meta > .st-badge.is-quiet { flex: 0 1 auto; } +.trek-dash .st-stop > .st-chip { flex: none; height: 24px; font-size: 11px; } +/* The same chip as a plain label: a viewer's, or a stop with no entry to switch. */ +.trek-dash .st-stop > span.st-chip { pointer-events: none; } + +/* content filter + upload */ +/* + * Which pictures the browser shows, and the way pictures get in (#2064). + * + * The filter chip is a `.st-chip` with two buttons inside it: the name, which + * opens the popover, and the cross, which clears. One button would have to be + * both, and a chip whose whole face means "open" cannot also mean "off". The + * popover is the bar's format menu, hung from the left edge instead of the + * right: the side panel is 236px wide and the menu would otherwise open into + * the rail. + */ +/* The row the filter sits in. It takes the panel's full width, so the entry + currently in force reads as a heading over the grid rather than as one + control among several, and a long entry name has somewhere to go. The + quick chip wraps under it. */ +.trek-dash .st-filter-row { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 5px; + padding: 0 12px 9px; + flex: none; + min-width: 0; +} +.trek-dash .st-filter-row .st-picker { flex: 1 0 100%; min-width: 0; } +.trek-dash .st-filter-chip { + display: flex; + width: 100%; + padding: 0; + min-width: 0; +} +.trek-dash .st-filter-chip > button { + display: inline-flex; + align-items: center; + gap: 5px; + height: 100%; + min-width: 0; + padding: 0 9px 0 10px; + border-radius: inherit; + color: inherit; +} +/* The name takes the slack; the count and the chevron stay at the right edge. */ +.trek-dash .st-filter-chip > button:first-child { flex: 1; justify-content: flex-start; } +.trek-dash .st-filter-chip > button + button { padding: 0 8px 0 3px; margin-left: -5px; } +.trek-dash .st-filter-chip > button:hover { background: color-mix(in oklch, var(--ink) 7%, transparent); } +.trek-dash .st-filter-chip.is-on > button:hover { background: color-mix(in oklch, var(--accent-text, #fff) 14%, transparent); } +.trek-dash .st-filter-name { + flex: 1; + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + text-align: left; +} +/* The count, set like the one in the tabs: a footnote, not part of the name. */ +.trek-dash .st-filter-chip em { + font-style: normal; + font-size: 10.5px; + font-weight: 600; + font-variant-numeric: tabular-nums; + opacity: .7; +} +/* Hung under the chip and as wide as it: a menu narrower than the control that + opens it reads as belonging to something else. */ +.trek-dash .st-filter-row .st-menu { + left: 0; + right: 0; + min-width: 0; + max-width: none; +} +.trek-dash .st-filter-row .st-menu-item { padding: 7px 10px; gap: 8px; } +.trek-dash .st-filter-row .st-menu-name, +.trek-dash .st-filter-row .st-menu-dim { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} +.trek-dash .st-filter-count { flex: none; } + +/* Where the next pictures go. Under the filter because the filter decides it. */ +.trek-dash .st-upload-where { padding: 0 2px 8px; font-size: 11.5px; } + +/* + * The upload cell: the first square of the grid, drawn as an outline so it + * reads as a place for a picture rather than as one. Disabled while a batch + * is on its way, in the same grey, because a target that goes lighter looks + * like it has stopped taking files rather than like it is busy. + */ +.trek-dash .st-photo-cell.is-upload { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 5px; + padding: 6px; + background: transparent; + border: 1px dashed var(--line-2); + color: var(--ink-3); + font-size: 10.5px; + font-weight: 600; + line-height: 1.25; + text-align: center; + transition: border-color .16s, color .16s, background .16s, transform .14s ease-out, box-shadow .14s; +} +.trek-dash .st-photo-cell.is-upload span { + max-width: 100%; + overflow: hidden; + text-overflow: ellipsis; + font-variant-numeric: tabular-nums; +} +.trek-dash .st-photo-cell.is-upload:hover:not(:disabled) { + border-color: var(--accent); + color: var(--accent); + background: color-mix(in oklch, var(--accent) 6%, transparent); +} +.trek-dash .st-photo-cell.is-upload:disabled { + cursor: default; + transform: none; + box-shadow: none; +} + +/* A file drag over the panel: an inner frame, and the cell lights with it. */ +.trek-dash .st-panel-scroll.is-drop-over { + box-shadow: inset 0 0 0 2px var(--accent); + border-radius: var(--r-sm); +} +.trek-dash .st-panel-scroll.is-drop-over .st-photo-cell.is-upload { + border-color: var(--accent); + color: var(--accent); +} + +/* + * Pictures dropped on the sheet, on their way. A pill above the zoom bar + * rather than a veil over the page: the page keeps rendering, and the only + * question in those two seconds is whether the drop was taken. + */ +.trek-dash .st-upload-veil { + position: absolute; + left: 50%; + bottom: 70px; + z-index: 6; + transform: translateX(-50%); + padding: 7px 14px; + border-radius: 999px; + background: var(--surface); + border: 1px solid var(--line-2); + box-shadow: var(--sh-md); + color: var(--ink-2); + font-size: 12px; + font-weight: 500; + font-variant-numeric: tabular-nums; + white-space: nowrap; + pointer-events: none; +} + +/* + * A file drag over the stage. Over the whole stage rather than inside the + * scaled sheet, so the label is one size at every zoom, and above the handles + * and the peers' pointers, none of which matter while a file is in the air. + */ +.trek-dash .st-file-veil { + position: absolute; + inset: 0; + z-index: 9; + display: grid; + place-items: center; + border: 2px dashed var(--accent); + border-radius: 4px; + background: color-mix(in oklch, var(--accent) 10%, transparent); + pointer-events: none; +} +.trek-dash .st-file-veil span { + padding: 8px 16px; + border-radius: 999px; + background: var(--surface); + border: 1px solid var(--line-2); + box-shadow: var(--sh-md); + color: var(--ink); + font-size: 12.5px; + font-weight: 600; + white-space: nowrap; +} diff --git a/client/tests/unit/mobile/journey/MJourneyEntryCard.test.tsx b/client/tests/unit/mobile/journey/MJourneyEntryCard.test.tsx index fa4eee899..a23868d33 100644 --- a/client/tests/unit/mobile/journey/MJourneyEntryCard.test.tsx +++ b/client/tests/unit/mobile/journey/MJourneyEntryCard.test.tsx @@ -43,4 +43,15 @@ describe('MJourneyEntryCard', () => { 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 a6c878221..cd52c918a 100644 --- a/client/tests/unit/mobile/journey/MJourneyEntrySheet.test.tsx +++ b/client/tests/unit/mobile/journey/MJourneyEntrySheet.test.tsx @@ -935,6 +935,52 @@ describe('MJourneyEntrySheet read-only', () => { }); }); +// FE-MOB-JENTRY-051 to FE-MOB-JENTRY-054: leaving a stop off the printed route +// (#2064), the phone sheet's half of the switch the desktop editor has. + +describe('MJourneyEntrySheet route switch', () => { + it('FE-MOB-JENTRY-051: offers to leave a located entry out of the route and saves it', async () => { + const user = userEvent.setup(); + const { onSave } = mountSheet(buildEntry({ + id: 5, location_name: 'Keflavík', location_lat: 63.98, location_lng: -22.6, + })); + + const toggle = screen.getByRole('switch', { name: 'Leave out of the route' }); + expect(toggle).toHaveAttribute('aria-checked', 'false'); + await user.click(toggle); + expect(toggle).toHaveAttribute('aria-checked', 'true'); + await user.click(screen.getByRole('button', { name: 'Save' })); + + await waitFor(() => expect(onSave).toHaveBeenCalled()); + expect(onSave.mock.calls[0][0]).toMatchObject({ stats_excluded: true }); + }); + + it('FE-MOB-JENTRY-052: keeps the switch away from a new entry, point or no point', () => { + mountSheet(buildEntry({ location_lat: 63.98, location_lng: -22.6 })); + + expect(screen.queryByRole('switch', { name: 'Leave out of the route' })).not.toBeInTheDocument(); + }); + + it('FE-MOB-JENTRY-053: keeps the switch away from an entry without a point, and sends nothing for it', async () => { + const user = userEvent.setup(); + const { onSave } = mountSheet(buildEntry({ id: 5 })); + + expect(screen.queryByRole('switch', { name: 'Leave out of the route' })).not.toBeInTheDocument(); + await user.click(screen.getByRole('button', { name: 'Save' })); + + await waitFor(() => expect(onSave).toHaveBeenCalled()); + expect(onSave.mock.calls[0][0].stats_excluded).toBeUndefined(); + }); + + it('FE-MOB-JENTRY-054: shows a left-out entry\'s switch to a viewer, locked', () => { + mountSheet(buildEntry({ id: 5, stats_excluded: true }), { readOnly: true }); + + const toggle = screen.getByRole('switch', { name: 'Leave out of the route' }); + expect(toggle).toHaveAttribute('aria-checked', 'true'); + expect(toggle).toBeDisabled(); + }); +}); + describe('MJourneyEntrySheet quick capture edge cases', () => { afterEach(() => { vi.restoreAllMocks(); diff --git a/client/tests/unit/studio/autoLayout.contract.test.ts b/client/tests/unit/studio/autoLayout.contract.test.ts index ca417bcc2..a9186d564 100644 --- a/client/tests/unit/studio/autoLayout.contract.test.ts +++ b/client/tests/unit/studio/autoLayout.contract.test.ts @@ -28,10 +28,10 @@ const stats = (over: Partial = {}): JourneyStats => ({ journeyId: 6, distance: 1_189_000, days: 14, steps: 14, photos: 57, places: 21, furthest: 408_000, countries: [{ code: 'IS', name: 'Iceland', places: 14, firstVisit: '2026-06-02' }], points: [ - { lat: 64.14, lng: -21.94, label: 'Reykjavík', date: '2026-06-02', country: 'IS', tripId: null, photoId: null }, - { lat: 65.68, lng: -18.12, label: 'Akureyri', date: '2026-06-06', country: 'IS', tripId: null, photoId: null }, + { lat: 64.14, lng: -21.94, label: 'Reykjavík', date: '2026-06-02', country: 'IS', tripId: null, photoId: null, entryId: null }, + { lat: 65.68, lng: -18.12, label: 'Akureyri', date: '2026-06-06', country: 'IS', tripId: null, photoId: null, entryId: null }, ], - trips: [], start: '2026-06-02', end: '2026-06-15', + trips: [], excluded: [], start: '2026-06-02', end: '2026-06-15', ...over, }) @@ -56,8 +56,8 @@ describe('a laid-out book is a book the server will take', () => { it('when a stop carries a place name longer than a map label may print', () => { const points = [ - { lat: 64.14, lng: -21.94, label: long(300), date: '2026-06-02', country: 'IS', tripId: null, photoId: null }, - { lat: 65.68, lng: -18.12, label: 'Akureyri', date: '2026-06-06', country: 'IS', tripId: null, photoId: null }, + { lat: 64.14, lng: -21.94, label: long(300), date: '2026-06-02', country: 'IS', tripId: null, photoId: null, entryId: null }, + { lat: 65.68, lng: -18.12, label: 'Akureyri', date: '2026-06-06', country: 'IS', tripId: null, photoId: null, entryId: null }, ] expect(savable(buildBook(input({ stats: stats({ points }) })))).toEqual({ ok: true }) }) diff --git a/client/tests/unit/studio/autoLayout.travel.test.ts b/client/tests/unit/studio/autoLayout.travel.test.ts index f369fa26b..194d6c20e 100644 --- a/client/tests/unit/studio/autoLayout.travel.test.ts +++ b/client/tests/unit/studio/autoLayout.travel.test.ts @@ -39,10 +39,11 @@ const stats = (over: Partial = {}): JourneyStats => ({ furthest: 408_000, countries: [{ code: 'IS', name: 'Iceland', places: 14, firstVisit: '2026-06-02' }], points: [ - { lat: 64.14, lng: -21.94, label: 'Reykjavík', date: '2026-06-02', country: 'IS', tripId: null, photoId: null }, - { lat: 65.68, lng: -18.12, label: 'Akureyri', date: '2026-06-06', country: 'IS', tripId: null, photoId: null }, + { lat: 64.14, lng: -21.94, label: 'Reykjavík', date: '2026-06-02', country: 'IS', tripId: null, photoId: null, entryId: null }, + { lat: 65.68, lng: -18.12, label: 'Akureyri', date: '2026-06-06', country: 'IS', tripId: null, photoId: null, entryId: null }, ], trips: [], + excluded: [], start: '2026-06-02', end: '2026-06-15', ...over, diff --git a/client/tests/unit/studio/contentPhotos.test.tsx b/client/tests/unit/studio/contentPhotos.test.tsx new file mode 100644 index 000000000..71ab1211a --- /dev/null +++ b/client/tests/unit/studio/contentPhotos.test.tsx @@ -0,0 +1,263 @@ +import { describe, it, expect, beforeEach, vi } from 'vitest' +import type { BookDocument, BookPageSetup } from '@trek/shared' +import { bookPageSetupSchema, normalizeBookDocument } from '@trek/shared' +import { fireEvent, render, screen, waitFor, within } from '../../helpers/render' +import { StudioSidebar, type JourneySource } from '../../../src/components/Studio/StudioSidebar' +import type { StudioUploader } from '../../../src/components/Studio/studioUpload' +import { useStudioStore } from '../../../src/store/studioStore' + +/** + * Content > Photos, narrowed to a day, and the upload cell (#2064). + * + * A journey of three hundred pictures showed them as one grid, so laying out + * the page for one day meant scrolling past every other day to find its + * pictures. What these check is that the browser can be narrowed to one + * entry, to the pictures no entry claims, and to what just arrived; that the + * search still applies on top; and that a picture uploaded here lands where + * the panel says it will, the entry the filter names or the gallery when it + * names none. + */ + +const page: BookPageSetup = bookPageSetupSchema.parse({ + preset: 'square-210', pageWidth: 210, pageHeight: 210, bleed: 3, safe: 5, +}) + +type Entry = JourneySource['entries'][number] + +const entry = (over: Partial): Entry => ({ + id: 1, title: 'Reykjavik', story: null, location: null, date: '2026-06-01', + lat: null, lng: null, mood: null, weather: null, pros: [], cons: [], photoIds: [], + ...over, +}) + +/** Two days with pictures, one written day without, and one picture only the gallery holds. */ +const source: JourneySource = { + entries: [ + entry({ id: 1, title: 'Reykjavik', date: '2026-06-01', photoIds: [11, 12] }), + entry({ id: 2, title: 'Vik', location: 'Vik i Myrdal', date: '2026-06-02', photoIds: [13] }), + entry({ id: 3, title: 'A written day', date: '2026-06-03' }), + ], + photos: [ + { photoId: 11, caption: null, entryIds: [1] }, + { photoId: 12, caption: 'Harbour', entryIds: [1] }, + { photoId: 13, caption: null, entryIds: [2] }, + { photoId: 14, caption: 'Loose', entryIds: [] }, + ], + photoEntries: { 11: 'reykjavik', 12: 'reykjavik', 13: 'vik vik i myrdal' }, +} + +/** One inner spread, tied to a day or to none. */ +const book = (entryId: number | null = null): BookDocument => normalizeBookDocument({ + version: 1, + title: 'Iceland', + page: { preset: 'square-210', pageWidth: 210, pageHeight: 210, bleed: 3, safe: 5 }, + spreads: [{ id: 'sp1', role: 'inner', entryId, background: null, elements: [], parked: [] }], +}) + +const nothing: StudioUploader = async () => ({ photoIds: [], failed: 0, skippedVideos: 0 }) + +beforeEach(() => { + useStudioStore.getState().load(book()) +}) + +/** Render the sidebar and open Content, which lands on the Photos tab. */ +function openContent(src = source, over: { canEdit?: boolean; onUpload?: StudioUploader } = {}) { + const view = render( + k} + locale="en-US" + canEdit={over.canEdit ?? true} + onUpload={over.onUpload ?? nothing} + onToggleStop={async () => true} + />, + ) + fireEvent.click(screen.getByLabelText('journey.studio.content')) + return view +} + +/** The pictures the grid shows now, by id, in grid order. */ +const shown = () => [...document.querySelectorAll('.st-photo-cell:not(.is-upload) img')] + .map(img => Number(img.getAttribute('src')!.match(/\/photos\/(\d+)\//)![1])) + +const openMenu = () => fireEvent.click(screen.getByTitle('journey.studio.filterPhotos')) + +const chipLabel = () => document.querySelector('.st-filter-name')!.textContent + +const picture = (name: string) => new File(['x'], name, { type: 'image/jpeg' }) + +describe('the filter menu', () => { + it('lists every entry that has pictures, with how many', () => { + openContent() + openMenu() + + const items = screen.getAllByRole('menuitemradio').map(b => b.textContent) + expect(items[0]).toContain('journey.studio.filterAll') + expect(items[0]).toContain('4') + expect(items[1]).toContain('journey.studio.filterLoose') + expect(items[1]).toContain('1') + expect(items.find(s => s!.includes('Reykjavik'))).toContain('2') + expect(items.find(s => s!.includes('Vik i Myrdal'))).toContain('1') + // A day with words and no pictures is not a filter worth offering. + expect(items.some(s => s!.includes('A written day'))).toBe(false) + }) + + it('narrows the grid to one day, and the cross widens it again', () => { + openContent() + openMenu() + fireEvent.click(screen.getByRole('menuitemradio', { name: /Reykjavik/ })) + + expect(shown()).toEqual([11, 12]) + expect(chipLabel()).toBe('Reykjavik') + + fireEvent.click(screen.getByLabelText('common.clear')) + + expect(shown()).toEqual([11, 12, 13, 14]) + expect(chipLabel()).toBe('journey.studio.filterAll') + }) + + it('shows only the pictures no entry claims', () => { + openContent() + openMenu() + fireEvent.click(screen.getByRole('menuitemradio', { name: /journey\.studio\.filterLoose/ })) + + expect(shown()).toEqual([14]) + }) + + it('combines with the search rather than replacing it', () => { + openContent() + openMenu() + fireEvent.click(screen.getByRole('menuitemradio', { name: /Reykjavik/ })) + fireEvent.change(screen.getByLabelText('journey.studio.searchContent'), { target: { value: 'harbour' } }) + + expect(shown()).toEqual([12]) + }) + + /* Four reasons for an empty grid, each said in its own words. */ + it('says why the grid is empty', () => { + openContent() + openMenu() + fireEvent.click(screen.getByRole('menuitemradio', { name: /Reykjavik/ })) + fireEvent.change(screen.getByLabelText('journey.studio.searchContent'), { target: { value: 'myrdal' } }) + expect(screen.getByText('journey.studio.noMatches')).toBeInTheDocument() + }) + + it('says so when every picture belongs to a day', () => { + openContent({ ...source, photos: source.photos.filter(p => p.entryIds.length) }) + openMenu() + fireEvent.click(screen.getByRole('menuitemradio', { name: /journey\.studio\.filterLoose/ })) + + expect(shown()).toEqual([]) + expect(screen.getByText('journey.studio.noLoosePhotos')).toBeInTheDocument() + }) +}) + +describe('the way in from an entry', () => { + it('opens the pictures of the day from its card', () => { + openContent() + fireEvent.click(screen.getByRole('button', { name: /journey\.studio\.entries/ })) + + const card = screen.getByText('Reykjavik').closest('.st-entry') as HTMLElement + fireEvent.click(within(card).getByText('journey.studio.entryPhotos')) + + expect(shown()).toEqual([11, 12]) + expect(chipLabel()).toBe('Reykjavik') + }) + + it('offers no such chip on a day without pictures', () => { + openContent() + fireEvent.click(screen.getByRole('button', { name: /journey\.studio\.entries/ })) + + const card = screen.getByText('A written day').closest('.st-entry') as HTMLElement + expect(within(card).queryByText('journey.studio.entryPhotos')).toBeNull() + }) + + it('offers the page\'s own day while the filter is not on it', () => { + useStudioStore.getState().load(book(1)) + openContent() + + fireEvent.click(screen.getByText('journey.studio.filterThisPage')) + + expect(shown()).toEqual([11, 12]) + expect(screen.queryByText('journey.studio.filterThisPage')).toBeNull() + }) + + it('offers nothing for a page tied to a day without pictures', () => { + useStudioStore.getState().load(book(3)) + openContent() + expect(screen.queryByText('journey.studio.filterThisPage')).toBeNull() + }) +}) + +describe('the upload cell', () => { + it('is not there for a viewer', () => { + openContent(source, { canEdit: false }) + + expect(document.querySelector('.st-photo-cell.is-upload')).toBeNull() + expect(document.querySelector('input[type="file"]')).toBeNull() + expect(screen.queryByText('journey.studio.uploadToGallery')).toBeNull() + }) + + it('sends into the gallery when no day is chosen', async () => { + const onUpload = vi.fn(nothing) + openContent(source, { onUpload }) + expect(screen.getByText('journey.studio.uploadToGallery')).toBeInTheDocument() + + const file = picture('a.jpg') + fireEvent.change(document.querySelector('input[type="file"]')!, { target: { files: [file] } }) + + await waitFor(() => expect(onUpload).toHaveBeenCalledWith([file], null, expect.any(Function))) + }) + + it('sends into the day the filter names', async () => { + const onUpload = vi.fn(nothing) + openContent(source, { onUpload }) + openMenu() + fireEvent.click(screen.getByRole('menuitemradio', { name: /Reykjavik/ })) + expect(screen.getByText('journey.studio.uploadToEntry')).toBeInTheDocument() + + const file = picture('b.jpg') + fireEvent.change(document.querySelector('input[type="file"]')!, { target: { files: [file] } }) + + await waitFor(() => expect(onUpload).toHaveBeenCalledWith([file], 1, expect.any(Function))) + }) + + /* + * The gallery sorts what arrives to its end, out of sight under three + * hundred pictures; the filter is what brings it into view. The source + * here already holds the new id, as the store would once the upload landed. + */ + it('shows what just arrived once it has', async () => { + const onUpload = vi.fn(async () => ({ photoIds: [99], failed: 0, skippedVideos: 0 })) + const arrived: JourneySource = { + ...source, + photos: [...source.photos, { photoId: 99, caption: null, entryIds: [] }], + } + openContent(arrived, { onUpload }) + fireEvent.change(screen.getByLabelText('journey.studio.searchContent'), { target: { value: 'harbour' } }) + + fireEvent.change(document.querySelector('input[type="file"]')!, { target: { files: [picture('c.jpg')] } }) + + await waitFor(() => expect(chipLabel()).toBe('journey.studio.filterRecent')) + expect(shown()).toEqual([99]) + // The search is let go of too, or the new picture would be hidden by it. + expect((screen.getByLabelText('journey.studio.searchContent') as HTMLInputElement).value).toBe('') + }) + + it('takes a drop anywhere on the panel', async () => { + const onUpload = vi.fn(nothing) + openContent(source, { onUpload }) + + const file = picture('d.jpg') + fireEvent.drop(document.querySelector('.st-panel-scroll')!, { + dataTransfer: { files: [file], types: ['Files'] }, + }) + + await waitFor(() => expect(onUpload).toHaveBeenCalledWith([file], null, expect.any(Function))) + }) +}) diff --git a/client/tests/unit/studio/entryChips.test.tsx b/client/tests/unit/studio/entryChips.test.tsx index 6c4afec59..d8a1f02e0 100644 --- a/client/tests/unit/studio/entryChips.test.tsx +++ b/client/tests/unit/studio/entryChips.test.tsx @@ -3,6 +3,7 @@ import type { BookDocument, BookPageSetup, BookTextElement } from '@trek/shared' import { bookPageSetupSchema, normalizeBookDocument } from '@trek/shared' import { fireEvent, render, screen } from '../../helpers/render' import { StudioSidebar, type JourneySource } from '../../../src/components/Studio/StudioSidebar' +import type { StudioUploader } from '../../../src/components/Studio/studioUpload' import { useStudioStore } from '../../../src/store/studioStore' /** @@ -39,11 +40,15 @@ const entry = (over: Partial = {}): Entry => ({ weather: null, pros: [], cons: [], + photoIds: [], ...over, }) const sourceOf = (...entries: Entry[]): JourneySource => ({ entries, photos: [], photoEntries: {} }) +/** Nothing ever arrives through these; the chips under test place text. */ +const noUpload: StudioUploader = async () => ({ photoIds: [], failed: 0, skippedVideos: 0 }) + /** One empty inner spread, parsed rather than cast, so the store holds a real document. */ const emptyBook = (): BookDocument => normalizeBookDocument({ version: 1, @@ -74,6 +79,9 @@ function openEntries(source: JourneySource) { path={[]} t={(k: string) => k} locale="en-US" + canEdit + onUpload={noUpload} + onToggleStop={async () => true} />, ) fireEvent.click(screen.getByLabelText('journey.studio.content')) diff --git a/client/tests/unit/studio/inspector.test.tsx b/client/tests/unit/studio/inspector.test.tsx index 1a8cee512..f56f0ac1f 100644 --- a/client/tests/unit/studio/inspector.test.tsx +++ b/client/tests/unit/studio/inspector.test.tsx @@ -48,7 +48,7 @@ const setup = (folios: Partial = {}): BookPageSetup => bookPage const source: JourneySource = { entries: [{ id: 7, title: 'Bergen', story: null, location: 'Bergen', date: '2026-05-01', - lat: 60.3913, lng: 5.3221, mood: null, weather: null, pros: [], cons: [], + lat: 60.3913, lng: 5.3221, mood: null, weather: null, pros: [], cons: [], photoIds: [], }], photos: [], photoEntries: {}, @@ -337,6 +337,81 @@ describe('a photo frame', () => { }) }) +/* + * The picture as the page (#2064). What is pinned is the geometry: the frame + * must reach the bleed on all four sides, or the trimmed page shows a white + * hairline, and the picture must end up behind everything else, or it covers + * the page it was meant to carry. + */ +describe('filling a page with a picture', () => { + /** The chips live in Arrange, which starts folded like the frame's numbers. */ + function fillChip(c: HTMLElement, label: string): HTMLElement | undefined { + open(c, 'journey.studio.arrange') + return [...c.querySelectorAll('.st-chip')].find(b => b.textContent === label) as HTMLElement | undefined + } + + it('reaches the bleed on every side and goes behind everything else', () => { + load([shape(), photo({ photoId: 12, rotation: 15, fit: 'contain' })], ['p1']) + const { container } = draw() + + fireEvent.click(fillChip(container, 'journey.studio.fillPage')!) + + expect(elements().map(e => e.id)).toEqual(['p1', 's1']) + expect(el(0)).toMatchObject({ + frame: { x: -3, y: -3, w: 216, h: 216 }, rotation: 0, fit: 'cover', + }) + }) + + /* The frame, the crop and the move to the back are one press, so they are + one undo; three steps for one press would take three presses back. */ + it('is a single undo step', () => { + load([shape(), photo({ photoId: 12 })], ['p1']) + const { container } = draw() + + fireEvent.click(fillChip(container, 'journey.studio.fillPage')!) + + expect(useStudioStore.getState().past).toHaveLength(1) + }) + + it('fills the right-hand page when the frame sits on it', () => { + load([photo({ photoId: 12, frame: { x: 300, y: 30, w: 60, h: 40 } })]) + const { container } = draw() + + fireEvent.click(fillChip(container, 'journey.studio.fillPage')!) + + expect(el().frame).toEqual({ x: 207, y: -3, w: 216, h: 216 }) + }) + + it('fills both pages when asked for the spread', () => { + load([photo({ photoId: 12, frame: { x: 300, y: 30, w: 60, h: 40 } })]) + const { container } = draw() + + fireEvent.click(fillChip(container, 'journey.studio.fillSpread')!) + + expect(el().frame).toEqual({ x: -3, y: -3, w: 426, h: 216 }) + }) + + it('offers no spread on a cover, which is one page', () => { + useStudioStore.getState().load({ + version: 1, title: 'T', page: setup(), spreads: [ + { id: 'sp1', role: 'cover', background: null, elements: [photo({ photoId: 12 })], parked: [], entryId: null }, + ], + } as BookDocument) + useStudioStore.getState().select(['p1']) + const { container } = draw() + + expect(fillChip(container, 'journey.studio.fillSpread')).toBeUndefined() + fireEvent.click(fillChip(container, 'journey.studio.fillPage')!) + expect(el().frame).toEqual({ x: -3, y: -3, w: 216, h: 216 }) + }) + + it('is not offered for anything but a picture', () => { + load([shape()]) + const { container } = draw() + expect(fillChip(container, 'journey.studio.fillPage')).toBeUndefined() + }) +}) + // ── Shape ────────────────────────────────────────────────────────────────── describe('a shape', () => { diff --git a/client/tests/unit/studio/mapRoads.test.tsx b/client/tests/unit/studio/mapRoads.test.tsx index 86016242e..2c1dc02c9 100644 --- a/client/tests/unit/studio/mapRoads.test.tsx +++ b/client/tests/unit/studio/mapRoads.test.tsx @@ -246,7 +246,10 @@ describe('placing a map from the panel', () => { }) const { getByTitle, unmount } = render( - k} locale="en" />, + k} locale="en" + canEdit={false} onToggleStop={async () => true} + />, ) fireEvent.click(getByTitle('journey.studio.mapStyle.minimal')) await vi.waitFor(() => expect(calculateRouteWithLegs).toHaveBeenCalled()) diff --git a/client/tests/unit/studio/mapScope.test.ts b/client/tests/unit/studio/mapScope.test.ts index 88ef1e3c0..153a4ea59 100644 --- a/client/tests/unit/studio/mapScope.test.ts +++ b/client/tests/unit/studio/mapScope.test.ts @@ -33,15 +33,16 @@ const stats: JourneyStats = { { code: 'PT', name: 'Portugal', places: 2, firstVisit: '2026-09-04' }, ], points: [ - { lat: 64.14, lng: -21.94, label: 'Reykjavík', date: '2026-06-02', country: 'IS', tripId: ICELAND, photoId: null }, - { lat: 65.68, lng: -18.12, label: 'Akureyri', date: '2026-06-06', country: 'IS', tripId: ICELAND, photoId: null }, - { lat: 38.72, lng: -9.14, label: 'Lisboa', date: '2026-09-04', country: 'PT', tripId: PORTUGAL, photoId: null }, - { lat: 41.15, lng: -8.61, label: 'Porto', date: '2026-09-08', country: 'PT', tripId: PORTUGAL, photoId: null }, + { lat: 64.14, lng: -21.94, label: 'Reykjavík', date: '2026-06-02', country: 'IS', tripId: ICELAND, photoId: null, entryId: null }, + { lat: 65.68, lng: -18.12, label: 'Akureyri', date: '2026-06-06', country: 'IS', tripId: ICELAND, photoId: null, entryId: null }, + { lat: 38.72, lng: -9.14, label: 'Lisboa', date: '2026-09-04', country: 'PT', tripId: PORTUGAL, photoId: null, entryId: null }, + { lat: 41.15, lng: -8.61, label: 'Porto', date: '2026-09-08', country: 'PT', tripId: PORTUGAL, photoId: null, entryId: null }, ], trips: [ { id: ICELAND, title: 'Iceland', start: '2026-06-02', end: '2026-06-14', points: 2 }, { id: PORTUGAL, title: 'Portugal', start: '2026-09-04', end: '2026-09-12', points: 2 }, ], + excluded: [], start: '2026-06-02', end: '2026-09-12', } diff --git a/client/tests/unit/studio/travelRefresh.test.ts b/client/tests/unit/studio/travelRefresh.test.ts index 9133054b3..815c62650 100644 --- a/client/tests/unit/studio/travelRefresh.test.ts +++ b/client/tests/unit/studio/travelRefresh.test.ts @@ -23,10 +23,11 @@ const stats: JourneyStats = { { code: 'IS', name: 'Iceland', places: 14, firstVisit: '2026-06-02' }, ], points: [ - { lat: 64.14, lng: -21.94, label: 'Reykjavík', date: '2026-06-02', country: 'IS', tripId: null, photoId: null }, - { lat: 65.68, lng: -18.12, label: 'Akureyri', date: '2026-06-06', country: 'IS', tripId: null, photoId: null }, + { lat: 64.14, lng: -21.94, label: 'Reykjavík', date: '2026-06-02', country: 'IS', tripId: null, photoId: null, entryId: null }, + { lat: 65.68, lng: -18.12, label: 'Akureyri', date: '2026-06-06', country: 'IS', tripId: null, photoId: null, entryId: null }, ], trips: [], + excluded: [], start: '2026-06-02', end: '2026-06-15', } diff --git a/client/tests/unit/studio/travelStops.test.tsx b/client/tests/unit/studio/travelStops.test.tsx new file mode 100644 index 000000000..1829914ba --- /dev/null +++ b/client/tests/unit/studio/travelStops.test.tsx @@ -0,0 +1,167 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import type { BookDocument, BookPageSetup, JourneyStats, JourneyStatsPoint } from '@trek/shared' +import { bookPageSetupSchema } from '@trek/shared' +import { act, fireEvent, render, screen } from '../../helpers/render' +import { StudioTravelPanel } from '../../../src/components/Studio/StudioTravelPanel' +import { useStudioStore } from '../../../src/store/studioStore' + +/** + * Switching a stop off the route, from the Travel panel (#2064). + * + * The home airport is the case: written up as an entry so the journal has a + * first day, it lands on the printed map and adds a leg to the distance that + * nobody thinks of as the trip. The panel lists every stop with a switch, and + * what these pin is the part that would rot quietly: the left-out stop stays + * in the list among the others, in date order, because the only way to put a + * stop back is to be able to see it; the switch asks for exactly the opposite + * of what the row shows; and a viewer, or a stop the server took from a trip + * place rather than from an entry, gets the label and no button. + * + * Nothing here is optimistic. The row reads the stats it was given, and the + * hook behind `onToggleStop` fetches fresh ones; the only state the panel + * keeps is which chips are held while an answer is on its way. + */ + +const page: BookPageSetup = bookPageSetupSchema.parse({ preset: 'square-210' }) + +const point = (over: Partial = {}): JourneyStatsPoint => ({ + lat: 64.14, lng: -21.94, label: 'Reykjavík', date: '2026-06-02', country: 'IS', + tripId: null, photoId: null, entryId: 1, + ...over, +}) + +const stats = (over: Partial = {}): JourneyStats => ({ + journeyId: 6, distance: 1_189_000, days: 14, steps: 2, photos: 0, places: 3, furthest: 408_000, + countries: [{ code: 'IS', name: 'Iceland', places: 2, firstVisit: '2026-06-02' }], + points: [ + point(), + point({ lat: 65.68, lng: -18.12, label: 'Akureyri', date: '2026-06-06', entryId: 2 }), + ], + excluded: [{ entryId: 3, label: 'Keflavík', date: '2026-06-01' }], + trips: [], start: '2026-06-02', end: '2026-06-15', + ...over, +}) + +type Toggle = (entryId: number, excluded: boolean) => Promise + +function open(journeyStats: JourneyStats, over: { canEdit?: boolean; onToggleStop?: Toggle } = {}) { + const onToggleStop = over.onToggleStop ?? vi.fn(async () => true) + render( + k} locale="en" + canEdit={over.canEdit ?? true} onToggleStop={onToggleStop} + />, + ) + return { onToggleStop } +} + +const rows = () => Array.from(document.querySelectorAll('.st-stop')) +const labels = () => rows().map(r => r.querySelector('.st-stop-label')?.textContent) +const switches = () => screen.queryAllByRole('button', { name: 'journey.studio.stopToggle' }) + +beforeEach(() => { + useStudioStore.getState().load({ + version: 1, title: 'T', page, + spreads: [{ id: 's1', role: 'inner', background: null, elements: [], parked: [], entryId: null }], + } as unknown as BookDocument) +}) + +describe('what the stops section lists', () => { + it('puts the left-out stop among the counting ones, in date order', () => { + open(stats()) + + expect(labels()).toEqual(['Keflavík', 'Reykjavík', 'Akureyri']) + }) + + it('dims the left-out row and says which way each switch stands', () => { + open(stats()) + + const [keflavik, reykjavik] = rows() + expect(keflavik.className).toContain('is-off') + expect(keflavik.textContent).toContain('journey.studio.stopOff') + expect(reykjavik.className).not.toContain('is-off') + expect(reykjavik.textContent).toContain('journey.studio.stopOn') + }) + + it('puts an undated stop last', () => { + open(stats({ excluded: [{ entryId: 3, label: 'Somewhere', date: null }] })) + + expect(labels()).toEqual(['Reykjavík', 'Akureyri', 'Somewhere']) + }) + + it('names the country in the reader\'s language, and only for a stop that has one', () => { + open(stats()) + + const quiet = rows().map(r => r.querySelector('.st-badge.is-quiet')?.textContent ?? null) + // The left-out stop carries no country: it is not on the route to have one. + expect(quiet).toEqual([null, 'Iceland', 'Iceland']) + }) + + it('is left out entirely when the journey has no stops at all', () => { + open(stats({ points: [], excluded: [] })) + + expect(screen.queryByText('journey.studio.stops')).toBeNull() + expect(screen.getByText('journey.studio.noRoute')).toBeInTheDocument() + }) +}) + +describe('the switch', () => { + it('asks to leave a counting stop out', () => { + const { onToggleStop } = open(stats()) + + fireEvent.click(switches()[1]) + + expect(onToggleStop).toHaveBeenCalledWith(1, true) + }) + + it('asks to count a left-out stop again', () => { + const { onToggleStop } = open(stats()) + + fireEvent.click(switches()[0]) + + expect(onToggleStop).toHaveBeenCalledWith(3, false) + }) + + /* + * The row does not flip on its own: the figures are the server's sum and + * arrive with the next stats. What the panel owes the user in the meantime + * is a chip that cannot be clicked twice, and one that comes free again + * whether the change landed or not, since a refusal is toasted elsewhere. + */ + it('holds the chip until the answer comes back, then frees it', async () => { + let settle: (ok: boolean) => void = () => {} + const onToggleStop = vi.fn(() => new Promise(resolve => { settle = resolve })) + open(stats(), { onToggleStop }) + + fireEvent.click(switches()[1]) + expect(switches()[1]).toBeDisabled() + // One stop in flight does not lock its neighbours. + expect(switches()[0]).toBeEnabled() + + await act(async () => { settle(false) }) + expect(switches()[1]).toBeEnabled() + }) + + it('is a label, not a button, for a viewer', () => { + open(stats(), { canEdit: false }) + + expect(switches()).toHaveLength(0) + expect(rows()).toHaveLength(3) + expect(rows()[1].textContent).toContain('journey.studio.stopOn') + }) + + it('is a label for a stop taken from a trip place, which has no entry to switch', () => { + open(stats({ + points: [ + point({ entryId: null, label: 'Vík' }), + point({ lat: 65.68, lng: -18.12, label: 'Akureyri', date: '2026-06-06', entryId: 2 }), + ], + excluded: [], + })) + + expect(labels()).toEqual(['Vík', 'Akureyri']) + expect(switches()).toHaveLength(1) + expect(rows()[0].querySelector('button')).toBeNull() + expect(rows()[0].textContent).toContain('journey.studio.stopOn') + }) +}) diff --git a/server/src/db/migrations.ts b/server/src/db/migrations.ts index 087046333..0970d732c 100644 --- a/server/src/db/migrations.ts +++ b/server/src/db/migrations.ts @@ -4236,6 +4236,27 @@ function runMigrations(db: Database.Database): void { db.exec("ALTER TABLE plugin_actions ADD COLUMN scope TEXT NOT NULL DEFAULT 'user'"); } }, + /** + * A journal entry that is not a stop (discussion #2064). + * + * Studio draws its route and prints its distance from every entry that + * carries coordinates, and that is right until the journal starts at the + * home airport: the night before the flight, the stopover, the place the + * trip was planned from all become stops, and the distance counts the legs + * to and from them. The traveller knows which of those are the journey and + * which are the way there, so the switch sits on the entry. The entry stays + * in the journal; it is only left out of the arithmetic. + * + * DEFAULT 0: every existing entry keeps counting, which is what it did. + * + * 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 === 'stats_excluded')) { + db.exec('ALTER TABLE journey_entries ADD COLUMN stats_excluded INTEGER NOT NULL DEFAULT 0'); + } + }, ]; 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 e58d943e2..1b6a20df1 100644 --- a/server/src/nest/journey/journey-domain.service.ts +++ b/server/src/nest/journey/journey-domain.service.ts @@ -888,19 +888,27 @@ export class JourneyDomainService { * `getCountryFromCoords`, which is the same answer computed rather than * remembered. That import is a plain function from a module built to have no * DI and no Nest edges, so it costs this domain no coupling to Atlas. + * + * ── Stops switched off ───────────────────────────────────────────────── + * + * An entry marked `stats_excluded` is in the journal and in nothing here: + * not on the route, not in the distance, not a step, not a country + * (discussion #2064). It is still named, under `excluded`, because the only + * way to switch a stop back on is to be able to see it. */ journeyStats(journeyId: number, userId: number): JourneyStats | null { if (!this.canAccessJourney(journeyId, userId)) return null; const entryRows = this.db.prepare(` - SELECT id, title, location_name, location_lat, location_lng, entry_date, source_trip_id + 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 = ? ORDER BY entry_date ASC, sort_order ASC, id ASC `).all(journeyId) as { id: number; title: string | null; location_name: string | null; location_lat: number | null; location_lng: number | null; entry_date: string | null; - source_trip_id: number | null; + source_trip_id: number | null; source_place_id: number | null; stats_excluded: number; }[]; /* @@ -1006,8 +1014,33 @@ export class JourneyDomainService { return getCountryFromCoords(lat, lng); }; - const fromEntries: StatsInputPoint[] = entryRows - .filter(e => Number.isFinite(e.location_lat) && Number.isFinite(e.location_lng)) + /* + * The switch is honoured before anything is measured, on both routes. + * + * Only an entry with coordinates is listed under `excluded`: one without + * a place was never a stop, and offering to switch it back on would offer + * a stop that does not exist. It still stops being a step, because the + * traveller said it does not count. + * + * The fallback has to honour it too. A skeleton is the entry TREK derives + * from a trip place, so switching the skeleton off and then drawing the + * place it came from would put the same stop straight back on the map + * under its other name. Hence the set of places to leave out. + */ + const hasCoords = (e: { location_lat: number | null; location_lng: number | null }) => + Number.isFinite(e.location_lat) && Number.isFinite(e.location_lng); + const counting = entryRows.filter(e => !e.stats_excluded); + const excluded = entryRows + .filter(e => e.stats_excluded && hasCoords(e)) + .map(e => ({ entryId: e.id, label: e.title || e.location_name || '', date: e.entry_date ?? null })); + const excludedPlaceIds = new Set( + entryRows + .filter(e => e.stats_excluded && e.source_place_id != null) + .map(e => e.source_place_id as number), + ); + + const fromEntries: StatsInputPoint[] = counting + .filter(hasCoords) .map(e => ({ lat: e.location_lat as number, lng: e.location_lng as number, @@ -1016,12 +1049,24 @@ export class JourneyDomainService { country: countryAt(e.location_lat as number, e.location_lng as number), tripId: e.source_trip_id ?? null, photoId: photoByEntry.get(e.id) ?? null, + entryId: e.id, })); - const points: StatsInputPoint[] = fromEntries.length + /* + * Which source wins is decided before the switches, not after. + * + * `fromEntries.length` was the test, and with a switch in the world it + * answers the wrong question: a journey whose every stop has been switched + * off has no entry points left, so the fallback would draw the trip's + * places instead and put the whole route back under other names. The + * journal is the source as soon as one entry carries a point, whether or + * not it still counts. + */ + const journalHasPoints = entryRows.some(hasCoords); + const points: StatsInputPoint[] = journalHasPoints ? fromEntries : placeRows - .filter(p => Number.isFinite(p.lat) && Number.isFinite(p.lng)) + .filter(p => !excludedPlaceIds.has(p.id) && Number.isFinite(p.lat) && Number.isFinite(p.lng)) .map(p => ({ lat: p.lat as number, lng: p.lng as number, @@ -1034,6 +1079,8 @@ export class JourneyDomainService { // smuggled into a field the book will print as one of the journey's // own pictures. photoId: null, + // No entry to switch off, so a panel lists it and offers no switch. + entryId: null, })); /* @@ -1051,9 +1098,11 @@ export class JourneyDomainService { return computeJourneyStats({ journeyId, points, - entries: entryRows.length, + entries: counting.length, photos: photoCount?.n ?? 0, - places: placeCount?.n ?? 0, + // A place whose skeleton was switched off is not a place the journey + // went to, whichever route drew it. + places: (placeCount?.n ?? 0) - placeRows.filter(p => excludedPlaceIds.has(p.id)).length, tripDates, countryNames: countryNamesFor(points), trips: tripRows.map(t => ({ @@ -1063,6 +1112,7 @@ export class JourneyDomainService { end: t.end, points: perTrip.get(t.id) ?? 0, })), + excluded, }); } @@ -1182,6 +1232,7 @@ export class JourneyDomainService { pros_cons: { pros: string[]; cons: string[] }; visibility: string; sort_order: number; + stats_excluded: boolean; }>, sid?: string, ): JourneyEntryWire | null { @@ -1210,6 +1261,7 @@ export class JourneyDomainService { 'pros_cons', 'visibility', 'sort_order', + 'stats_excluded', ]); for (const [key, val] of Object.entries(data)) { @@ -1221,6 +1273,10 @@ 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 = ?'); + values.push(val ? 1 : 0); } else { fields.push(`${key} = ?`); values.push(val); diff --git a/server/src/nest/journey/journey-entry-row.ts b/server/src/nest/journey/journey-entry-row.ts index 0ce00035f..2f77855d8 100644 --- a/server/src/nest/journey/journey-entry-row.ts +++ b/server/src/nest/journey/journey-entry-row.ts @@ -20,18 +20,25 @@ import type { JourneyEntry } from '../../types'; * breaks on it today — the journey page reloads on any event and ignores the * payload — but a payload that contradicts what the read paths answer is a trap * for the first listener that decides to trust it. + * + * `stats_excluded` is the same gap one column over: an INTEGER holding 0 or 1 + * that the wire carries as a boolean (discussion #2064). It crosses here for + * 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; } -/** Decode a row's JSON columns. The one place that knows they are JSON. */ +/** Decode a row's JSON columns and its flag. The one place that knows how they are stored. */ export function decodeEntryRow(row: JourneyEntry): JourneyEntryWire { - const { tags, pros_cons, ...rest } = row; + const { tags, pros_cons, stats_excluded, ...rest } = row; return { ...rest, tags: tags ? JSON.parse(tags) : [], pros_cons: pros_cons ? JSON.parse(pros_cons) : null, + stats_excluded: !!stats_excluded, }; } diff --git a/server/src/nest/journey/journey-stats.ts b/server/src/nest/journey/journey-stats.ts index 98d5ac9d4..e6b0f0426 100644 --- a/server/src/nest/journey/journey-stats.ts +++ b/server/src/nest/journey/journey-stats.ts @@ -1,4 +1,6 @@ -import type { JourneyStats, JourneyStatsCountry, JourneyStatsPoint, JourneyStatsTrip } from '@trek/shared'; +import type { + JourneyStats, JourneyStatsCountry, JourneyStatsExcludedStop, JourneyStatsPoint, JourneyStatsTrip, +} from '@trek/shared'; /** * What a journey adds up to — the arithmetic, with no database and no Nest. @@ -27,6 +29,8 @@ export interface StatsInputPoint { tripId?: number | null; /** One photograph from this stop, for a map that marks it with a picture. */ photoId?: number | null; + /** The journal entry this stop was read from; null or absent for a trip place. */ + entryId?: number | null; } const rad = (deg: number) => (deg * Math.PI) / 180; @@ -166,6 +170,15 @@ export interface StatsInput { countryNames: Record; /** The trips themselves, so a book can print a map of one of them. */ trips?: JourneyStatsTrip[]; + /** + * Stops switched off by hand (discussion #2064). + * + * Not in `points`, and that is the whole arrangement: the service takes + * them out before anything here is measured, so nothing in this module has + * to know which stop is which. The list only rides along so that one + * answer holds both halves, the route and what was left off it. + */ + excluded?: JourneyStatsExcludedStop[]; } /** @@ -206,6 +219,7 @@ export function computeJourneyStats(input: StatsInput): JourneyStats { country: p.country, tripId: p.tripId ?? null, photoId: p.photoId ?? null, + entryId: p.entryId ?? null, })), /* * Counted after thinning, not before. @@ -219,6 +233,7 @@ export function computeJourneyStats(input: StatsInput): JourneyStats { ...t, points: points.filter(p => p.tripId === t.id).length, })), + excluded: input.excluded ?? [], start, end, }; diff --git a/server/src/nest/journey/journey.mcp.ts b/server/src/nest/journey/journey.mcp.ts index 49222cdbb..47d3b99be 100644 --- a/server/src/nest/journey/journey.mcp.ts +++ b/server/src/nest/journey/journey.mcp.ts @@ -143,7 +143,7 @@ export class JourneyMcp { @Tool({ name: 'get_journey_stats', - description: 'What a journey adds up to: distance travelled in metres, calendar days spanned, countries in visit order, the furthest point reached, and entry, photo and place counts. Prefer this over get_journey whenever the question is about totals, since the stats get_journey carries are three counts and nothing else.', + description: 'What a journey adds up to: distance travelled in metres, calendar days spanned, countries in visit order, the furthest point reached, and entry, photo and place counts. Stops the traveller switched off with update_journey_entry count towards none of those and are listed under excluded instead. Prefer this over get_journey whenever the question is about totals, since the stats get_journey carries are three counts and nothing else.', inputSchema: { journeyId: z.number().int().positive(), include_route: z.boolean().optional().describe('Also return the route itself, up to 400 stops with coordinates. Off by default: the totals and the country list do not need it.'), @@ -348,7 +348,7 @@ export class JourneyMcp { @Tool({ name: 'update_journey_entry', - description: 'Update an existing journey entry: its text, date, place, coordinates, weather, tags, verdict or visibility. Fields left out keep their value; pass null to clear one. To move an entry within its day use reorder_journey_entries rather than setting sort_order here.', + description: 'Update an existing journey entry: its text, date, place, coordinates, weather, tags, verdict, visibility, or whether it counts as a stop. Fields left out keep their value; pass null to clear one. To move an entry within its day use reorder_journey_entries rather than setting sort_order here.', inputSchema: { entryId: z.number().int().positive(), title: z.string().max(300).nullable().optional(), @@ -365,6 +365,7 @@ export class JourneyMcp { visibility: ENTRY_VISIBILITY.optional(), 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'), }, annotations: TOOL_ANNOTATIONS_WRITE, when: journeyAddonOn, @@ -376,7 +377,7 @@ export class JourneyMcp { entry_time?: string | null; location_name?: string | null; location_lat?: number | null; 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; + visibility?: EntryVisibility; type?: EntryType; sort_order?: number; stats_excluded?: boolean; }, ctx: McpContext, ) { diff --git a/server/src/types.ts b/server/src/types.ts index d0b383291..aed7164d6 100644 --- a/server/src/types.ts +++ b/server/src/types.ts @@ -382,6 +382,12 @@ export interface JourneyEntry { pros_cons?: string | null; visibility: 'private' | 'shared' | 'public'; sort_order: 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). + * The wire carries a boolean; journey-entry-row.ts is where the two meet. + */ + stats_excluded: number; created_at: number; updated_at: number; } diff --git a/server/tests/e2e/journey.e2e.test.ts b/server/tests/e2e/journey.e2e.test.ts index 8e049a89b..954ed2b2d 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(), + journeyStats: vi.fn(), updateEntry: vi.fn(), }, })); import { JourneyDomainService } from '../../src/nest/journey/journey-domain.service'; @@ -153,6 +153,30 @@ describe('Journey e2e (real auth guard + temp SQLite)', () => { expect(res.body).toEqual({ error: 'Journey not found' }); }); + /* + * The stop switch (discussion #2064). The entry body is a loose contract, so + * what e2e pins is that the boolean reaches the service as it was sent, + * through the addon gate and the auth guard, and that the entry comes back + * bare rather than in an envelope. + */ + it('PATCH entries/:entryId hands stats_excluded to the service as the boolean it was sent', async () => { + jsvc.updateEntry.mockReturnValue({ id: 3, journey_id: 9, stats_excluded: true }); + const res = await request(server) + .patch('/api/journeys/entries/3') + .set('Cookie', sessionCookie(1)) + .send({ stats_excluded: true }); + expect(res.status).toBe(200); + expect(res.body).toEqual({ id: 3, journey_id: 9, stats_excluded: true }); + const [entryId, userId, body] = jsvc.updateEntry.mock.calls[0]; + expect([entryId, userId]).toEqual([3, 1]); + expect(body).toEqual({ stats_excluded: true }); + }); + + it('401 for PATCH entries/:entryId without a session', async () => { + const res = await request(server).patch('/api/journeys/entries/3').send({ stats_excluded: true }); + expect(res.status).toBe(401); + }); + /* * The journey figures TREK Studio prints (#1973). Read-only and derived, so * what e2e adds over the controller unit test is that the addon gate and the diff --git a/server/tests/helpers/factories.ts b/server/tests/helpers/factories.ts index 51596001b..6407bd9d1 100644 --- a/server/tests/helpers/factories.ts +++ b/server/tests/helpers/factories.ts @@ -703,18 +703,20 @@ export interface TestJourneyEntry { entry_date: string; title: string | null; story: string | null; + /** 0/1, as the column holds it. */ + stats_excluded: number; } export function createJourneyEntry( db: Database.Database, journeyId: number, authorId: number, - overrides: Partial<{ type: string; entry_date: string; title: string; story: string; location_name: string; mood: string; weather: string }> = {} + overrides: Partial<{ type: string; entry_date: string; title: string; story: string; location_name: string; mood: string; weather: string; stats_excluded: number }> = {} ): TestJourneyEntry { const now = Date.now(); const result = db.prepare(` - INSERT INTO journey_entries (journey_id, author_id, type, entry_date, title, story, location_name, mood, weather, visibility, sort_order, created_at, updated_at) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, 'private', 0, ?, ?) + INSERT INTO journey_entries (journey_id, author_id, type, entry_date, title, story, location_name, mood, weather, stats_excluded, visibility, sort_order, created_at, updated_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'private', 0, ?, ?) `).run( journeyId, authorId, overrides.type ?? 'entry', @@ -724,6 +726,7 @@ export function createJourneyEntry( overrides.location_name ?? null, overrides.mood ?? null, overrides.weather ?? null, + overrides.stats_excluded ?? 0, now, now ); return db.prepare('SELECT * FROM journey_entries WHERE id = ?').get(result.lastInsertRowid) as TestJourneyEntry; diff --git a/server/tests/unit/mcp/tools-journey.test.ts b/server/tests/unit/mcp/tools-journey.test.ts index 5f2d63f5c..cdabc0b06 100644 --- a/server/tests/unit/mcp/tools-journey.test.ts +++ b/server/tests/unit/mcp/tools-journey.test.ts @@ -619,6 +619,59 @@ describe('journey entry fields', () => { }); }); + /* + * The stop switch (discussion #2064). The REST route takes it, so the tool + * does too, and get_journey_stats is where the effect shows. + */ + it('update_journey_entry switches a stop out of the figures and back', async () => { + const { user } = createUser(testDb); + await withHarness(user.id, async (h) => { + const journey = await seedJourney(h); + const airport = await seedEntry(h, journey.id, { + title: 'Keflavík', entry_date: '2026-06-01', location_lat: 63.98, location_lng: -22.62, + }); + await seedEntry(h, journey.id, { + title: 'Vík', entry_date: '2026-06-03', location_lat: 63.42, location_lng: -19.01, + }); + + const data = parseToolResult(await h.client.callTool({ + name: 'update_journey_entry', arguments: { entryId: airport.id, stats_excluded: true }, + })) as any; + expect(entryRow(airport.id).stats_excluded).toBe(1); + // The echo carries the boolean, as the REST answer does. + expect(data.entry.stats_excluded).toBe(true); + + const off = parseToolResult(await h.client.callTool({ + name: 'get_journey_stats', arguments: { journeyId: journey.id, include_route: true }, + })) as any; + expect(off.stats.points.map((p: any) => p.label)).toEqual(['Vík']); + expect(off.stats.steps).toBe(1); + expect(off.stats.excluded).toEqual([{ entryId: airport.id, label: 'Keflavík', date: '2026-06-01' }]); + + await h.client.callTool({ + name: 'update_journey_entry', arguments: { entryId: airport.id, stats_excluded: false }, + }); + const on = parseToolResult(await h.client.callTool({ + name: 'get_journey_stats', arguments: { journeyId: journey.id }, + })) as any; + expect(entryRow(airport.id).stats_excluded).toBe(0); + expect(on.stats.steps).toBe(2); + expect(on.stats.excluded).toEqual([]); + }); + }); + + it('update_journey_entry refuses a stats_excluded that is not a boolean', async () => { + const { user } = createUser(testDb); + await withHarness(user.id, async (h) => { + const journey = await seedJourney(h); + const entry = await seedEntry(h, journey.id); + expect((await h.client.callTool({ + name: 'update_journey_entry', arguments: { entryId: entry.id, stats_excluded: 'yes' }, + })).isError).toBe(true); + expect(entryRow(entry.id).stats_excluded).toBe(0); + }); + }); + it('update_journey_entry refuses an unknown visibility and an out-of-range longitude', async () => { const { user } = createUser(testDb); await withHarness(user.id, async (h) => { diff --git a/server/tests/unit/nest/journey-domain.service.test.ts b/server/tests/unit/nest/journey-domain.service.test.ts index e7fe2afee..f0ee58146 100644 --- a/server/tests/unit/nest/journey-domain.service.test.ts +++ b/server/tests/unit/nest/journey-domain.service.test.ts @@ -518,6 +518,25 @@ describe('listEntries', () => { expect(result).toBeNull(); }); + + /* + * The stop switch reads back as a boolean on every path (discussion #2064). + * The column holds 0 or 1; a client handed the integer on one read and the + * boolean on another could compare against neither. + */ + it('answers stats_excluded as a boolean, on the list and on the full journey alike', () => { + const { user } = createUser(testDb); + const journey = createJourney(testDb, user.id); + const off = createJourneyEntry(testDb, journey.id, user.id, { entry_date: '2026-03-01', stats_excluded: 1 }); + const on = createJourneyEntry(testDb, journey.id, user.id, { entry_date: '2026-03-02' }); + + const listed = svc.listEntries(journey.id, user.id)!; + expect(listed.find(e => e.id === off.id)!.stats_excluded).toBe(true); + expect(listed.find(e => e.id === on.id)!.stats_excluded).toBe(false); + + const full = svc.getJourneyFull(journey.id, user.id)!; + expect(full.entries.map(e => e.stats_excluded)).toEqual([true, false]); + }); }); describe('createEntry', () => { @@ -667,6 +686,46 @@ describe('updateEntry', () => { } }); + /* + * The stop switch (discussion #2064). The column is an INTEGER, the wire is + * a boolean and the client compares with `=== true`, so the answer has to + * be the boolean on the way out as well as the integer on the way in, and + * the broadcast has to say the same thing the answer does. + */ + it('switches a stop off and back on, and answers with the flag as a boolean', () => { + const { user } = createUser(testDb); + const journey = createJourney(testDb, user.id); + const entry = createJourneyEntry(testDb, journey.id, user.id, { entry_date: '2026-03-01' }); + const flagOf = (id: number) => + (testDb.prepare('SELECT stats_excluded FROM journey_entries WHERE id = ?').get(id) as { stats_excluded: number }).stats_excluded; + + const spy = vi.spyOn(RealtimeService.prototype, 'broadcastToUser').mockImplementation(() => {}); + try { + const off = svc.updateEntry(entry.id, user.id, { stats_excluded: true }); + expect(off!.stats_excluded).toBe(true); + expect(flagOf(entry.id)).toBe(1); + const payload = spy.mock.calls.at(-1)?.[1] as { type: string; entry: { stats_excluded: unknown } }; + expect(payload.entry.stats_excluded).toBe(true); + + const on = svc.updateEntry(entry.id, user.id, { stats_excluded: false }); + expect(on!.stats_excluded).toBe(false); + expect(flagOf(entry.id)).toBe(0); + } finally { + spy.mockRestore(); + } + }); + + it('a viewer cannot switch a stop off', () => { + const { user: owner } = createUser(testDb); + const { user: viewer } = createUser(testDb); + const journey = createJourney(testDb, owner.id); + addJourneyContributor(testDb, journey.id, viewer.id, 'viewer'); + const entry = createJourneyEntry(testDb, journey.id, owner.id, { entry_date: '2026-03-01' }); + + expect(svc.updateEntry(entry.id, viewer.id, { stats_excluded: true })).toBeNull(); + expect(svc.listEntries(journey.id, owner.id)![0].stats_excluded).toBe(false); + }); + it('JOURNEY-SVC-034: returns null for non-existent entry', () => { const { user } = createUser(testDb); diff --git a/server/tests/unit/nest/journey-stats.service.test.ts b/server/tests/unit/nest/journey-stats.service.test.ts index 26d2e4407..2478923bf 100644 --- a/server/tests/unit/nest/journey-stats.service.test.ts +++ b/server/tests/unit/nest/journey-stats.service.test.ts @@ -76,7 +76,7 @@ function placeEntry( authorId: number, lat: number, lng: number, - overrides: { title?: string; entry_date?: string } = {}, + overrides: { title?: string; entry_date?: string; stats_excluded?: number } = {}, ) { const entry = createJourneyEntry(testDb, journeyId, authorId, overrides); testDb @@ -356,3 +356,159 @@ describe('journeyStats totals', () => { expect(svc.journeyStats(journey.id, helper.id)).not.toBeNull(); }); }); + +/* + * The stop somebody switched off (discussion #2064): the home airport, the + * stopover, the place the trip was planned from. It stays in the journal and + * leaves every figure, and it is named under `excluded` so that it can be + * switched back on. + */ +describe('journeyStats excluded stops', () => { + it('leaves a switched-off entry out of the route, the distance and the countries', () => { + const { user } = createUser(testDb); + const journey = createJourney(testDb, user.id); + placeEntry(journey.id, user.id, 52.52, 13.4, { title: 'Berlin, the airport', entry_date: '2026-06-01', stats_excluded: 1 }); + placeEntry(journey.id, user.id, 64.14, -21.94, { title: 'Reykjavík', entry_date: '2026-06-02' }); + placeEntry(journey.id, user.id, 65.68, -18.12, { title: 'Akureyri', entry_date: '2026-06-06' }); + + const stats = svc.journeyStats(journey.id, user.id)!; + + expect(stats.points.map(p => p.label)).toEqual(['Reykjavík', 'Akureyri']); + // Reykjavík to Akureyri and nothing else; the leg in from Berlin is gone. + expect(stats.distance).toBeLessThan(300_000); + expect(stats.countries.map(c => c.code)).toEqual(['IS']); + expect(stats.start).toBe('2026-06-02'); + expect(stats.days).toBe(5); + }); + + it('names the switched-off stop under excluded so it can be switched back on', () => { + const { user } = createUser(testDb); + const journey = createJourney(testDb, user.id); + const airport = placeEntry(journey.id, user.id, 63.98, -22.62, { title: 'Keflavík', entry_date: '2026-06-01', stats_excluded: 1 }); + placeEntry(journey.id, user.id, 64.14, -21.94, { title: 'Reykjavík', entry_date: '2026-06-02' }); + + const stats = svc.journeyStats(journey.id, user.id)!; + + expect(stats.excluded).toEqual([{ entryId: airport.id, label: 'Keflavík', date: '2026-06-01' }]); + }); + + it('does not count a switched-off entry as a step', () => { + const { user } = createUser(testDb); + const journey = createJourney(testDb, user.id); + placeEntry(journey.id, user.id, 63.98, -22.62, { entry_date: '2026-06-01', stats_excluded: 1 }); + placeEntry(journey.id, user.id, 64.14, -21.94, { entry_date: '2026-06-02' }); + + expect(svc.journeyStats(journey.id, user.id)!.steps).toBe(1); + }); + + /* An entry without a place was never a stop, so there is nothing to offer back. */ + it('lists nothing under excluded for a switched-off entry without coordinates', () => { + const { user } = createUser(testDb); + const journey = createJourney(testDb, user.id); + createJourneyEntry(testDb, journey.id, user.id, { title: 'a thought', entry_date: '2026-06-03', stats_excluded: 1 }); + placeEntry(journey.id, user.id, 64.14, -21.94, { entry_date: '2026-06-02' }); + + const stats = svc.journeyStats(journey.id, user.id)!; + + expect(stats.excluded).toEqual([]); + expect(stats.steps).toBe(1); + }); + + it('carries the entry on every stop read from the entries', () => { + const { user } = createUser(testDb); + const journey = createJourney(testDb, user.id); + const a = placeEntry(journey.id, user.id, 64.14, -21.94, { entry_date: '2026-06-02' }); + const b = placeEntry(journey.id, user.id, 65.68, -18.12, { entry_date: '2026-06-06' }); + + expect(svc.journeyStats(journey.id, user.id)!.points.map(p => p.entryId)).toEqual([a.id, b.id]); + }); + + it('drops the place behind a switched-off skeleton from the place count', () => { + const { user } = createUser(testDb); + const journey = createJourney(testDb, user.id); + const trip = createTrip(testDb, user.id); + linkTripToJourney(testDb, journey.id, trip.id); + const airport = createPlace(testDb, trip.id, { name: 'Keflavík', lat: 63.98, lng: -22.62 }); + const town = createPlace(testDb, trip.id, { name: 'Vík', lat: 63.42, lng: -19.01 }); + createDayAssignment(testDb, createDay(testDb, trip.id, { date: '2026-06-01' }).id, airport.id); + createDayAssignment(testDb, createDay(testDb, trip.id, { date: '2026-06-02' }).id, town.id); + svc.syncTripPlaces(journey.id, trip.id, user.id); + testDb + .prepare('UPDATE journey_entries SET stats_excluded = 1 WHERE journey_id = ? AND source_place_id = ?') + .run(journey.id, airport.id); + + const stats = svc.journeyStats(journey.id, user.id)!; + + // The town's skeleton still draws the route, so this is the entries path. + expect(stats.points.map(p => p.label)).toEqual(['Vík']); + expect(stats.excluded.map(s => s.label)).toEqual(['Keflavík']); + expect(stats.places).toBe(1); + }); + + /* + * The fallback honours the switch too. A skeleton is the entry TREK derives + * from a trip place; switching it off and then drawing the place it came + * from would put the same stop straight back on the map by its other name. + */ + it('leaves the place behind a switched-off skeleton out of the fallback route', () => { + const { user } = createUser(testDb); + const journey = createJourney(testDb, user.id); + const trip = createTrip(testDb, user.id); + linkTripToJourney(testDb, journey.id, trip.id); + const airport = createPlace(testDb, trip.id, { name: 'Keflavík', lat: 63.98, lng: -22.62 }); + createPlace(testDb, trip.id, { name: 'Vík', lat: 63.42, lng: -19.01 }); + // Only the airport is on a day, so only the airport gets a skeleton; the + // town stays a bare trip place that the fallback is there to draw. + createDayAssignment(testDb, createDay(testDb, trip.id, { date: '2026-06-01' }).id, airport.id); + svc.syncTripPlaces(journey.id, trip.id, user.id); + // Switched off, and its point cleared by hand afterwards, which is what + // sends the journey down the fallback while the skeleton still names the + // place it came from. + testDb + .prepare(` + UPDATE journey_entries + SET stats_excluded = 1, location_lat = NULL, location_lng = NULL + WHERE journey_id = ? AND source_place_id = ? + `) + .run(journey.id, airport.id); + + const stats = svc.journeyStats(journey.id, user.id)!; + + expect(stats.points.map(p => p.label)).toEqual(['Vík']); + expect(stats.points[0].entryId).toBeNull(); + expect(stats.steps).toBe(0); + expect(stats.places).toBe(1); + }); + + /* + * The pair of cases that decide which source a journey reads from, now that + * a stop can be taken out of one of them. + */ + it('draws no route at all when every stop of the journal is switched off', () => { + const { user } = createUser(testDb); + const journey = createJourney(testDb, user.id); + const trip = createTrip(testDb, user.id); + linkTripToJourney(testDb, journey.id, trip.id); + createPlace(testDb, trip.id, { name: 'a trip place', lat: 48.85, lng: 2.35 }); + placeEntry(journey.id, user.id, 52.52, 13.4, { title: 'Berlin, the airport', entry_date: '2026-06-01', stats_excluded: 1 }); + + const stats = svc.journeyStats(journey.id, user.id)!; + + expect(stats.points).toEqual([]); + expect(stats.distance).toBe(0); + expect(stats.excluded.map(s => s.label)).toEqual(['Berlin, the airport']); + }); + + it('still falls back to the trip places for a journal that never had a point', () => { + const { user } = createUser(testDb); + const journey = createJourney(testDb, user.id); + const trip = createTrip(testDb, user.id); + linkTripToJourney(testDb, journey.id, trip.id); + createPlace(testDb, trip.id, { name: 'a trip place', lat: 48.85, lng: 2.35 }); + createJourneyEntry(testDb, journey.id, user.id, { title: 'written, not placed', stats_excluded: 1 }); + + const stats = svc.journeyStats(journey.id, user.id)!; + + expect(stats.points.map(p => p.label)).toEqual(['a trip place']); + }); +}); diff --git a/server/tests/unit/nest/journey-stats.test.ts b/server/tests/unit/nest/journey-stats.test.ts index d3c394d1b..610f87a4d 100644 --- a/server/tests/unit/nest/journey-stats.test.ts +++ b/server/tests/unit/nest/journey-stats.test.ts @@ -331,11 +331,34 @@ describe('computeJourneyStats', () => { expect(stats.points[0]).toEqual({ // `tripId` is null when the stop came from an entry nobody linked to a // trip, which is every stop here — the field is what lets a book print a - // map of one trip out of several. - lat: 64, lng: -22, label: 'Reykjavík', date: '2026-06-02', country: 'IS', tripId: null, photoId: null, + // map of one trip out of several. `entryId` is null the same way: the + // fixture names no entry, so there is none to switch off. + lat: 64, lng: -22, label: 'Reykjavík', date: '2026-06-02', country: 'IS', tripId: null, photoId: null, entryId: null, }); }); + it('keeps the entry on each stop, and null for a stop that came from a trip place', () => { + const stats = computeJourneyStats({ + ...base, + points: [point({ lat: 64, lng: -22, entryId: 12 }), point({ lat: 65, lng: -18 })], + }); + expect(stats.points.map(p => p.entryId)).toEqual([12, null]); + }); + + /* + * The stops switched off by hand ride through untouched (discussion #2064). + * The service takes them out of `points` before anything here is measured, + * so all this has to do is hand the list back beside the route it was left + * off, and hand back nothing when it was given nothing. + */ + it('carries the excluded stops through beside the route', () => { + const excluded = [{ entryId: 4, label: 'Keflavík', date: '2026-06-01' }]; + const stats = computeJourneyStats({ ...base, points: [point({ lat: 64, lng: -22 })], excluded }); + expect(stats.excluded).toEqual(excluded); + expect(stats.points).toHaveLength(1); + expect(computeJourneyStats({ ...base, points: [] }).excluded).toEqual([]); + }); + it('names the countries from the map it was given', () => { const stats = computeJourneyStats({ ...base, @@ -361,6 +384,7 @@ describe('computeJourneyStats', () => { countries: [], points: [], trips: [], + excluded: [], start: null, end: null, }); diff --git a/shared/src/book/journey-stats.schema.ts b/shared/src/book/journey-stats.schema.ts index 79059a548..bc75d0528 100644 --- a/shared/src/book/journey-stats.schema.ts +++ b/shared/src/book/journey-stats.schema.ts @@ -55,9 +55,33 @@ export const journeyStatsPointSchema = z.object({ * photographed honestly is. */ photoId: z.number().int().positive().nullable().default(null), + /** + * The journal entry this stop was read from, when it was one. + * + * A stop can be switched off (see `excluded` below) and the switch lives + * on the entry, so a panel that offers it needs to know which entry a point + * came from. Null for a stop taken from a trip place directly, which is the + * fallback route a journey gets before anyone has written an entry. + */ + entryId: z.number().int().positive().nullable().default(null), }); export type JourneyStatsPoint = z.infer; +/** + * A stop that was left out. + * + * The home airport, the stopover, the place the trip was planned from: a + * journal entry can be marked as not counting, and then it is not on the + * route, not in the distance and not in the countries. It is still listed + * here, because the only way to put a stop back is to be able to see it. + */ +export const journeyStatsExcludedStopSchema = z.object({ + entryId: z.number().int().positive(), + label: z.string(), + date: z.string().nullable(), +}); +export type JourneyStatsExcludedStop = z.infer; + /** * The trips behind a journey, named. * @@ -111,6 +135,8 @@ export const journeyStatsSchema = z.object({ points: z.array(journeyStatsPointSchema).max(400), /** The trips this journey is made of, for a map of one of them. */ trips: z.array(journeyStatsTripSchema).max(200).default([]), + /** Stops switched off by hand. Not in `points` and not in any figure. */ + excluded: z.array(journeyStatsExcludedStopSchema).max(400).default([]), /** ISO dates, null when nothing in the journey is dated. */ start: z.string().nullable(), end: z.string().nullable(), diff --git a/shared/src/i18n/ar/journey.ts b/shared/src/i18n/ar/journey.ts index 509cbe28b..0e85cbd1a 100644 --- a/shared/src/i18n/ar/journey.ts +++ b/shared/src/i18n/ar/journey.ts @@ -595,5 +595,33 @@ const journey: TranslationStrings = { 'journey.studio.rotateLeft': 'تدوير لليسار', 'journey.studio.rotateRight': 'تدوير لليمين', 'journey.studio.saveReadOnly': 'للقراءة فقط، لا يتم الحفظ', + 'journey.studio.filterPhotos': 'تصفية الصور', + 'journey.studio.filterAll': 'كل الصور', + 'journey.studio.filterLoose': 'ليست ضمن إدخال', + 'journey.studio.filterRecent': 'رُفعت للتو', + 'journey.studio.filterThisPage': 'هذه الصفحة', + 'journey.studio.entryPhotos': 'الصور', + 'journey.studio.noEntryPhotos': 'لا توجد صور في هذا الإدخال بعد.', + 'journey.studio.noLoosePhotos': 'كل صورة هنا تنتمي إلى إدخال.', + 'journey.studio.uploadPhotos': 'رفع الصور', + 'journey.studio.uploadHint': 'أفلت الصور هنا أو انقر لاختيارها', + 'journey.studio.uploadToEntry': 'ستُضاف الصور الجديدة إلى هذا الإدخال', + 'journey.studio.uploadToGallery': 'ستُضاف الصور الجديدة إلى المعرض', + 'journey.studio.uploading': 'جارٍ رفع {done} من {total}', + 'journey.studio.dropFilesHere': 'أفلت لإضافة الصور', + 'journey.studio.videosSkipped': 'تم تجاهل {count} من مقاطع الفيديو. الكتاب يحتوي على صور فقط.', + 'journey.studio.fillPage': 'ملء الصفحة', + 'journey.studio.fillSpread': 'ملء الصفحتين المتقابلتين', + 'journey.studio.fillHint': 'يمدّ الصورة على الصفحة كاملة بما في ذلك الفيض، ويضعها خلف كل شيء آخر.', + 'journey.studio.stops': 'المحطات', + 'journey.studio.stopsHint': 'كل محطة تُحتسب ضمن المسار والمسافة والدول. أوقف واحدة لاستبعادها، مثل مطار بلدك.', + 'journey.studio.stopOn': 'محتسبة', + 'journey.studio.stopOff': 'مستبعدة', + 'journey.studio.stopToggle': 'احتساب هذه المحطة', + 'journey.studio.stopsFailed': 'تعذّر تغيير المحطة.', + 'journey.editor.statsExcluded': 'استبعاد من المسار', + 'journey.editor.statsExcludedHint': + 'تبقى المحطة في اليوميات لكنها لا تُحتسب ضمن المسافة أو الدول أو الخريطة في Studio.', + 'journey.entry.offRoute': 'خارج المسار', }; export default journey; diff --git a/shared/src/i18n/br/journey.ts b/shared/src/i18n/br/journey.ts index 8770cbd2d..dccac328a 100644 --- a/shared/src/i18n/br/journey.ts +++ b/shared/src/i18n/br/journey.ts @@ -596,5 +596,35 @@ const journey: TranslationStrings = { 'journey.studio.rotateLeft': 'Girar à esquerda', 'journey.studio.rotateRight': 'Girar à direita', 'journey.studio.saveReadOnly': 'Somente leitura, nada é salvo', + 'journey.studio.filterPhotos': 'Filtrar fotos', + 'journey.studio.filterAll': 'Todas as fotos', + 'journey.studio.filterLoose': 'Sem entrada', + 'journey.studio.filterRecent': 'Recém-enviadas', + 'journey.studio.filterThisPage': 'Esta página', + 'journey.studio.entryPhotos': 'Fotos', + 'journey.studio.noEntryPhotos': 'Esta entrada ainda não tem fotos.', + 'journey.studio.noLoosePhotos': 'Todas as fotos aqui pertencem a uma entrada.', + 'journey.studio.uploadPhotos': 'Enviar fotos', + 'journey.studio.uploadHint': 'Solte imagens aqui ou clique para escolher', + 'journey.studio.uploadToEntry': 'As novas imagens vão para esta entrada', + 'journey.studio.uploadToGallery': 'As novas imagens vão para a galeria', + 'journey.studio.uploading': 'Enviando {done} de {total}', + 'journey.studio.dropFilesHere': 'Solte para adicionar as imagens', + 'journey.studio.videosSkipped': '{count} vídeos ficaram de fora. Um livro contém imagens.', + 'journey.studio.fillPage': 'Preencher página', + 'journey.studio.fillSpread': 'Preencher página dupla', + 'journey.studio.fillHint': + 'Estica a imagem por toda a página, incluindo a sangria, e a envia para trás de todo o resto.', + 'journey.studio.stops': 'Paradas', + 'journey.studio.stopsHint': + 'Cada parada conta para a rota, a distância e os países. Desligue uma para deixá-la de fora, por exemplo o seu aeroporto de origem.', + 'journey.studio.stopOn': 'Conta', + 'journey.studio.stopOff': 'De fora', + 'journey.studio.stopToggle': 'Contar esta parada', + 'journey.studio.stopsFailed': 'Não foi possível alterar a parada.', + 'journey.editor.statsExcluded': 'Deixar fora da rota', + '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', }; export default journey; diff --git a/shared/src/i18n/ca/journey.ts b/shared/src/i18n/ca/journey.ts index 12b589206..a6de506f6 100644 --- a/shared/src/i18n/ca/journey.ts +++ b/shared/src/i18n/ca/journey.ts @@ -596,5 +596,34 @@ const journey: TranslationStrings = { 'journey.studio.rotateLeft': "Gira cap a l'esquerra", 'journey.studio.rotateRight': 'Gira cap a la dreta', 'journey.studio.saveReadOnly': 'Només lectura, no es desa', + 'journey.studio.filterPhotos': 'Filtra les fotos', + 'journey.studio.filterAll': 'Totes les fotos', + 'journey.studio.filterLoose': 'Sense entrada', + 'journey.studio.filterRecent': 'Acabades de pujar', + 'journey.studio.filterThisPage': 'Aquesta pàgina', + 'journey.studio.entryPhotos': 'Fotos', + 'journey.studio.noEntryPhotos': 'Aquesta entrada encara no té fotos.', + 'journey.studio.noLoosePhotos': "Totes les fotos d'aquí pertanyen a una entrada.", + 'journey.studio.uploadPhotos': 'Puja fotos', + 'journey.studio.uploadHint': 'Deixa anar imatges aquí o fes clic per triar-ne', + 'journey.studio.uploadToEntry': 'Les imatges noves aniran a aquesta entrada', + 'journey.studio.uploadToGallery': 'Les imatges noves aniran a la galeria', + 'journey.studio.uploading': 'Pujant {done} de {total}', + 'journey.studio.dropFilesHere': 'Deixa anar per afegir les imatges', + 'journey.studio.videosSkipped': "S'han deixat fora {count} vídeos. Un llibre conté imatges.", + 'journey.studio.fillPage': 'Omple la pàgina', + 'journey.studio.fillSpread': 'Omple la doble pàgina', + 'journey.studio.fillHint': 'Estira la imatge per tota la pàgina, sagnat inclòs, i la posa darrere de tota la resta.', + 'journey.studio.stops': 'Parades', + 'journey.studio.stopsHint': + "Cada parada compta per a la ruta, la distància i els països. Desactiva'n una per deixar-la fora, per exemple el teu aeroport d'origen.", + 'journey.studio.stopOn': 'Compta', + 'journey.studio.stopOff': 'Exclosa', + 'journey.studio.stopToggle': 'Compta aquesta parada', + 'journey.studio.stopsFailed': "No s'ha pogut canviar la parada.", + 'journey.editor.statsExcluded': 'Deixa fora de la ruta', + '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', }; export default journey; diff --git a/shared/src/i18n/cs/journey.ts b/shared/src/i18n/cs/journey.ts index 6908698f3..c76e7372d 100644 --- a/shared/src/i18n/cs/journey.ts +++ b/shared/src/i18n/cs/journey.ts @@ -596,5 +596,34 @@ const journey: TranslationStrings = { 'journey.studio.rotateLeft': 'Otočit doleva', 'journey.studio.rotateRight': 'Otočit doprava', 'journey.studio.saveReadOnly': 'Jen ke čtení, neukládá se', + 'journey.studio.filterPhotos': 'Filtrovat fotografie', + 'journey.studio.filterAll': 'Všechny fotografie', + 'journey.studio.filterLoose': 'Bez záznamu', + 'journey.studio.filterRecent': 'Právě nahrané', + 'journey.studio.filterThisPage': 'Tato stránka', + 'journey.studio.entryPhotos': 'Fotografie', + 'journey.studio.noEntryPhotos': 'Tento záznam zatím nemá žádné fotografie.', + 'journey.studio.noLoosePhotos': 'Každá fotografie zde patří k nějakému záznamu.', + 'journey.studio.uploadPhotos': 'Nahrát fotografie', + 'journey.studio.uploadHint': 'Přetáhněte sem obrázky nebo klikněte a vyberte je', + 'journey.studio.uploadToEntry': 'Nové obrázky půjdou do tohoto záznamu', + 'journey.studio.uploadToGallery': 'Nové obrázky půjdou do galerie', + 'journey.studio.uploading': 'Nahrává se {done} z {total}', + 'journey.studio.dropFilesHere': 'Pusťte pro přidání obrázků', + 'journey.studio.videosSkipped': '{count} videí bylo vynecháno. Kniha obsahuje obrázky.', + 'journey.studio.fillPage': 'Vyplnit stránku', + 'journey.studio.fillSpread': 'Vyplnit dvoustranu', + 'journey.studio.fillHint': 'Roztáhne obrázek přes celou stránku včetně spadávky a umístí ho za vše ostatní.', + 'journey.studio.stops': 'Zastávky', + 'journey.studio.stopsHint': + 'Každá zastávka se počítá do trasy, vzdálenosti a zemí. Vypněte ji, pokud ji chcete vynechat, třeba domovské letiště.', + 'journey.studio.stopOn': 'Počítá se', + 'journey.studio.stopOff': 'Vynechána', + 'journey.studio.stopToggle': 'Počítat tuto zastávku', + 'journey.studio.stopsFailed': 'Zastávku se nepodařilo změnit.', + 'journey.editor.statsExcluded': 'Vynechat z trasy', + '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', }; export default journey; diff --git a/shared/src/i18n/de/journey.ts b/shared/src/i18n/de/journey.ts index 707670fcd..78b8db400 100644 --- a/shared/src/i18n/de/journey.ts +++ b/shared/src/i18n/de/journey.ts @@ -596,5 +596,35 @@ const journey: TranslationStrings = { 'journey.studio.rotateLeft': 'Nach links drehen', 'journey.studio.rotateRight': 'Nach rechts drehen', 'journey.studio.saveReadOnly': 'Nur lesen, nichts wird gespeichert', + 'journey.studio.filterPhotos': 'Fotos filtern', + 'journey.studio.filterAll': 'Alle Fotos', + 'journey.studio.filterLoose': 'Keinem Eintrag zugeordnet', + 'journey.studio.filterRecent': 'Gerade hochgeladen', + 'journey.studio.filterThisPage': 'Diese Seite', + 'journey.studio.entryPhotos': 'Fotos', + 'journey.studio.noEntryPhotos': 'Dieser Eintrag hat noch keine Fotos.', + 'journey.studio.noLoosePhotos': 'Jedes Foto hier gehört zu einem Eintrag.', + 'journey.studio.uploadPhotos': 'Fotos hochladen', + 'journey.studio.uploadHint': 'Bilder hier ablegen oder klicken, um welche auszuwählen', + 'journey.studio.uploadToEntry': 'Neue Bilder landen in diesem Eintrag', + 'journey.studio.uploadToGallery': 'Neue Bilder landen in der Galerie', + 'journey.studio.uploading': 'Lade {done} von {total} hoch', + 'journey.studio.dropFilesHere': 'Loslassen, um die Bilder hinzuzufügen', + 'journey.studio.videosSkipped': '{count} Videos wurden ausgelassen. Ein Buch enthält Bilder.', + 'journey.studio.fillPage': 'Seite füllen', + 'journey.studio.fillSpread': 'Doppelseite füllen', + 'journey.studio.fillHint': + 'Zieht das Bild über die ganze Seite, Anschnitt inklusive, und legt es hinter alles andere.', + 'journey.studio.stops': 'Stationen', + 'journey.studio.stopsHint': + 'Jede Station zählt für Route, Distanz und Länder. Schalte eine aus, um sie auszulassen, zum Beispiel deinen Heimatflughafen.', + 'journey.studio.stopOn': 'Zählt', + 'journey.studio.stopOff': 'Ausgelassen', + 'journey.studio.stopToggle': 'Diese Station zählen', + 'journey.studio.stopsFailed': 'Die Station konnte nicht geändert werden.', + 'journey.editor.statsExcluded': 'Aus der Route herausnehmen', + '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', }; export default journey; diff --git a/shared/src/i18n/en/journey.ts b/shared/src/i18n/en/journey.ts index 8b4d1e02d..e302aa350 100644 --- a/shared/src/i18n/en/journey.ts +++ b/shared/src/i18n/en/journey.ts @@ -596,5 +596,35 @@ const journey: TranslationStrings = { 'journey.studio.rotateLeft': 'Rotate left', 'journey.studio.rotateRight': 'Rotate right', 'journey.studio.saveReadOnly': 'Read-only, not saved', + 'journey.studio.filterPhotos': 'Filter photos', + 'journey.studio.filterAll': 'All photos', + 'journey.studio.filterLoose': 'Not in an entry', + 'journey.studio.filterRecent': 'Just uploaded', + 'journey.studio.filterThisPage': 'This page', + 'journey.studio.entryPhotos': 'Photos', + 'journey.studio.noEntryPhotos': 'This entry has no photos yet.', + 'journey.studio.noLoosePhotos': 'Every photo here belongs to an entry.', + 'journey.studio.uploadPhotos': 'Upload photos', + 'journey.studio.uploadHint': 'Drop pictures here, or click to choose some', + 'journey.studio.uploadToEntry': 'New pictures go into this entry', + 'journey.studio.uploadToGallery': 'New pictures go into the gallery', + 'journey.studio.uploading': 'Uploading {done} of {total}', + 'journey.studio.dropFilesHere': 'Drop to add the pictures', + 'journey.studio.videosSkipped': '{count} videos were left out. A book holds pictures.', + 'journey.studio.fillPage': 'Fill page', + 'journey.studio.fillSpread': 'Fill spread', + 'journey.studio.fillHint': + 'Stretches the picture over the whole page, bleed included, and sends it behind everything else.', + 'journey.studio.stops': 'Stops', + 'journey.studio.stopsHint': + 'Every stop counts toward the route, the distance and the countries. Switch one off to leave it out, your home airport for instance.', + 'journey.studio.stopOn': 'Counts', + 'journey.studio.stopOff': 'Left out', + 'journey.studio.stopToggle': 'Count this stop', + 'journey.studio.stopsFailed': 'The stop could not be changed.', + 'journey.editor.statsExcluded': 'Leave out of the route', + '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', }; export default journey; diff --git a/shared/src/i18n/es/journey.ts b/shared/src/i18n/es/journey.ts index f6e4735fc..6dcaf4011 100644 --- a/shared/src/i18n/es/journey.ts +++ b/shared/src/i18n/es/journey.ts @@ -596,5 +596,35 @@ const journey: TranslationStrings = { 'journey.studio.rotateLeft': 'Girar a la izquierda', 'journey.studio.rotateRight': 'Girar a la derecha', 'journey.studio.saveReadOnly': 'Solo lectura, no se guarda', + 'journey.studio.filterPhotos': 'Filtrar fotos', + 'journey.studio.filterAll': 'Todas las fotos', + 'journey.studio.filterLoose': 'Sin entrada', + 'journey.studio.filterRecent': 'Recién subidas', + 'journey.studio.filterThisPage': 'Esta página', + 'journey.studio.entryPhotos': 'Fotos', + 'journey.studio.noEntryPhotos': 'Esta entrada aún no tiene fotos.', + 'journey.studio.noLoosePhotos': 'Todas las fotos de aquí pertenecen a una entrada.', + 'journey.studio.uploadPhotos': 'Subir fotos', + 'journey.studio.uploadHint': 'Suelta imágenes aquí o haz clic para elegirlas', + 'journey.studio.uploadToEntry': 'Las imágenes nuevas irán a esta entrada', + 'journey.studio.uploadToGallery': 'Las imágenes nuevas irán a la galería', + 'journey.studio.uploading': 'Subiendo {done} de {total}', + 'journey.studio.dropFilesHere': 'Suelta para añadir las imágenes', + 'journey.studio.videosSkipped': '{count} vídeos se han dejado fuera. Un libro contiene imágenes.', + 'journey.studio.fillPage': 'Llenar página', + 'journey.studio.fillSpread': 'Llenar doble página', + 'journey.studio.fillHint': + 'Estira la imagen por toda la página, sangrado incluido, y la envía detrás de todo lo demás.', + 'journey.studio.stops': 'Paradas', + 'journey.studio.stopsHint': + 'Cada parada cuenta para la ruta, la distancia y los países. Desactiva una para dejarla fuera, por ejemplo tu aeropuerto de origen.', + 'journey.studio.stopOn': 'Cuenta', + 'journey.studio.stopOff': 'Excluida', + 'journey.studio.stopToggle': 'Contar esta parada', + 'journey.studio.stopsFailed': 'No se pudo cambiar la parada.', + 'journey.editor.statsExcluded': 'Dejar fuera de la ruta', + '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', }; export default journey; diff --git a/shared/src/i18n/fr/journey.ts b/shared/src/i18n/fr/journey.ts index dc677b5f9..023963891 100644 --- a/shared/src/i18n/fr/journey.ts +++ b/shared/src/i18n/fr/journey.ts @@ -597,5 +597,34 @@ const journey: TranslationStrings = { 'journey.studio.rotateLeft': 'Pivoter à gauche', 'journey.studio.rotateRight': 'Pivoter à droite', 'journey.studio.saveReadOnly': "Lecture seule, rien n'est enregistré", + 'journey.studio.filterPhotos': 'Filtrer les photos', + 'journey.studio.filterAll': 'Toutes les photos', + 'journey.studio.filterLoose': 'Sans entrée', + 'journey.studio.filterRecent': 'Tout juste téléversées', + 'journey.studio.filterThisPage': 'Cette page', + 'journey.studio.entryPhotos': 'Photos', + 'journey.studio.noEntryPhotos': "Cette entrée n'a pas encore de photos.", + 'journey.studio.noLoosePhotos': 'Chaque photo ici appartient à une entrée.', + 'journey.studio.uploadPhotos': 'Téléverser des photos', + 'journey.studio.uploadHint': 'Déposez des images ici ou cliquez pour en choisir', + 'journey.studio.uploadToEntry': 'Les nouvelles images iront dans cette entrée', + 'journey.studio.uploadToGallery': 'Les nouvelles images iront dans la galerie', + 'journey.studio.uploading': 'Téléversement de {done} sur {total}', + 'journey.studio.dropFilesHere': 'Relâchez pour ajouter les images', + 'journey.studio.videosSkipped': '{count} vidéos ont été laissées de côté. Un livre contient des images.', + 'journey.studio.fillPage': 'Remplir la page', + 'journey.studio.fillSpread': 'Remplir la double page', + 'journey.studio.fillHint': "Étire l'image sur toute la page, fond perdu compris, et la place derrière tout le reste.", + 'journey.studio.stops': 'Étapes', + 'journey.studio.stopsHint': + "Chaque étape compte pour l'itinéraire, la distance et les pays. Désactivez-en une pour l'exclure, votre aéroport de départ par exemple.", + 'journey.studio.stopOn': 'Comptée', + 'journey.studio.stopOff': 'Exclue', + 'journey.studio.stopToggle': 'Compter cette étape', + 'journey.studio.stopsFailed': "L'étape n'a pas pu être modifiée.", + 'journey.editor.statsExcluded': "Exclure de l'itinéraire", + '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', }; export default journey; diff --git a/shared/src/i18n/gr/journey.ts b/shared/src/i18n/gr/journey.ts index 5fb69eca6..cfce69bbf 100644 --- a/shared/src/i18n/gr/journey.ts +++ b/shared/src/i18n/gr/journey.ts @@ -598,5 +598,35 @@ const journey: TranslationStrings = { 'journey.studio.rotateLeft': 'Περιστροφή αριστερά', 'journey.studio.rotateRight': 'Περιστροφή δεξιά', 'journey.studio.saveReadOnly': 'Μόνο για ανάγνωση, δεν αποθηκεύεται', + 'journey.studio.filterPhotos': 'Φιλτράρισμα φωτογραφιών', + 'journey.studio.filterAll': 'Όλες οι φωτογραφίες', + 'journey.studio.filterLoose': 'Χωρίς καταχώρηση', + 'journey.studio.filterRecent': 'Μόλις μεταφορτώθηκαν', + 'journey.studio.filterThisPage': 'Αυτή η σελίδα', + 'journey.studio.entryPhotos': 'Φωτογραφίες', + 'journey.studio.noEntryPhotos': 'Αυτή η καταχώρηση δεν έχει ακόμη φωτογραφίες.', + 'journey.studio.noLoosePhotos': 'Κάθε φωτογραφία εδώ ανήκει σε μια καταχώρηση.', + 'journey.studio.uploadPhotos': 'Μεταφόρτωση φωτογραφιών', + 'journey.studio.uploadHint': 'Αφήστε εικόνες εδώ ή κάντε κλικ για να επιλέξετε', + 'journey.studio.uploadToEntry': 'Οι νέες εικόνες θα μπουν σε αυτή την καταχώρηση', + 'journey.studio.uploadToGallery': 'Οι νέες εικόνες θα μπουν στη συλλογή', + 'journey.studio.uploading': 'Μεταφόρτωση {done} από {total}', + 'journey.studio.dropFilesHere': 'Αφήστε για να προσθέσετε τις εικόνες', + 'journey.studio.videosSkipped': '{count} βίντεο παραλείφθηκαν. Ένα βιβλίο περιέχει εικόνες.', + 'journey.studio.fillPage': 'Γέμισμα σελίδας', + 'journey.studio.fillSpread': 'Γέμισμα δισέλιδου', + 'journey.studio.fillHint': + 'Απλώνει την εικόνα σε όλη τη σελίδα, μαζί με τη διαφυγή, και τη στέλνει πίσω από όλα τα υπόλοιπα.', + 'journey.studio.stops': 'Στάσεις', + 'journey.studio.stopsHint': + 'Κάθε στάση μετράει στη διαδρομή, την απόσταση και τις χώρες. Απενεργοποιήστε μία για να την εξαιρέσετε, για παράδειγμα το αεροδρόμιο της πόλης σας.', + 'journey.studio.stopOn': 'Μετράει', + 'journey.studio.stopOff': 'Εξαιρείται', + 'journey.studio.stopToggle': 'Να μετράει αυτή η στάση', + 'journey.studio.stopsFailed': 'Η στάση δεν μπόρεσε να αλλάξει.', + 'journey.editor.statsExcluded': 'Εξαίρεση από τη διαδρομή', + 'journey.editor.statsExcludedHint': + 'Η στάση παραμένει στο ημερολόγιο, αλλά δεν μετράει στην απόσταση, τις χώρες ή τον χάρτη στο Studio.', + 'journey.entry.offRoute': 'Εκτός διαδρομής', }; export default journey; diff --git a/shared/src/i18n/hu/journey.ts b/shared/src/i18n/hu/journey.ts index cac0082d0..858115e0f 100644 --- a/shared/src/i18n/hu/journey.ts +++ b/shared/src/i18n/hu/journey.ts @@ -596,5 +596,34 @@ const journey: TranslationStrings = { 'journey.studio.rotateLeft': 'Forgatás balra', 'journey.studio.rotateRight': 'Forgatás jobbra', 'journey.studio.saveReadOnly': 'Csak olvasható, nem mentődik', + 'journey.studio.filterPhotos': 'Fotók szűrése', + 'journey.studio.filterAll': 'Összes fotó', + 'journey.studio.filterLoose': 'Nincs bejegyzésben', + 'journey.studio.filterRecent': 'Most feltöltve', + 'journey.studio.filterThisPage': 'Ez az oldal', + 'journey.studio.entryPhotos': 'Fotók', + 'journey.studio.noEntryPhotos': 'Ehhez a bejegyzéshez még nincs fotó.', + 'journey.studio.noLoosePhotos': 'Itt minden fotó egy bejegyzéshez tartozik.', + 'journey.studio.uploadPhotos': 'Fotók feltöltése', + 'journey.studio.uploadHint': 'Húzd ide a képeket, vagy kattints a kiválasztáshoz', + 'journey.studio.uploadToEntry': 'Az új képek ebbe a bejegyzésbe kerülnek', + 'journey.studio.uploadToGallery': 'Az új képek a galériába kerülnek', + 'journey.studio.uploading': 'Feltöltés: {done} / {total}', + 'journey.studio.dropFilesHere': 'Engedd el a képek hozzáadásához', + 'journey.studio.videosSkipped': '{count} videó kimaradt. A könyvbe képek kerülnek.', + 'journey.studio.fillPage': 'Oldal kitöltése', + 'journey.studio.fillSpread': 'Oldalpár kitöltése', + 'journey.studio.fillHint': 'A képet a teljes oldalra nyújtja, kifutóval együtt, és minden más mögé teszi.', + 'journey.studio.stops': 'Megállók', + 'journey.studio.stopsHint': + 'Minden megálló beleszámít az útvonalba, a távolságba és az országokba. Kapcsolj ki egyet, ha ki szeretnéd hagyni, például az otthoni repülőteret.', + 'journey.studio.stopOn': 'Beleszámít', + 'journey.studio.stopOff': 'Kihagyva', + 'journey.studio.stopToggle': 'Ez a megálló számítson', + 'journey.studio.stopsFailed': 'A megállót nem sikerült módosítani.', + 'journey.editor.statsExcluded': 'Kihagyás az útvonalból', + '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', }; export default journey; diff --git a/shared/src/i18n/id/journey.ts b/shared/src/i18n/id/journey.ts index e75bb909b..126eb709e 100644 --- a/shared/src/i18n/id/journey.ts +++ b/shared/src/i18n/id/journey.ts @@ -595,5 +595,35 @@ const journey: TranslationStrings = { 'journey.studio.rotateLeft': 'Putar ke kiri', 'journey.studio.rotateRight': 'Putar ke kanan', 'journey.studio.saveReadOnly': 'Hanya baca, tidak disimpan', + 'journey.studio.filterPhotos': 'Filter foto', + 'journey.studio.filterAll': 'Semua foto', + 'journey.studio.filterLoose': 'Tidak dalam entri', + 'journey.studio.filterRecent': 'Baru diunggah', + 'journey.studio.filterThisPage': 'Halaman ini', + 'journey.studio.entryPhotos': 'Foto', + 'journey.studio.noEntryPhotos': 'Entri ini belum punya foto.', + 'journey.studio.noLoosePhotos': 'Semua foto di sini milik sebuah entri.', + 'journey.studio.uploadPhotos': 'Unggah foto', + 'journey.studio.uploadHint': 'Letakkan gambar di sini atau klik untuk memilih', + 'journey.studio.uploadToEntry': 'Gambar baru akan masuk ke entri ini', + 'journey.studio.uploadToGallery': 'Gambar baru akan masuk ke galeri', + 'journey.studio.uploading': 'Mengunggah {done} dari {total}', + 'journey.studio.dropFilesHere': 'Lepaskan untuk menambahkan gambar', + 'journey.studio.videosSkipped': '{count} video dilewati. Buku berisi gambar.', + 'journey.studio.fillPage': 'Isi halaman', + 'journey.studio.fillSpread': 'Isi halaman ganda', + 'journey.studio.fillHint': + 'Merentangkan gambar ke seluruh halaman, termasuk bleed, dan menaruhnya di belakang semua yang lain.', + 'journey.studio.stops': 'Perhentian', + 'journey.studio.stopsHint': + 'Setiap perhentian dihitung untuk rute, jarak, dan negara. Matikan salah satunya untuk mengecualikannya, misalnya bandara asal Anda.', + 'journey.studio.stopOn': 'Dihitung', + 'journey.studio.stopOff': 'Dikecualikan', + 'journey.studio.stopToggle': 'Hitung perhentian ini', + 'journey.studio.stopsFailed': 'Perhentian tidak dapat diubah.', + 'journey.editor.statsExcluded': 'Kecualikan dari rute', + 'journey.editor.statsExcludedHint': + 'Perhentian tetap ada di jurnal tetapi tidak dihitung untuk jarak, negara, atau peta di Studio.', + 'journey.entry.offRoute': 'Di luar rute', }; export default journey; diff --git a/shared/src/i18n/it/journey.ts b/shared/src/i18n/it/journey.ts index 50ad25e35..4f954ef0b 100644 --- a/shared/src/i18n/it/journey.ts +++ b/shared/src/i18n/it/journey.ts @@ -596,5 +596,35 @@ const journey: TranslationStrings = { 'journey.studio.rotateLeft': 'Ruota a sinistra', 'journey.studio.rotateRight': 'Ruota a destra', 'journey.studio.saveReadOnly': 'Sola lettura, non si salva', + 'journey.studio.filterPhotos': 'Filtra foto', + 'journey.studio.filterAll': 'Tutte le foto', + 'journey.studio.filterLoose': 'Senza voce', + 'journey.studio.filterRecent': 'Appena caricate', + 'journey.studio.filterThisPage': 'Questa pagina', + 'journey.studio.entryPhotos': 'Foto', + 'journey.studio.noEntryPhotos': 'Questa voce non ha ancora foto.', + 'journey.studio.noLoosePhotos': 'Ogni foto qui appartiene a una voce.', + 'journey.studio.uploadPhotos': 'Carica foto', + 'journey.studio.uploadHint': 'Trascina qui le immagini o fai clic per sceglierle', + 'journey.studio.uploadToEntry': 'Le nuove immagini andranno in questa voce', + 'journey.studio.uploadToGallery': 'Le nuove immagini andranno nella galleria', + 'journey.studio.uploading': 'Caricamento {done} di {total}', + 'journey.studio.dropFilesHere': 'Rilascia per aggiungere le immagini', + 'journey.studio.videosSkipped': '{count} video sono stati esclusi. Un libro contiene immagini.', + 'journey.studio.fillPage': 'Riempi pagina', + 'journey.studio.fillSpread': 'Riempi doppia pagina', + 'journey.studio.fillHint': + "Estende l'immagine su tutta la pagina, abbondanza inclusa, e la manda dietro a tutto il resto.", + 'journey.studio.stops': 'Tappe', + 'journey.studio.stopsHint': + 'Ogni tappa conta per il percorso, la distanza e i paesi. Disattivane una per escluderla, ad esempio il tuo aeroporto di casa.', + 'journey.studio.stopOn': 'Conta', + 'journey.studio.stopOff': 'Esclusa', + 'journey.studio.stopToggle': 'Conta questa tappa', + 'journey.studio.stopsFailed': 'Non è stato possibile modificare la tappa.', + 'journey.editor.statsExcluded': 'Escludi dal percorso', + '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', }; export default journey; diff --git a/shared/src/i18n/ja/journey.ts b/shared/src/i18n/ja/journey.ts index 17a0252ab..6b7c2de89 100644 --- a/shared/src/i18n/ja/journey.ts +++ b/shared/src/i18n/ja/journey.ts @@ -597,5 +597,33 @@ const journey: TranslationStrings = { 'journey.studio.rotateLeft': '左に回転', 'journey.studio.rotateRight': '右に回転', 'journey.studio.saveReadOnly': '閲覧のみ・保存されません', + 'journey.studio.filterPhotos': '写真を絞り込む', + 'journey.studio.filterAll': 'すべての写真', + 'journey.studio.filterLoose': 'エントリーに属さない', + 'journey.studio.filterRecent': 'アップロードしたばかり', + 'journey.studio.filterThisPage': 'このページ', + 'journey.studio.entryPhotos': '写真', + 'journey.studio.noEntryPhotos': 'このエントリーにはまだ写真がありません。', + 'journey.studio.noLoosePhotos': 'ここにある写真はすべてエントリーに属しています。', + 'journey.studio.uploadPhotos': '写真をアップロード', + 'journey.studio.uploadHint': 'ここに画像をドロップするか、クリックして選択してください', + 'journey.studio.uploadToEntry': '新しい画像はこのエントリーに追加されます', + 'journey.studio.uploadToGallery': '新しい画像はギャラリーに追加されます', + 'journey.studio.uploading': '{total}枚中{done}枚をアップロード中', + 'journey.studio.dropFilesHere': 'ドロップして画像を追加', + 'journey.studio.videosSkipped': '{count}本の動画は除外されました。本に入るのは画像だけです。', + 'journey.studio.fillPage': 'ページいっぱいに', + 'journey.studio.fillSpread': '見開きいっぱいに', + 'journey.studio.fillHint': '塗り足しを含めてページ全体に画像を広げ、ほかのすべての背面に送ります。', + 'journey.studio.stops': '立ち寄り地', + 'journey.studio.stopsHint': + 'すべての立ち寄り地はルート、距離、国の集計に含まれます。除外したい場合はオフにしてください。たとえば出発地の空港などです。', + 'journey.studio.stopOn': '集計に含む', + 'journey.studio.stopOff': '除外', + 'journey.studio.stopToggle': 'この立ち寄り地を集計に含める', + 'journey.studio.stopsFailed': '立ち寄り地を変更できませんでした。', + 'journey.editor.statsExcluded': 'ルートから除外', + 'journey.editor.statsExcludedHint': '立ち寄り地は日記に残りますが、Studio の距離、国、地図には含まれません。', + 'journey.entry.offRoute': 'ルート外', }; export default journey; diff --git a/shared/src/i18n/ko/journey.ts b/shared/src/i18n/ko/journey.ts index a5e8296c6..163d04dc8 100644 --- a/shared/src/i18n/ko/journey.ts +++ b/shared/src/i18n/ko/journey.ts @@ -596,5 +596,33 @@ const journey: TranslationStrings = { 'journey.studio.rotateLeft': '왼쪽으로 회전', 'journey.studio.rotateRight': '오른쪽으로 회전', 'journey.studio.saveReadOnly': '읽기 전용, 저장되지 않음', + 'journey.studio.filterPhotos': '사진 필터', + 'journey.studio.filterAll': '모든 사진', + 'journey.studio.filterLoose': '항목에 없음', + 'journey.studio.filterRecent': '방금 업로드됨', + 'journey.studio.filterThisPage': '이 페이지', + 'journey.studio.entryPhotos': '사진', + 'journey.studio.noEntryPhotos': '이 항목에는 아직 사진이 없습니다.', + 'journey.studio.noLoosePhotos': '여기 있는 사진은 모두 항목에 속해 있습니다.', + 'journey.studio.uploadPhotos': '사진 업로드', + 'journey.studio.uploadHint': '여기에 이미지를 놓거나 클릭해서 선택하세요', + 'journey.studio.uploadToEntry': '새 이미지는 이 항목에 추가됩니다', + 'journey.studio.uploadToGallery': '새 이미지는 갤러리에 추가됩니다', + 'journey.studio.uploading': '{total}장 중 {done}장 업로드 중', + 'journey.studio.dropFilesHere': '놓아서 이미지 추가', + 'journey.studio.videosSkipped': '동영상 {count}개는 제외되었습니다. 책에는 이미지만 들어갑니다.', + 'journey.studio.fillPage': '페이지 채우기', + 'journey.studio.fillSpread': '펼침면 채우기', + 'journey.studio.fillHint': '도련을 포함해 페이지 전체에 이미지를 펼치고 다른 모든 요소의 뒤로 보냅니다.', + 'journey.studio.stops': '경유지', + 'journey.studio.stopsHint': + '모든 경유지는 경로, 거리, 국가에 포함됩니다. 제외하려면 끄세요. 예를 들어 출발 공항이 있습니다.', + 'journey.studio.stopOn': '포함', + 'journey.studio.stopOff': '제외됨', + 'journey.studio.stopToggle': '이 경유지 포함', + 'journey.studio.stopsFailed': '경유지를 변경할 수 없습니다.', + 'journey.editor.statsExcluded': '경로에서 제외', + 'journey.editor.statsExcludedHint': '경유지는 일기에 남지만 Studio의 거리, 국가, 지도에는 포함되지 않습니다.', + 'journey.entry.offRoute': '경로 외', }; export default journey; diff --git a/shared/src/i18n/nl/journey.ts b/shared/src/i18n/nl/journey.ts index 4d19a21d9..0dfe8e363 100644 --- a/shared/src/i18n/nl/journey.ts +++ b/shared/src/i18n/nl/journey.ts @@ -597,5 +597,35 @@ const journey: TranslationStrings = { 'journey.studio.rotateLeft': 'Naar links draaien', 'journey.studio.rotateRight': 'Naar rechts draaien', 'journey.studio.saveReadOnly': 'Alleen lezen, wordt niet opgeslagen', + 'journey.studio.filterPhotos': "Foto's filteren", + 'journey.studio.filterAll': "Alle foto's", + 'journey.studio.filterLoose': 'Niet in een vermelding', + 'journey.studio.filterRecent': 'Net geüpload', + 'journey.studio.filterThisPage': 'Deze pagina', + 'journey.studio.entryPhotos': "Foto's", + 'journey.studio.noEntryPhotos': "Deze vermelding heeft nog geen foto's.", + 'journey.studio.noLoosePhotos': 'Elke foto hier hoort bij een vermelding.', + 'journey.studio.uploadPhotos': "Foto's uploaden", + 'journey.studio.uploadHint': 'Sleep afbeeldingen hierheen of klik om ze te kiezen', + 'journey.studio.uploadToEntry': 'Nieuwe afbeeldingen komen in deze vermelding', + 'journey.studio.uploadToGallery': 'Nieuwe afbeeldingen komen in de galerij', + 'journey.studio.uploading': '{done} van {total} wordt geüpload', + 'journey.studio.dropFilesHere': 'Laat los om de afbeeldingen toe te voegen', + 'journey.studio.videosSkipped': "{count} video's zijn overgeslagen. Een boek bevat afbeeldingen.", + 'journey.studio.fillPage': 'Pagina vullen', + 'journey.studio.fillSpread': 'Dubbele pagina vullen', + 'journey.studio.fillHint': + 'Rekt de afbeelding uit over de hele pagina, afloop inbegrepen, en zet haar achter al het andere.', + 'journey.studio.stops': 'Stops', + 'journey.studio.stopsHint': + 'Elke stop telt mee voor de route, de afstand en de landen. Zet er een uit om hem weg te laten, bijvoorbeeld je thuisluchthaven.', + 'journey.studio.stopOn': 'Telt mee', + 'journey.studio.stopOff': 'Weggelaten', + 'journey.studio.stopToggle': 'Deze stop meetellen', + 'journey.studio.stopsFailed': 'De stop kon niet worden gewijzigd.', + 'journey.editor.statsExcluded': 'Weglaten uit de route', + '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', }; export default journey; diff --git a/shared/src/i18n/pl/journey.ts b/shared/src/i18n/pl/journey.ts index 1eb198e51..ef51d73c4 100644 --- a/shared/src/i18n/pl/journey.ts +++ b/shared/src/i18n/pl/journey.ts @@ -596,5 +596,34 @@ const journey: TranslationStrings = { 'journey.studio.rotateLeft': 'Obróć w lewo', 'journey.studio.rotateRight': 'Obróć w prawo', 'journey.studio.saveReadOnly': 'Tylko do odczytu, nie zapisuje się', + 'journey.studio.filterPhotos': 'Filtruj zdjęcia', + 'journey.studio.filterAll': 'Wszystkie zdjęcia', + 'journey.studio.filterLoose': 'Bez wpisu', + 'journey.studio.filterRecent': 'Właśnie przesłane', + 'journey.studio.filterThisPage': 'Ta strona', + 'journey.studio.entryPhotos': 'Zdjęcia', + 'journey.studio.noEntryPhotos': 'Ten wpis nie ma jeszcze zdjęć.', + 'journey.studio.noLoosePhotos': 'Każde zdjęcie tutaj należy do jakiegoś wpisu.', + 'journey.studio.uploadPhotos': 'Prześlij zdjęcia', + 'journey.studio.uploadHint': 'Upuść obrazy tutaj lub kliknij, aby je wybrać', + 'journey.studio.uploadToEntry': 'Nowe obrazy trafią do tego wpisu', + 'journey.studio.uploadToGallery': 'Nowe obrazy trafią do galerii', + 'journey.studio.uploading': 'Przesyłanie {done} z {total}', + 'journey.studio.dropFilesHere': 'Upuść, aby dodać obrazy', + 'journey.studio.videosSkipped': '{count} filmów pominięto. Książka zawiera obrazy.', + 'journey.studio.fillPage': 'Wypełnij stronę', + 'journey.studio.fillSpread': 'Wypełnij rozkładówkę', + 'journey.studio.fillHint': 'Rozciąga obraz na całą stronę, łącznie ze spadem, i umieszcza go za wszystkim innym.', + 'journey.studio.stops': 'Przystanki', + 'journey.studio.stopsHint': + 'Każdy przystanek liczy się do trasy, dystansu i krajów. Wyłącz któryś, aby go pominąć, na przykład swoje lotnisko domowe.', + 'journey.studio.stopOn': 'Liczy się', + 'journey.studio.stopOff': 'Pominięty', + 'journey.studio.stopToggle': 'Liczyć ten przystanek', + 'journey.studio.stopsFailed': 'Nie udało się zmienić przystanku.', + 'journey.editor.statsExcluded': 'Pomiń w trasie', + 'journey.editor.statsExcludedHint': + 'Przystanek zostaje w dzienniku, ale nie liczy się do dystansu, krajów ani mapy w Studio.', + 'journey.entry.offRoute': 'Poza trasą', }; export default journey; diff --git a/shared/src/i18n/ru/journey.ts b/shared/src/i18n/ru/journey.ts index 6233c812c..47ef71dd2 100644 --- a/shared/src/i18n/ru/journey.ts +++ b/shared/src/i18n/ru/journey.ts @@ -596,5 +596,35 @@ const journey: TranslationStrings = { 'journey.studio.rotateLeft': 'Повернуть влево', 'journey.studio.rotateRight': 'Повернуть вправо', 'journey.studio.saveReadOnly': 'Только чтение, не сохраняется', + 'journey.studio.filterPhotos': 'Фильтр фото', + 'journey.studio.filterAll': 'Все фото', + 'journey.studio.filterLoose': 'Без записи', + 'journey.studio.filterRecent': 'Только что загружены', + 'journey.studio.filterThisPage': 'Эта страница', + 'journey.studio.entryPhotos': 'Фото', + 'journey.studio.noEntryPhotos': 'В этой записи пока нет фото.', + 'journey.studio.noLoosePhotos': 'Каждое фото здесь относится к какой-то записи.', + 'journey.studio.uploadPhotos': 'Загрузить фото', + 'journey.studio.uploadHint': 'Перетащите изображения сюда или нажмите, чтобы выбрать', + 'journey.studio.uploadToEntry': 'Новые изображения попадут в эту запись', + 'journey.studio.uploadToGallery': 'Новые изображения попадут в галерею', + 'journey.studio.uploading': 'Загрузка {done} из {total}', + 'journey.studio.dropFilesHere': 'Отпустите, чтобы добавить изображения', + 'journey.studio.videosSkipped': 'Видео пропущено: {count}. В книге только изображения.', + 'journey.studio.fillPage': 'Заполнить страницу', + 'journey.studio.fillSpread': 'Заполнить разворот', + 'journey.studio.fillHint': + 'Растягивает изображение на всю страницу, включая вылет, и помещает его позади всего остального.', + 'journey.studio.stops': 'Остановки', + 'journey.studio.stopsHint': + 'Каждая остановка учитывается в маршруте, расстоянии и странах. Отключите остановку, чтобы исключить её, например домашний аэропорт.', + 'journey.studio.stopOn': 'Учитывается', + 'journey.studio.stopOff': 'Исключена', + 'journey.studio.stopToggle': 'Учитывать эту остановку', + 'journey.studio.stopsFailed': 'Не удалось изменить остановку.', + 'journey.editor.statsExcluded': 'Исключить из маршрута', + 'journey.editor.statsExcludedHint': + 'Остановка остаётся в дневнике, но не учитывается в расстоянии, странах и на карте в Studio.', + 'journey.entry.offRoute': 'Вне маршрута', }; export default journey; diff --git a/shared/src/i18n/sv/journey.ts b/shared/src/i18n/sv/journey.ts index 928892095..b4c68b50d 100644 --- a/shared/src/i18n/sv/journey.ts +++ b/shared/src/i18n/sv/journey.ts @@ -596,5 +596,34 @@ const journey: TranslationStrings = { 'journey.studio.rotateLeft': 'Rotera åt vänster', 'journey.studio.rotateRight': 'Rotera åt höger', 'journey.studio.saveReadOnly': 'Skrivskyddad, sparas inte', + 'journey.studio.filterPhotos': 'Filtrera foton', + 'journey.studio.filterAll': 'Alla foton', + 'journey.studio.filterLoose': 'Inte i något inlägg', + 'journey.studio.filterRecent': 'Nyss uppladdade', + 'journey.studio.filterThisPage': 'Den här sidan', + 'journey.studio.entryPhotos': 'Foton', + 'journey.studio.noEntryPhotos': 'Det här inlägget har inga foton än.', + 'journey.studio.noLoosePhotos': 'Alla foton här hör till ett inlägg.', + 'journey.studio.uploadPhotos': 'Ladda upp foton', + 'journey.studio.uploadHint': 'Släpp bilder här eller klicka för att välja', + 'journey.studio.uploadToEntry': 'Nya bilder hamnar i det här inlägget', + 'journey.studio.uploadToGallery': 'Nya bilder hamnar i galleriet', + 'journey.studio.uploading': 'Laddar upp {done} av {total}', + 'journey.studio.dropFilesHere': 'Släpp för att lägga till bilderna', + 'journey.studio.videosSkipped': '{count} videor lämnades utanför. En bok rymmer bilder.', + 'journey.studio.fillPage': 'Fyll sidan', + 'journey.studio.fillSpread': 'Fyll uppslaget', + 'journey.studio.fillHint': 'Sträcker ut bilden över hela sidan, utfall inräknat, och lägger den bakom allt annat.', + 'journey.studio.stops': 'Stopp', + 'journey.studio.stopsHint': + 'Varje stopp räknas med i rutten, sträckan och länderna. Stäng av ett för att utelämna det, till exempel din hemmaflygplats.', + 'journey.studio.stopOn': 'Räknas', + 'journey.studio.stopOff': 'Utelämnat', + 'journey.studio.stopToggle': 'Räkna med det här stoppet', + 'journey.studio.stopsFailed': 'Stoppet kunde inte ändras.', + 'journey.editor.statsExcluded': 'Utelämna från rutten', + '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', }; export default journey; diff --git a/shared/src/i18n/tr/journey.ts b/shared/src/i18n/tr/journey.ts index 36b10be6b..e3b59143c 100644 --- a/shared/src/i18n/tr/journey.ts +++ b/shared/src/i18n/tr/journey.ts @@ -596,5 +596,34 @@ const journey: TranslationStrings = { 'journey.studio.rotateLeft': 'Sola döndür', 'journey.studio.rotateRight': 'Sağa döndür', 'journey.studio.saveReadOnly': 'Salt okunur, kaydedilmiyor', + 'journey.studio.filterPhotos': 'Fotoğrafları filtrele', + 'journey.studio.filterAll': 'Tüm fotoğraflar', + 'journey.studio.filterLoose': 'Bir kayıtta değil', + 'journey.studio.filterRecent': 'Az önce yüklendi', + 'journey.studio.filterThisPage': 'Bu sayfa', + 'journey.studio.entryPhotos': 'Fotoğraflar', + 'journey.studio.noEntryPhotos': 'Bu kaydın henüz fotoğrafı yok.', + 'journey.studio.noLoosePhotos': 'Buradaki her fotoğraf bir kayda ait.', + 'journey.studio.uploadPhotos': 'Fotoğraf yükle', + 'journey.studio.uploadHint': 'Resimleri buraya bırakın veya seçmek için tıklayın', + 'journey.studio.uploadToEntry': 'Yeni resimler bu kayda eklenir', + 'journey.studio.uploadToGallery': 'Yeni resimler galeriye eklenir', + 'journey.studio.uploading': '{total} resimden {done} tanesi yükleniyor', + 'journey.studio.dropFilesHere': 'Resimleri eklemek için bırakın', + 'journey.studio.videosSkipped': '{count} video dışarıda bırakıldı. Kitapta resimler yer alır.', + 'journey.studio.fillPage': 'Sayfayı doldur', + 'journey.studio.fillSpread': 'Çift sayfayı doldur', + 'journey.studio.fillHint': 'Resmi taşma dahil tüm sayfaya yayar ve diğer her şeyin arkasına gönderir.', + 'journey.studio.stops': 'Duraklar', + 'journey.studio.stopsHint': + 'Her durak rotaya, mesafeye ve ülkelere dahil edilir. Dışarıda bırakmak için birini kapatın, örneğin kalkış havalimanınızı.', + 'journey.studio.stopOn': 'Sayılıyor', + 'journey.studio.stopOff': 'Dışarıda', + 'journey.studio.stopToggle': 'Bu durağı say', + 'journey.studio.stopsFailed': 'Durak değiştirilemedi.', + 'journey.editor.statsExcluded': 'Rotanın dışında bırak', + 'journey.editor.statsExcludedHint': + 'Durak günlükte kalır ama mesafeye, ülkelere veya Studio içindeki haritaya dahil edilmez.', + 'journey.entry.offRoute': 'Rota dışı', }; export default journey; diff --git a/shared/src/i18n/uk/journey.ts b/shared/src/i18n/uk/journey.ts index 3d17643ce..235688519 100644 --- a/shared/src/i18n/uk/journey.ts +++ b/shared/src/i18n/uk/journey.ts @@ -597,5 +597,35 @@ const journey: TranslationStrings = { 'journey.studio.rotateLeft': 'Повернути ліворуч', 'journey.studio.rotateRight': 'Повернути праворуч', 'journey.studio.saveReadOnly': 'Лише для читання, не зберігається', + 'journey.studio.filterPhotos': 'Фільтр фото', + 'journey.studio.filterAll': 'Усі фото', + 'journey.studio.filterLoose': 'Без запису', + 'journey.studio.filterRecent': 'Щойно завантажені', + 'journey.studio.filterThisPage': 'Ця сторінка', + 'journey.studio.entryPhotos': 'Фото', + 'journey.studio.noEntryPhotos': 'У цьому записі ще немає фото.', + 'journey.studio.noLoosePhotos': 'Кожне фото тут належить до якогось запису.', + 'journey.studio.uploadPhotos': 'Завантажити фото', + 'journey.studio.uploadHint': 'Перетягніть зображення сюди або натисніть, щоб вибрати', + 'journey.studio.uploadToEntry': 'Нові зображення потраплять у цей запис', + 'journey.studio.uploadToGallery': 'Нові зображення потраплять у галерею', + 'journey.studio.uploading': 'Завантаження {done} з {total}', + 'journey.studio.dropFilesHere': 'Відпустіть, щоб додати зображення', + 'journey.studio.videosSkipped': 'Пропущено відео: {count}. У книзі лише зображення.', + 'journey.studio.fillPage': 'Заповнити сторінку', + 'journey.studio.fillSpread': 'Заповнити розворот', + 'journey.studio.fillHint': + 'Розтягує зображення на всю сторінку, включно з вильотом, і розміщує його позаду всього іншого.', + 'journey.studio.stops': 'Зупинки', + 'journey.studio.stopsHint': + 'Кожна зупинка враховується в маршруті, відстані та країнах. Вимкніть зупинку, щоб виключити її, наприклад домашній аеропорт.', + 'journey.studio.stopOn': 'Враховується', + 'journey.studio.stopOff': 'Виключена', + 'journey.studio.stopToggle': 'Враховувати цю зупинку', + 'journey.studio.stopsFailed': 'Не вдалося змінити зупинку.', + 'journey.editor.statsExcluded': 'Виключити з маршруту', + 'journey.editor.statsExcludedHint': + 'Зупинка залишається в щоденнику, але не враховується у відстані, країнах і на карті в Studio.', + 'journey.entry.offRoute': 'Поза маршрутом', }; export default journey; diff --git a/shared/src/i18n/vi/journey.ts b/shared/src/i18n/vi/journey.ts index e50a6eb28..5b360ec9f 100644 --- a/shared/src/i18n/vi/journey.ts +++ b/shared/src/i18n/vi/journey.ts @@ -596,5 +596,34 @@ const journey: TranslationStrings = { 'journey.studio.rotateLeft': 'Xoay trái', 'journey.studio.rotateRight': 'Xoay phải', 'journey.studio.saveReadOnly': 'Chỉ đọc, không lưu', + 'journey.studio.filterPhotos': 'Lọc ảnh', + 'journey.studio.filterAll': 'Tất cả ảnh', + 'journey.studio.filterLoose': 'Không thuộc mục nào', + 'journey.studio.filterRecent': 'Vừa tải lên', + 'journey.studio.filterThisPage': 'Trang này', + 'journey.studio.entryPhotos': 'Ảnh', + 'journey.studio.noEntryPhotos': 'Mục này chưa có ảnh.', + 'journey.studio.noLoosePhotos': 'Mọi ảnh ở đây đều thuộc về một mục.', + 'journey.studio.uploadPhotos': 'Tải ảnh lên', + 'journey.studio.uploadHint': 'Thả ảnh vào đây hoặc nhấp để chọn', + 'journey.studio.uploadToEntry': 'Ảnh mới sẽ được đưa vào mục này', + 'journey.studio.uploadToGallery': 'Ảnh mới sẽ được đưa vào thư viện', + 'journey.studio.uploading': 'Đang tải lên {done} trên {total}', + 'journey.studio.dropFilesHere': 'Thả để thêm ảnh', + 'journey.studio.videosSkipped': 'Đã bỏ qua {count} video. Sách chỉ chứa ảnh.', + 'journey.studio.fillPage': 'Phủ kín trang', + 'journey.studio.fillSpread': 'Phủ kín trang đôi', + 'journey.studio.fillHint': 'Kéo giãn ảnh phủ kín cả trang, bao gồm cả tràn lề, và đưa ảnh ra sau mọi thứ khác.', + 'journey.studio.stops': 'Điểm dừng', + 'journey.studio.stopsHint': + 'Mỗi điểm dừng đều được tính vào lộ trình, quãng đường và các quốc gia. Tắt một điểm để loại nó ra, chẳng hạn sân bay nhà của bạn.', + 'journey.studio.stopOn': 'Được tính', + 'journey.studio.stopOff': 'Đã loại ra', + 'journey.studio.stopToggle': 'Tính điểm dừng này', + 'journey.studio.stopsFailed': 'Không thể thay đổi điểm dừng.', + 'journey.editor.statsExcluded': 'Loại khỏi lộ trình', + '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', }; export default journey; diff --git a/shared/src/i18n/zh-TW/journey.ts b/shared/src/i18n/zh-TW/journey.ts index 98226cd1b..f4aac669d 100644 --- a/shared/src/i18n/zh-TW/journey.ts +++ b/shared/src/i18n/zh-TW/journey.ts @@ -594,5 +594,32 @@ const journey: TranslationStrings = { 'journey.studio.rotateLeft': '向左旋轉', 'journey.studio.rotateRight': '向右旋轉', 'journey.studio.saveReadOnly': '唯讀,不會儲存', + 'journey.studio.filterPhotos': '篩選照片', + 'journey.studio.filterAll': '全部照片', + 'journey.studio.filterLoose': '不屬於任何條目', + 'journey.studio.filterRecent': '剛剛上傳', + 'journey.studio.filterThisPage': '目前頁面', + 'journey.studio.entryPhotos': '照片', + 'journey.studio.noEntryPhotos': '這個條目還沒有照片。', + 'journey.studio.noLoosePhotos': '這裡的每張照片都屬於某個條目。', + 'journey.studio.uploadPhotos': '上傳照片', + 'journey.studio.uploadHint': '將圖片拖放到這裡,或點擊選擇', + 'journey.studio.uploadToEntry': '新圖片會加入這個條目', + 'journey.studio.uploadToGallery': '新圖片會加入圖庫', + 'journey.studio.uploading': '正在上傳第 {done} 張,共 {total} 張', + 'journey.studio.dropFilesHere': '放開以加入圖片', + 'journey.studio.videosSkipped': '已略過 {count} 部影片。書裡只放圖片。', + 'journey.studio.fillPage': '填滿頁面', + 'journey.studio.fillSpread': '填滿跨頁', + 'journey.studio.fillHint': '將圖片延展至整頁(含出血),並置於所有內容之後。', + 'journey.studio.stops': '停留點', + 'journey.studio.stopsHint': '每個停留點都會計入路線、距離和國家。關閉某個停留點即可將其排除,例如你的出發機場。', + 'journey.studio.stopOn': '計入', + 'journey.studio.stopOff': '已排除', + 'journey.studio.stopToggle': '計入此停留點', + 'journey.studio.stopsFailed': '無法變更該停留點。', + 'journey.editor.statsExcluded': '不計入路線', + 'journey.editor.statsExcludedHint': '該停留點仍保留在日誌中,但不計入 Studio 中的距離、國家和地圖。', + 'journey.entry.offRoute': '不在路線內', }; export default journey; diff --git a/shared/src/i18n/zh/journey.ts b/shared/src/i18n/zh/journey.ts index 0116330e5..ea6902bda 100644 --- a/shared/src/i18n/zh/journey.ts +++ b/shared/src/i18n/zh/journey.ts @@ -594,5 +594,32 @@ const journey: TranslationStrings = { 'journey.studio.rotateLeft': '向左旋转', 'journey.studio.rotateRight': '向右旋转', 'journey.studio.saveReadOnly': '只读,不会保存', + 'journey.studio.filterPhotos': '筛选照片', + 'journey.studio.filterAll': '全部照片', + 'journey.studio.filterLoose': '不属于任何条目', + 'journey.studio.filterRecent': '刚刚上传', + 'journey.studio.filterThisPage': '当前页', + 'journey.studio.entryPhotos': '照片', + 'journey.studio.noEntryPhotos': '这个条目还没有照片。', + 'journey.studio.noLoosePhotos': '这里的每张照片都属于某个条目。', + 'journey.studio.uploadPhotos': '上传照片', + 'journey.studio.uploadHint': '将图片拖放到这里,或点击选择', + 'journey.studio.uploadToEntry': '新图片会加入这个条目', + 'journey.studio.uploadToGallery': '新图片会加入图库', + 'journey.studio.uploading': '正在上传第 {done} 张,共 {total} 张', + 'journey.studio.dropFilesHere': '松开以添加图片', + 'journey.studio.videosSkipped': '已跳过 {count} 个视频。书里只放图片。', + 'journey.studio.fillPage': '铺满页面', + 'journey.studio.fillSpread': '铺满跨页', + 'journey.studio.fillHint': '将图片拉伸至整页(含出血),并置于所有内容之后。', + 'journey.studio.stops': '停留点', + 'journey.studio.stopsHint': '每个停留点都会计入路线、距离和国家。关掉某个停留点即可将其排除,例如你的出发机场。', + 'journey.studio.stopOn': '计入', + 'journey.studio.stopOff': '已排除', + 'journey.studio.stopToggle': '计入此停留点', + 'journey.studio.stopsFailed': '无法更改该停留点。', + 'journey.editor.statsExcluded': '不计入路线', + 'journey.editor.statsExcludedHint': '该停留点仍保留在日志中,但不计入 Studio 中的距离、国家和地图。', + 'journey.entry.offRoute': '不在路线内', }; export default journey;