refactor(journey): share the entry editor logic between desktop and phone

useJourneyEntryForm holds the fields, the photo queue that only goes out on
save, the gallery links, the location search and "use my location", the
verdict rows, quick capture and the save for both the editor dialog and the
entry sheet. journeyWeatherCategory moves to components/Journey/journeyWeather
so the dialog no longer reaches into mobile/. Where the two differed they still
do: the dialog starts with a blank verdict row, counts a moved point as a
change, toasts a failed save or location and fills the weather from the
forecast; the sheet edits tags and shows a refused location under the field.
This commit is contained in:
Maurice
2026-10-09 11:10:53 +02:00
parent 78ddb1b0b8
commit 8e95b4c7b6
10 changed files with 1071 additions and 674 deletions
@@ -1,32 +1,22 @@
import { useEffect, useId, useMemo, useState, useRef, type SyntheticEvent } from 'react'
import { localIsoDate } from '../../utils/localDate'
import { useEffect, useId, useState, useRef, type SyntheticEvent } from 'react'
import { Briefcase, X, Plus, Image, Minus, Check, MapPin, Locate, Camera, Play, Loader2, NotebookPen } from 'lucide-react'
import { normalizeImageFiles } from '../../utils/convertHeic'
import { isVideoFile } from '../../utils/videoPoster'
import { type ResilientResult, type UploadProgress } from '../../utils/uploadQueue'
import { useTranslation } from '../../i18n'
import { journeyApi, mapsApi, addonsApi, memoriesApi, weatherApi } from '../../api/client'
import { useToast } from '../shared/Toast'
import { getCurrentPositionOnce } from '../../hooks/useGeolocation'
import { getApiErrorMessage } from '../../types'
import { addonsApi, memoriesApi } from '../../api/client'
import type { JourneyEntry, JourneyPhoto, GalleryPhoto, JourneyTrip } from '../../store/journeyStore'
import { MOOD_CONFIG, WEATHER_CONFIG } from '../../pages/journeyDetail/JourneyDetailPage.constants'
import { photoUrl, posterlessVideo, isValidGeoPoint, geoOnceErrorKey } from '../../pages/journeyDetail/JourneyDetailPage.helpers'
import { photoUrl, posterlessVideo } from '../../pages/journeyDetail/JourneyDetailPage.helpers'
import MarkdownToolbar from './MarkdownToolbar'
import { DatePicker } from './JourneyDetailPageDatePicker'
import CustomTimePicker from '../shared/CustomTimePicker'
import ToggleSwitch from '../Settings/ToggleSwitch'
import { ProviderPicker, type ProviderPhotoGroup } from './JourneyDetailPageProviderPicker'
import { journeyWeatherCategory } from '../../mobile/screens/journey/mobileJourneyMeta'
import { ProviderPicker } from './JourneyDetailPageProviderPicker'
import ConfirmDialog from '../shared/ConfirmDialog'
import { DialogButton, DialogFooter, DialogHeader, DialogSection, DialogShell, DialogTile, FooterSpacer, NEUTRAL_TINT, fs } from '../shared/DialogShell'
import { AddRowButton, EditorField, INPUT } from '../shared/dialogParts'
import { Tooltip } from '../shared/Tooltip'
import { useJourneyTripSuggestion } from './useJourneyTripSuggestion'
import { useEntryPhotoOrder } from './useEntryPhotoOrder'
import { usePlaceLanguage } from '../../hooks/usePlaceLanguage'
type PendingProviderGroup = ProviderPhotoGroup & { provider: string }
import { useJourneyEntryForm, type PendingProviderGroup } from './useJourneyEntryForm'
/** A chip of the mood and weather rows, as in the collection dialogs' category row. */
const CHIP = 'inline-flex items-center gap-1.5 rounded-full border px-3 py-1 font-semibold transition-colors'
@@ -146,86 +136,34 @@ export function EntryEditor({ entry, journeyId, tripDates, galleryPhotos, trips,
onAddProviderPhotos?: (entryId: number, group: PendingProviderGroup) => Promise<void>
onDone: () => void
}) {
const { t, language } = useTranslation()
const placeLang = usePlaceLanguage()
const toast = useToast()
const [title, setTitle] = useState(entry.title || '')
const [story, setStory] = useState(entry.story || '')
const [entryDate, setEntryDate] = useState(entry.entry_date || localIsoDate())
const [entryTime, setEntryTime] = useState(entry.entry_time?.slice(0, 5) || '')
const [locationName, setLocationName] = useState(entry.location_name || '')
const [locationLat, setLocationLat] = useState<number | null>(entry.location_lat ?? null)
const [locationLng, setLocationLng] = useState<number | null>(entry.location_lng ?? null)
const [locationQuery, setLocationQuery] = useState('')
const [locationResults, setLocationResults] = useState<{ name: string; address?: string; lat: number; lng: number }[]>([])
const [locationSearching, setLocationSearching] = useState(false)
const [showLocationResults, setShowLocationResults] = useState(false)
const [locating, setLocating] = useState(false)
const locationTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
const [mood, setMood] = useState(entry.mood || '')
const [weather, setWeather] = useState(entry.weather || '')
const [statsExcluded, setStatsExcluded] = useState(entry.stats_excluded ?? false)
// The trip this day belongs to, when the journey does not follow it yet (#2265).
const tripSuggestion = useJourneyTripSuggestion(journeyId, trips.map(tr => tr.trip_id), entryDate, true)
const [isDraft, setIsDraft] = useState(entry.is_draft ?? false)
const [pros, setPros] = useState<string[]>(entry.pros_cons?.pros?.length ? entry.pros_cons.pros : [''])
const [cons, setCons] = useState<string[]>(entry.pros_cons?.cons?.length ? entry.pros_cons.cons : [''])
const [saving, setSaving] = useState(false)
const [uploadProgress, setUploadProgress] = useState<{ done: number; total: number } | null>(null)
const [photos, setPhotos] = useState<(JourneyPhoto | GalleryPhoto)[]>(entry.photos || [])
// Drag a photo onto another's place, or send it to the front (#824).
const photoOrder = useEntryPhotoOrder(entry.id, photos, setPhotos)
const canReorder = entry.id > 0 && photos.length > 1
const [pendingFiles, setPendingFiles] = useState<File[]>([])
// Minting the preview URL inline in the JSX would hand out a fresh blob on
// every keystroke in the story field and never give one back.
const pendingUrls = useMemo(() => pendingFiles.map(f => URL.createObjectURL(f)), [pendingFiles])
useEffect(() => () => { pendingUrls.forEach(u => URL.revokeObjectURL(u)) }, [pendingUrls])
const [pendingLinkIds, setPendingLinkIds] = useState<number[]>([])
const [showGalleryPick, setShowGalleryPick] = useState(false)
const { t } = useTranslation()
const {
title, setTitle, story, setStory, entryDate, setEntryDate, entryTime, setEntryTime,
locationName,
locationQuery, locationResults, locationSearching, showLocationResults, setShowLocationResults, locating,
mood, setMood, weather, setWeather, statsExcluded, setStatsExcluded, tripSuggestion, isDraft, setIsDraft,
pros, setPros, cons, setCons, saving, uploadProgress, photos, photoOrder, canReorder,
pendingFiles, setPendingFiles, pendingPreviews: pendingUrls,
pendingProviderGroups, setPendingProviderGroups, showGalleryPick, setShowGalleryPick, isDirty,
availableGalleryPhotos, providerAssetIds, contextLocation, offersStatsToggle, addVerdictRow, verdictRowRef,
handleSave, handleFileChange, pickGalleryPhoto, removePhoto, searchLocation, pickLocation, handleUseCurrentLocation,
} = useJourneyEntryForm({
entry, journeyId, trips, galleryPhotos, onSave, onUploadPhotos, onAddProviderPhotos, onDone,
blankVerdictRow: true, dirtyOnCoordinates: true, toastSaveError: true, autoFillWeather: showWeather,
})
const [photoTab, setPhotoTab] = useState<'upload' | 'gallery' | 'external'>('upload')
const [availableProviders, setAvailableProviders] = useState<{ id: string; name: string }[]>([])
const [providersLoading, setProvidersLoading] = useState(false)
const [externalProvider, setExternalProvider] = useState<string | null>(null)
const [pendingProviderGroups, setPendingProviderGroups] = useState<PendingProviderGroup[]>([])
const fileRef = useRef<HTMLInputElement>(null)
// Own input: putting `capture` on the picker above would take the photo library
// away on a phone, which is the more common way in. This one only ever opens the
// camera, so tablets and laptops get the same route the phone sheet already has.
const cameraRef = useRef<HTMLInputElement>(null)
const storyRef = useRef<HTMLTextAreaElement>(null)
// Which verdict row to put the caret in after the next render. Enter adds a row
// and the caret has to follow it, or the key does half a job.
const verdictFocusRef = useRef<string | null>(null)
const persistedEntryIdRef = useRef<number | null>(entry.id > 0 ? entry.id : null)
const [confirmDiscard, setConfirmDiscard] = useState(false)
const labelId = useId()
// Track which fields differ from the entry we started editing so we can
// warn before discarding on close/cancel.
const originalPros = (entry.pros_cons?.pros ?? []).join('\n')
const originalCons = (entry.pros_cons?.cons ?? []).join('\n')
const isDirty = (
title !== (entry.title || '') ||
story !== (entry.story || '') ||
entryDate !== (entry.entry_date || localIsoDate()) ||
entryTime !== (entry.entry_time?.slice(0, 5) || '') ||
locationName !== (entry.location_name || '') ||
(locationLat ?? null) !== (entry.location_lat ?? null) ||
(locationLng ?? null) !== (entry.location_lng ?? null) ||
mood !== (entry.mood || '') ||
weather !== (entry.weather || '') ||
statsExcluded !== (entry.stats_excluded ?? false) ||
isDraft !== (entry.is_draft ?? false) ||
pros.filter(p => p.trim()).join('\n') !== originalPros ||
cons.filter(c => c.trim()).join('\n') !== originalCons ||
pendingFiles.length > 0 ||
pendingLinkIds.length > 0 ||
pendingProviderGroups.length > 0
)
const availableGalleryPhotos = galleryPhotos.filter(gp => !photos.some(p => p.id === gp.id))
useEffect(() => {
if (photoTab !== 'external' || availableProviders.length > 0 || providersLoading) return
setProvidersLoading(true)
@@ -250,75 +188,7 @@ export function EntryEditor({ entry, journeyId, tripDates, galleryPhotos, trips,
}, [photoTab, availableProviders.length])
const activeExternalProvider = externalProvider || availableProviders[0]?.id || null
const providerExistingAssetIds = new Set<string>()
if (activeExternalProvider) {
photos.forEach(photo => {
if (photo.provider === activeExternalProvider && photo.asset_id) providerExistingAssetIds.add(photo.asset_id)
})
pendingProviderGroups.forEach(group => {
if (group.provider === activeExternalProvider) group.assetIds.forEach(assetId => providerExistingAssetIds.add(assetId))
})
}
/**
* Fill the weather in from the forecast once the entry knows where and when.
*
* The phone's quick capture has done this since it was built; typing an entry
* up at a desk was the one place you still picked the icon by hand (discussion
* #2299). The date decides the source on the server: today comes from the
* forecast, a backdated day from the ERA5 archive, so writing up last Tuesday
* gets last Tuesday's weather rather than this afternoon's.
*
* Only ever fills an empty field, and each place-and-day is tried once, so a
* cleared icon stays cleared and a chosen one is never overwritten.
*/
const weatherTriedRef = useRef<string | null>(null)
useEffect(() => {
if (!showWeather) return
if (typeof locationLat !== 'number' || typeof locationLng !== 'number') return
if (weather) return
const key = `${locationLat.toFixed(3)},${locationLng.toFixed(3)},${entryDate}`
if (weatherTriedRef.current === key) return
weatherTriedRef.current = key
let active = true
weatherApi.get(locationLat, locationLng, entryDate, language)
.then(result => {
// An error-shaped answer carries no `main`, and the dev-only schema check
// does not stop it reaching here in production.
if (!active || !result || result.error || typeof result.main !== 'string') return
const category = journeyWeatherCategory(result.main, result.description ?? '')
// Re-checked rather than trusted from the closure: the request is a
// round trip and the traveller may have picked an icon while it was out.
setWeather(current => current || category)
})
.catch(() => { /* no weather is a fine outcome for a journal entry */ })
return () => { active = false }
}, [showWeather, locationLat, locationLng, entryDate, weather, language])
/**
* Enter in a pro or con opens the next one, the way every list of short things
* behaves. Reaching for the plus button between every item was the complaint
* (discussion #2299); the button stays for the mouse.
*
* The new row goes directly below the one you are in rather than at the end, so
* a thought inserted in the middle lands where you meant it.
*/
const addVerdictRow = (list: 'pros' | 'cons', index: number) => {
const [values, setValues] = list === 'pros' ? [pros, setPros] as const : [cons, setCons] as const
const next = [...values]
next.splice(index + 1, 0, '')
setValues(next)
verdictFocusRef.current = `${list}-${index + 1}`
}
/** Give the caret to the row `addVerdictRow` just made, once React has drawn it. */
const verdictRowRef = (key: string) => (el: HTMLInputElement | null) => {
if (el && verdictFocusRef.current === key) {
verdictFocusRef.current = null
el.focus()
}
}
const providerExistingAssetIds = providerAssetIds(activeExternalProvider)
// Every way out (Cancel, the close button, Escape, the dimmed backdrop) comes
// through here, so none of them drops an edit without asking first.
@@ -330,121 +200,6 @@ export function EntryEditor({ entry, journeyId, tripDates, galleryPhotos, trips,
onClose()
}
const handleSave = async () => {
setSaving(true)
try {
const entryId = await onSave({
title: title || null,
story: story || null,
entry_date: entryDate,
entry_time: entryTime || null,
location_name: locationName || null,
location_lat: locationLat,
location_lng: locationLng,
stats_excluded: offersStatsToggle ? statsExcluded : undefined,
is_draft: isDraft,
mood: mood || null,
weather: weather || null,
pros_cons: { pros: pros.filter(p => p.trim()), cons: cons.filter(c => c.trim()) },
// An explicit Save is the user saying this suggestion is now their entry —
// it does not need a story to earn that (#2008).
type: entry.type === 'skeleton' ? 'entry' : undefined,
}, persistedEntryIdRef.current ?? undefined)
if (entryId > 0) persistedEntryIdRef.current = entryId
// upload queued files after entry is created
if (pendingFiles.length > 0 && entryId) {
const filesToUpload = pendingFiles
setUploadProgress({ done: 0, total: filesToUpload.length })
try {
const { failed } = await onUploadPhotos(entryId, filesToUpload, {
onProgress: p => setUploadProgress({ done: p.done, total: p.total }),
})
setPendingFiles(failed)
if (failed.length > 0) {
toast.error(t('journey.editor.uploadPartialFailed', { failed: String(failed.length), total: String(filesToUpload.length) }))
}
} catch (err) {
toast.error(getApiErrorMessage(err, t('journey.editor.uploadFailed')))
} finally {
setUploadProgress(null)
}
}
// link gallery photos that were picked before save
if (pendingLinkIds.length > 0 && entryId) {
for (const photoId of pendingLinkIds) {
try { await journeyApi.linkPhoto(entryId, photoId) } catch {}
}
}
if (pendingProviderGroups.length > 0 && entryId && onAddProviderPhotos) {
const failed: PendingProviderGroup[] = []
for (const group of pendingProviderGroups) {
try { await onAddProviderPhotos(entryId, group) } catch { failed.push(group) }
}
if (failed.length > 0) {
setPendingProviderGroups(failed)
toast.error(t('journey.editor.externalPhotosPartialFailed', { failed: String(failed.length), total: String(pendingProviderGroups.length) }))
return
}
setPendingProviderGroups([])
}
onDone()
} catch (err) {
// Neither the page callback nor journeyStore toasts, so without this the
// whole entry just fails to save with no sign of it.
toast.error(getApiErrorMessage(err, t('journey.settings.saveFailed')))
return
} finally {
setSaving(false)
}
}
const handleFileChange = async (e: React.ChangeEvent<HTMLInputElement>) => {
const files = e.target.files
if (!files?.length) return
// Queue files locally until Save so cancel/close actually discards. This
// keeps photo behavior consistent with text fields — no silent persistence.
const normalized = await normalizeImageFiles(files)
setPendingFiles(prev => [...prev, ...normalized])
}
const contextLocation = isValidGeoPoint({ lat: locationLat ?? Number.NaN, lng: locationLng ?? Number.NaN })
? { 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)
try {
const pos = await getCurrentPositionOnce()
// Fill coordinates right away; the name is refined below once the
// reverse geocode comes back.
const fallbackName = `${pos.lat.toFixed(5)}, ${pos.lng.toFixed(5)}`
if (locationTimerRef.current) clearTimeout(locationTimerRef.current)
setLocationSearching(false)
setLocationLat(pos.lat)
setLocationLng(pos.lng)
setLocationName(fallbackName)
setLocationQuery('')
setLocationResults([])
setShowLocationResults(false)
try {
const data = await mapsApi.reverse(pos.lat, pos.lng, placeLang)
const name = data.name || data.address
// Only replace the coordinate fallback — don't clobber a search
// result the user may have picked while the reverse call was in flight.
if (name) setLocationName(prev => (prev === fallbackName ? name : prev))
} catch { /* best effort — keep the coordinate fallback */ }
} catch (err) {
toast.error(t(geoOnceErrorKey(err)))
} finally {
setLocating(false)
}
}
const header = (
<DialogHeader
tile={<DialogTile><NotebookPen size={20} strokeWidth={1.9} className="text-content-muted" /></DialogTile>}
@@ -538,17 +293,7 @@ export function EntryEditor({ entry, journeyId, tripDates, galleryPhotos, trips,
type="button"
key={gp.id}
aria-label={t('journey.editor.fromGallery')}
onClick={async () => {
if (entry.id > 0) {
try {
const linked = await journeyApi.linkPhoto(entry.id, gp.id)
if (linked) setPhotos(prev => [...prev, linked])
} catch {}
} else {
setPendingLinkIds(prev => [...prev, gp.id])
setPhotos(prev => [...prev, gp])
}
}}
onClick={() => pickGalleryPhoto(gp)}
className="relative block w-full cursor-pointer overflow-hidden rounded-[10px] border-0 bg-transparent p-0 transition-shadow hover:ring-2 hover:ring-accent"
style={{ paddingTop: '100%' }}
>
@@ -672,15 +417,9 @@ export function EntryEditor({ entry, journeyId, tripDates, galleryPhotos, trips,
)}
<Tooltip label={t('common.delete')}>
<button type="button"
onClick={async (e) => {
onClick={(e) => {
e.stopPropagation()
setPhotos(prev => prev.filter(x => x.id !== p.id))
if (entry.id > 0) {
// unlink from entry; gallery row is preserved
try { await journeyApi.unlinkPhoto(entry.id, p.id) } catch {}
} else {
setPendingLinkIds(prev => prev.filter(id => id !== p.id))
}
void removePhoto(p)
}}
aria-label={t('common.delete')}
className={PHOTO_REMOVE}
@@ -803,26 +542,7 @@ export function EntryEditor({ entry, journeyId, tripDates, galleryPhotos, trips,
<input
id={`${labelId}-location`}
value={locationQuery || locationName}
onChange={e => {
const q = e.target.value
setLocationQuery(q)
setShowLocationResults(true)
if (locationTimerRef.current) clearTimeout(locationTimerRef.current)
if (q.trim().length >= 2) {
locationTimerRef.current = setTimeout(async () => {
setLocationSearching(true)
try {
const res = await mapsApi.search(q, placeLang)
setLocationResults((res.places || []).slice(0, 6).map((p: any) => ({
name: p.name, address: p.address, lat: Number(p.lat), lng: Number(p.lng),
})))
} catch { setLocationResults([]) }
finally { setLocationSearching(false) }
}, 400)
} else {
setLocationResults([])
}
}}
onChange={e => searchLocation(e.target.value)}
onFocus={() => { if (locationResults.length > 0) setShowLocationResults(true) }}
placeholder={t('journey.editor.searchLocation')}
className={`${INPUT} pe-9`}
@@ -846,14 +566,7 @@ export function EntryEditor({ entry, journeyId, tripDates, galleryPhotos, trips,
{locationResults.map((r, i) => (
<button type="button"
key={i}
onClick={() => {
setLocationName(r.name)
setLocationLat(r.lat)
setLocationLng(r.lng)
setLocationQuery('')
setShowLocationResults(false)
setLocationResults([])
}}
onClick={() => pickLocation(r)}
className="flex w-full items-start gap-2.5 rounded-[8px] px-2.5 py-2 text-start hover:bg-surface-hover"
>
<MapPin size={13} className="mt-0.5 flex-none text-content-faint" />
@@ -0,0 +1,27 @@
// FE-COMP-JOURNEYGEO-001 to FE-COMP-JOURNEYGEO-002: the location helpers the journey
// entry form shares between the desktop editor and the phone sheet.
import { describe, expect, it } from 'vitest';
import { GeoOnceError } from '../../hooks/useGeolocation';
import { geoOnceErrorKey, isValidGeoPoint } from './journeyGeo';
describe('journeyGeo', () => {
it('FE-COMP-JOURNEYGEO-001: names a failed position fix by its code, anything else as unavailable', () => {
expect(geoOnceErrorKey(new GeoOnceError('permission-denied'))).toBe('journey.editor.locationPermissionDenied');
expect(geoOnceErrorKey(new GeoOnceError('timeout'))).toBe('journey.editor.locationTimeout');
expect(geoOnceErrorKey(new GeoOnceError('insecure-context'))).toBe('journey.editor.locationInsecureContext');
expect(geoOnceErrorKey(new GeoOnceError('unsupported'))).toBe('journey.editor.locationUnavailable');
expect(geoOnceErrorKey(new Error('boom'))).toBe('journey.editor.locationUnavailable');
});
it('FE-COMP-JOURNEYGEO-002: a point needs finite coordinates inside the globe', () => {
expect(isValidGeoPoint({ lat: 48.86, lng: 2.33 })).toBe(true);
expect(isValidGeoPoint({ lat: -90, lng: 180 })).toBe(true);
expect(isValidGeoPoint({ lat: 91, lng: 0 })).toBe(false);
expect(isValidGeoPoint({ lat: 0, lng: -181 })).toBe(false);
expect(isValidGeoPoint({ lat: Number.NaN, lng: 0 })).toBe(false);
expect(isValidGeoPoint({ lat: 0 })).toBe(false);
expect(isValidGeoPoint(null)).toBe(false);
expect(isValidGeoPoint(undefined)).toBe(false);
});
});
@@ -0,0 +1,31 @@
import { GeoOnceError } from '../../hooks/useGeolocation';
// Shared by the desktop entry editor and the mobile entry sheet so a failed
// one-shot position fix surfaces the same translated message everywhere.
export function geoOnceErrorKey(err: unknown): string {
const code = err instanceof GeoOnceError ? err.code : 'unavailable';
return code === 'permission-denied'
? 'journey.editor.locationPermissionDenied'
: code === 'timeout'
? 'journey.editor.locationTimeout'
: code === 'insecure-context'
? 'journey.editor.locationInsecureContext'
: 'journey.editor.locationUnavailable';
}
export interface GeoPoint {
lat: number;
lng: number;
}
export function isValidGeoPoint(point: Partial<GeoPoint> | null | undefined): point is GeoPoint {
return (
!!point &&
Number.isFinite(point.lat) &&
Number.isFinite(point.lng) &&
point.lat >= -90 &&
point.lat <= 90 &&
point.lng >= -180 &&
point.lng <= 180
);
}
@@ -0,0 +1,15 @@
/**
* The journey weather icon id for a weather service answer: its main condition, with
* the description telling a partly cloudy sky from a cloudy one. 'cold' is the stored
* id for snow.
*/
export function journeyWeatherCategory(main: string, description: string): string {
const normalizedMain = main.toLowerCase();
const normalizedDescription = description.toLowerCase();
if (normalizedMain === 'clear') return 'sunny';
if (normalizedMain === 'thunderstorm') return 'stormy';
if (normalizedMain === 'snow') return 'cold';
if (normalizedMain === 'rain' || normalizedMain === 'drizzle') return 'rainy';
if (normalizedDescription.includes('partly') || normalizedDescription.includes('teilweise')) return 'partly';
return 'cloudy';
}
@@ -0,0 +1,381 @@
// FE-JRN-ENTRYFORM-001 to FE-JRN-ENTRYFORM-020: the journey entry form logic behind both
// the desktop editor dialog and the phone entry sheet.
import { act, renderHook, waitFor } from '@testing-library/react';
import type React from 'react';
import { afterEach, beforeEach, describe, expect, it, vi, type Mock } from 'vitest';
import { journeyApi, mapsApi, weatherApi } from '../../api/client';
import { getCurrentPositionOnce } from '../../hooks/useGeolocation';
import type { GalleryPhoto, JourneyEntry, JourneyPhoto } from '../../store/journeyStore';
import { useJourneyEntryForm, type JourneyEntryFormOptions } from './useJourneyEntryForm';
vi.mock('../../i18n', () => ({
useTranslation: () => ({
t: (k: string, p?: Record<string, unknown>) => (p ? `${k}:${JSON.stringify(p)}` : k),
language: 'en',
}),
}));
vi.mock('../../hooks/usePlaceLanguage', () => ({ usePlaceLanguage: () => 'en' }));
vi.mock('../../hooks/useGeolocation', async (importOriginal) => ({
...(await importOriginal<typeof import('../../hooks/useGeolocation')>()),
getCurrentPositionOnce: vi.fn(),
}));
vi.mock('../../utils/convertHeic', () => ({ normalizeImageFiles: (files: File[]) => Promise.resolve([...files]) }));
vi.mock('./useJourneyTripSuggestion', () => ({
useJourneyTripSuggestion: () => ({ trip: null, link: vi.fn(), linking: false, dismiss: vi.fn() }),
}));
vi.mock('./useEntryPhotoOrder', () => ({ useEntryPhotoOrder: () => ({ makeFirst: vi.fn() }) }));
const locate = getCurrentPositionOnce as unknown as Mock;
let addToast: Mock<NonNullable<Window['__addToast']>>;
function entry(over: Partial<JourneyEntry> = {}): JourneyEntry {
return {
id: 4,
journey_id: 3,
title: 'Colosseum',
story: 'Old stones',
entry_date: '2026-05-01',
entry_time: '10:30:00',
location_name: 'Rome',
location_lat: 41.89,
location_lng: 12.49,
mood: 'good',
weather: 'sunny',
tags: ['rome'],
pros_cons: { pros: ['Big'], cons: [] },
photos: [],
...over,
} as JourneyEntry;
}
function setup(over: Partial<JourneyEntryFormOptions> = {}) {
const props: JourneyEntryFormOptions = {
entry: entry(),
journeyId: 3,
trips: [],
galleryPhotos: [],
onSave: vi.fn().mockResolvedValue(4),
onUploadPhotos: vi.fn().mockResolvedValue({ ok: [], failed: [] }),
onDone: vi.fn(),
...over,
};
const hook = renderHook((p: JourneyEntryFormOptions) => useJourneyEntryForm(p), { initialProps: props });
return { ...hook, props };
}
const photo = (id: number, over: Record<string, unknown> = {}) =>
({ id, photo_id: id, ...over }) as unknown as JourneyPhoto;
beforeEach(() => {
addToast = vi.fn<NonNullable<Window['__addToast']>>();
window.__addToast = addToast;
locate.mockReset();
URL.createObjectURL = vi.fn(() => 'blob:x');
URL.revokeObjectURL = vi.fn();
});
const { createObjectURL, revokeObjectURL } = URL;
afterEach(() => {
URL.createObjectURL = createObjectURL;
URL.revokeObjectURL = revokeObjectURL;
vi.useRealTimers();
vi.restoreAllMocks();
delete window.__addToast;
});
describe('useJourneyEntryForm', () => {
it('FE-JRN-ENTRYFORM-001: seeds every field from the entry', () => {
const { result } = setup();
expect(result.current.title).toBe('Colosseum');
expect(result.current.entryTime).toBe('10:30');
expect(result.current.locationLat).toBe(41.89);
expect(result.current.pros).toEqual(['Big']);
expect(result.current.cons).toEqual([]);
expect(result.current.isDirty).toBe(false);
expect(result.current.offersStatsToggle).toBe(true);
expect(result.current.contextLocation).toEqual({ lat: 41.89, lng: 12.49, name: 'Rome' });
});
it('FE-JRN-ENTRYFORM-002: the desktop dialog starts an empty verdict list with one blank row', () => {
const { result } = setup({ blankVerdictRow: true });
expect(result.current.pros).toEqual(['Big']);
expect(result.current.cons).toEqual(['']);
expect(result.current.isDirty).toBe(false);
});
it('FE-JRN-ENTRYFORM-003: a moved point is dirty only for the desktop dialog, tags only for the sheet', () => {
const desktop = setup({ dirtyOnCoordinates: true });
act(() => desktop.result.current.pickLocation({ name: 'Rome', lat: 1, lng: 2 }));
expect(desktop.result.current.isDirty).toBe(true);
const sheet = setup();
act(() => sheet.result.current.pickLocation({ name: 'Rome', lat: 1, lng: 2 }));
expect(sheet.result.current.isDirty).toBe(false);
act(() => sheet.result.current.setTags(['rome', 'food']));
expect(sheet.result.current.isDirty).toBe(false);
const tagged = setup({ withTags: true });
act(() => tagged.result.current.setTags(['rome', 'food']));
expect(tagged.result.current.isDirty).toBe(true);
});
it('FE-JRN-ENTRYFORM-004: saves the fields, with tags only for the sheet, and turns a suggestion into an entry', async () => {
const plain = setup({ entry: entry({ type: 'skeleton' } as Partial<JourneyEntry>) });
await act(() => plain.result.current.handleSave());
expect(plain.props.onSave).toHaveBeenCalledWith(
{
title: 'Colosseum',
story: 'Old stones',
entry_date: '2026-05-01',
entry_time: '10:30',
location_name: 'Rome',
location_lat: 41.89,
location_lng: 12.49,
stats_excluded: false,
is_draft: false,
mood: 'good',
weather: 'sunny',
pros_cons: { pros: ['Big'], cons: [] },
type: 'entry',
},
4
);
expect(plain.props.onDone).toHaveBeenCalledTimes(1);
const tagged = setup({ withTags: true });
act(() => tagged.result.current.setTags(['rome', ' ']));
await act(() => tagged.result.current.handleSave());
expect((tagged.props.onSave as Mock).mock.calls[0][0]).toMatchObject({ tags: ['rome'], type: undefined });
});
it('FE-JRN-ENTRYFORM-005: a new entry keeps the id its first save created for the retry', async () => {
const onSave = vi.fn().mockResolvedValue(77);
const onAddProviderPhotos = vi.fn().mockRejectedValueOnce(new Error('x')).mockResolvedValue(undefined);
const { result, props } = setup({ entry: entry({ id: 0 }), onSave, onAddProviderPhotos });
act(() => result.current.setPendingProviderGroups([{ provider: 'immich', assetIds: ['a'] } as never]));
await act(() => result.current.handleSave());
expect(onSave).toHaveBeenLastCalledWith(expect.any(Object), undefined);
expect(addToast).toHaveBeenCalledWith(
'journey.editor.externalPhotosPartialFailed:{"failed":"1","total":"1"}',
'error',
undefined
);
expect(props.onDone).not.toHaveBeenCalled();
expect(result.current.pendingProviderGroups).toHaveLength(1);
await act(() => result.current.handleSave());
expect(onSave).toHaveBeenLastCalledWith(expect.any(Object), 77);
expect(result.current.pendingProviderGroups).toEqual([]);
expect(props.onDone).toHaveBeenCalledTimes(1);
});
it('FE-JRN-ENTRYFORM-006: uploads the queued files after the save and keeps the failed ones', async () => {
const a = new File(['a'], 'a.jpg');
const b = new File(['b'], 'b.jpg');
const onUploadPhotos = vi.fn(
async (_id: number, _files: File[], cbs?: { onProgress?: (p: { done: number; total: number }) => void }) => {
cbs?.onProgress?.({ done: 1, total: 2 });
return { ok: [], failed: [b] };
}
);
const { result } = setup({
onUploadPhotos: onUploadPhotos as unknown as JourneyEntryFormOptions['onUploadPhotos'],
});
await act(() =>
result.current.handleFileChange({ target: { files: [a, b] } } as unknown as React.ChangeEvent<HTMLInputElement>)
);
expect(result.current.pendingFiles).toEqual([a, b]);
expect(result.current.pendingPreviews).toEqual(['blob:x', 'blob:x']);
await act(() => result.current.handleSave());
expect(onUploadPhotos.mock.calls[0][1]).toEqual([a, b]);
expect(result.current.pendingFiles).toEqual([b]);
expect(result.current.uploadProgress).toBeNull();
expect(addToast).toHaveBeenCalledWith(
'journey.editor.uploadPartialFailed:{"failed":"1","total":"2"}',
'error',
undefined
);
});
it('FE-JRN-ENTRYFORM-007: a failed save toasts for the desktop dialog and rejects for the sheet', async () => {
const onSave = vi.fn().mockRejectedValue({ response: { data: { error: 'Locked' } } });
const desktop = setup({ onSave, toastSaveError: true });
await act(() => desktop.result.current.handleSave());
expect(addToast).toHaveBeenCalledWith('Locked', 'error', undefined);
expect(desktop.result.current.saving).toBe(false);
const sheet = setup({ onSave });
let rejected = false;
await act(() => sheet.result.current.handleSave().catch(() => void (rejected = true)));
expect(rejected).toBe(true);
expect(sheet.result.current.saving).toBe(false);
expect(addToast).toHaveBeenCalledTimes(1);
});
it('FE-JRN-ENTRYFORM-008: links gallery photos picked before the first save', async () => {
const link = vi.spyOn(journeyApi, 'linkPhoto').mockResolvedValue({});
const gp = photo(9) as unknown as GalleryPhoto;
const { result } = setup({ entry: entry({ id: 0 }), galleryPhotos: [gp] });
expect(result.current.availableGalleryPhotos).toEqual([gp]);
await act(() => result.current.pickGalleryPhoto(gp));
expect(link).not.toHaveBeenCalled();
expect(result.current.availableGalleryPhotos).toEqual([]);
await act(() => result.current.handleSave());
expect(link).toHaveBeenCalledWith(4, 9);
});
it('FE-JRN-ENTRYFORM-009: a saved entry links and unlinks gallery photos at once', async () => {
const link = vi.spyOn(journeyApi, 'linkPhoto').mockResolvedValue(photo(20));
const unlink = vi.spyOn(journeyApi, 'unlinkPhoto').mockResolvedValue({});
const { result } = setup();
await act(() => result.current.pickGalleryPhoto(photo(9) as unknown as GalleryPhoto));
expect(link).toHaveBeenCalledWith(4, 9);
expect(result.current.photos.map((p) => p.id)).toEqual([20]);
await act(() => result.current.removePhoto(photo(20)));
expect(unlink).toHaveBeenCalledWith(4, 20);
expect(result.current.photos).toEqual([]);
});
it('FE-JRN-ENTRYFORM-010: knows which provider assets are already on the entry or queued', () => {
const { result } = setup({
entry: entry({
photos: [photo(1, { provider: 'immich', asset_id: 'a' }), photo(2, { provider: 'synology', asset_id: 'b' })],
}),
});
act(() => result.current.setPendingProviderGroups([{ provider: 'immich', assetIds: ['c', 'd'] } as never]));
expect([...result.current.providerAssetIds('immich')].sort()).toEqual(['a', 'c', 'd']);
expect(result.current.providerAssetIds(null).size).toBe(0);
expect(result.current.queuedProviderPhotos).toBe(2);
});
it('FE-JRN-ENTRYFORM-011: Enter in a verdict row opens the next one below it and hands it the caret', () => {
const { result, rerender, props } = setup({ entry: entry({ pros_cons: { pros: ['A', 'B'], cons: [] } }) });
act(() => result.current.addVerdictRow('pros', 0));
expect(result.current.pros).toEqual(['A', '', 'B']);
rerender(props);
const input = { focus: vi.fn() } as unknown as HTMLInputElement;
result.current.verdictRowRef('pros-2')(input);
expect(input.focus).not.toHaveBeenCalled();
result.current.verdictRowRef('pros-1')(input);
expect(input.focus).toHaveBeenCalledTimes(1);
result.current.verdictRowRef('pros-1')(input);
expect(input.focus).toHaveBeenCalledTimes(1);
});
it('FE-JRN-ENTRYFORM-012: a tag drops trailing commas and is added once', () => {
const { result } = setup({ withTags: true });
act(() => result.current.setTagInput(' food,,, '));
act(() => result.current.addTag());
expect(result.current.tags).toEqual(['rome', 'food']);
expect(result.current.tagInput).toBe('');
act(() => result.current.setTagInput('food'));
act(() => result.current.addTag());
expect(result.current.tags).toEqual(['rome', 'food']);
act(() => result.current.setTagInput(',,'));
act(() => result.current.addTag());
expect(result.current.tagInput).toBe(',,');
});
it('FE-JRN-ENTRYFORM-013: the location search waits 400 ms, needs two letters and keeps six results', async () => {
vi.useFakeTimers();
const places = Array.from({ length: 8 }, (_, i) => ({ name: `P${i}`, lat: String(i), lng: '1' }));
const search = vi.spyOn(mapsApi, 'search').mockResolvedValue({ places } as never);
const { result } = setup();
act(() => result.current.searchLocation('R'));
expect(result.current.showLocationResults).toBe(true);
await act(async () => {
await vi.advanceTimersByTimeAsync(500);
});
expect(search).not.toHaveBeenCalled();
act(() => result.current.searchLocation('Ro'));
act(() => result.current.searchLocation('Rom'));
await act(async () => {
await vi.advanceTimersByTimeAsync(400);
});
expect(search).toHaveBeenCalledTimes(1);
expect(search).toHaveBeenCalledWith('Rom', 'en');
expect(result.current.locationResults).toHaveLength(6);
expect(result.current.locationResults[1]).toEqual({ name: 'P1', address: undefined, lat: 1, lng: 1 });
expect(result.current.locationSearching).toBe(false);
act(() => result.current.pickLocation(result.current.locationResults[1]));
expect(result.current.locationName).toBe('P1');
expect(result.current.locationResults).toEqual([]);
expect(result.current.showLocationResults).toBe(false);
});
it('FE-JRN-ENTRYFORM-014: "use my location" fills the point, then the place name', async () => {
locate.mockResolvedValue({ lat: 1.234567, lng: 2.345678 });
vi.spyOn(mapsApi, 'reverse').mockResolvedValue({ name: 'Here' } as never);
const { result } = setup();
await act(() => result.current.handleUseCurrentLocation());
expect(result.current.locationLat).toBe(1.234567);
expect(result.current.locationName).toBe('Here');
expect(result.current.locating).toBe(false);
});
it('FE-JRN-ENTRYFORM-015: a refused location shows under the field on the phone and as a toast on desktop', async () => {
locate.mockRejectedValue(new Error('denied'));
const sheet = setup({ inlineLocateError: true });
await act(() => sheet.result.current.handleUseCurrentLocation());
expect(sheet.result.current.locationError).not.toBe('');
expect(addToast).not.toHaveBeenCalled();
const desktop = setup();
await act(() => desktop.result.current.handleUseCurrentLocation());
expect(desktop.result.current.locationError).toBe('');
expect(addToast).toHaveBeenCalledTimes(1);
});
it('FE-JRN-ENTRYFORM-016: the desktop dialog fills an empty weather from the forecast once per place and day', async () => {
const get = vi.spyOn(weatherApi, 'get').mockResolvedValue({ main: 'Rain', description: 'light rain' } as never);
const { result } = setup({ entry: entry({ weather: '' }), autoFillWeather: true });
await waitFor(() => expect(result.current.weather).toBe('rainy'));
expect(get).toHaveBeenCalledWith(41.89, 12.49, '2026-05-01', 'en');
act(() => result.current.setWeather(''));
expect(get).toHaveBeenCalledTimes(1);
setup({ entry: entry({ weather: '' }) });
expect(get).toHaveBeenCalledTimes(1);
});
it('FE-JRN-ENTRYFORM-017: quick capture locates the traveller and names the place and the weather', async () => {
locate.mockResolvedValue({ lat: 5, lng: 6 });
vi.spyOn(mapsApi, 'reverse').mockResolvedValue({ name: '', address: 'Main St' } as never);
vi.spyOn(weatherApi, 'getCurrent').mockResolvedValue({ main: 'Clear', description: 'clear sky' } as never);
const { result } = setup({
quickCapture: true,
entry: entry({ id: 0, location_lat: null, location_lng: null, location_name: '', weather: '' }),
});
await waitFor(() => expect(result.current.locating).toBe(false));
expect(result.current.locationLat).toBe(5);
expect(result.current.locationName).toBe('Main St');
expect(result.current.weather).toBe('sunny');
});
it('FE-JRN-ENTRYFORM-018: quick capture leaves a located entry and a reader alone', () => {
setup({ quickCapture: true });
setup({ quickCapture: true, readOnly: true, entry: entry({ location_lat: null, location_lng: null }) });
expect(locate).not.toHaveBeenCalled();
});
it('FE-JRN-ENTRYFORM-019: a quick capture without a position says why', async () => {
locate.mockRejectedValue(new Error('timeout'));
const { result } = setup({ quickCapture: true, entry: entry({ location_lat: null, location_lng: null }) });
await waitFor(() => expect(result.current.locating).toBe(false));
expect(result.current.locationError).not.toBe('');
});
it('FE-JRN-ENTRYFORM-020: no route switch for a new entry or one that never had a point', () => {
expect(setup({ entry: entry({ id: 0 }) }).result.current.offersStatsToggle).toBe(false);
expect(setup({ entry: entry({ location_lat: null, location_lng: null }) }).result.current.offersStatsToggle).toBe(
false
);
expect(
setup({ entry: entry({ location_lat: null, location_lng: null, stats_excluded: true }) }).result.current
.offersStatsToggle
).toBe(true);
});
});
@@ -0,0 +1,566 @@
import type React from 'react';
import { useEffect, useMemo, useRef, useState } from 'react';
import { journeyApi, mapsApi, weatherApi } from '../../api/client';
import { getCurrentPositionOnce } from '../../hooks/useGeolocation';
import { usePlaceLanguage } from '../../hooks/usePlaceLanguage';
import { useTranslation } from '../../i18n';
import type { GalleryPhoto, JourneyEntry, JourneyPhoto, JourneyTrip } from '../../store/journeyStore';
import { getApiErrorMessage } from '../../types';
import { normalizeImageFiles } from '../../utils/convertHeic';
import { localIsoDate } from '../../utils/localDate';
import type { ResilientResult, UploadProgress } from '../../utils/uploadQueue';
import { useToast } from '../shared/Toast';
import type { ProviderPhotoGroup } from './JourneyDetailPageProviderPicker';
import { geoOnceErrorKey, isValidGeoPoint } from './journeyGeo';
import { journeyWeatherCategory } from './journeyWeather';
import { useEntryPhotoOrder } from './useEntryPhotoOrder';
import { useJourneyTripSuggestion } from './useJourneyTripSuggestion';
export type PendingProviderGroup = ProviderPhotoGroup & { provider: string };
export interface LocationResult {
name: string;
address?: string;
lat: number;
lng: number;
}
export interface JourneyEntryFormOptions {
entry: JourneyEntry;
journeyId: number;
trips: JourneyTrip[];
galleryPhotos: GalleryPhoto[];
onSave: (data: Record<string, unknown>, existingEntryId?: number) => Promise<number>;
onUploadPhotos: (
entryId: number,
files: File[],
cbs?: { onProgress?: (p: UploadProgress) => void }
) => Promise<ResilientResult<JourneyPhoto>>;
onAddProviderPhotos?: (entryId: number, group: PendingProviderGroup) => Promise<void>;
onDone: () => void;
/** A viewer only reads the entry: no trip suggestion, no location probe. */
readOnly?: boolean;
/** Opened from quick capture: locate the traveller and fill in the place and the weather. */
quickCapture?: boolean;
/** Start an empty pros or cons list with one blank row to type into (the desktop dialog). */
blankVerdictRow?: boolean;
/** The form edits tags and a save sends them (the phone sheet). */
withTags?: boolean;
/** Moving the point alone, without renaming it, counts as an unsaved change (the desktop dialog). */
dirtyOnCoordinates?: boolean;
/** Toast a failed save and swallow it (the desktop dialog); otherwise the save rejects. */
toastSaveError?: boolean;
/** Show a failed "use my location" under the field (the phone sheet) instead of as a toast. */
inlineLocateError?: boolean;
/** Fill an empty weather field from the forecast once the place and day are known (the desktop dialog). */
autoFillWeather?: boolean;
}
/** Joined non blank rows, the way a verdict list is compared with the saved one. */
const joinRows = (rows: string[]) => rows.filter((r) => r.trim()).join('\n');
/**
* The journey entry form behind both the desktop editor dialog and the phone entry
* sheet, which render their own markup over it: every field, the photo queue (uploads,
* gallery links, provider photos) that only goes out on save, the location search and
* "use my location", the verdict rows, and the save itself, which reuses the entry a
* first save created when a later step of it fails (#1808).
*/
export function useJourneyEntryForm({
entry,
journeyId,
trips,
galleryPhotos,
onSave,
onUploadPhotos,
onAddProviderPhotos,
onDone,
readOnly = false,
quickCapture = false,
blankVerdictRow = false,
withTags = false,
dirtyOnCoordinates = false,
toastSaveError = false,
inlineLocateError = false,
autoFillWeather = false,
}: JourneyEntryFormOptions) {
const { t, language } = useTranslation();
const placeLang = usePlaceLanguage();
const toast = useToast();
const initialRows = (rows: string[] | undefined) => (blankVerdictRow ? (rows?.length ? rows : ['']) : (rows ?? []));
const [title, setTitle] = useState(entry.title || '');
const [story, setStory] = useState(entry.story || '');
const [entryDate, setEntryDate] = useState(entry.entry_date || localIsoDate());
const [entryTime, setEntryTime] = useState(entry.entry_time?.slice(0, 5) || '');
const [locationName, setLocationName] = useState(entry.location_name || '');
const [locationLat, setLocationLat] = useState<number | null>(entry.location_lat ?? null);
const [locationLng, setLocationLng] = useState<number | null>(entry.location_lng ?? null);
const [locationQuery, setLocationQuery] = useState('');
const [locationResults, setLocationResults] = useState<LocationResult[]>([]);
const [locationSearching, setLocationSearching] = useState(false);
const [showLocationResults, setShowLocationResults] = useState(false);
const [locating, setLocating] = useState(false);
const [locationError, setLocationError] = useState('');
const locationTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const [mood, setMood] = useState(entry.mood || '');
const [weather, setWeather] = useState(entry.weather || '');
const [statsExcluded, setStatsExcluded] = useState(entry.stats_excluded ?? false);
// The trip this day belongs to, when the journey does not follow it yet (#2265).
const tripSuggestion = useJourneyTripSuggestion(
journeyId,
trips.map((tr) => tr.trip_id),
entryDate,
!readOnly
);
const [isDraft, setIsDraft] = useState(entry.is_draft ?? false);
const [pros, setPros] = useState<string[]>(initialRows(entry.pros_cons?.pros));
const [cons, setCons] = useState<string[]>(initialRows(entry.pros_cons?.cons));
const [tags, setTags] = useState<string[]>(entry.tags ?? []);
const [tagInput, setTagInput] = useState('');
const [saving, setSaving] = useState(false);
const [uploadProgress, setUploadProgress] = useState<{ done: number; total: number } | null>(null);
const [photos, setPhotos] = useState<(JourneyPhoto | GalleryPhoto)[]>(entry.photos || []);
// Drag a photo onto another's place, or send it to the front in one request (#824).
const photoOrder = useEntryPhotoOrder(entry.id, photos, setPhotos);
const canReorder = entry.id > 0 && photos.length > 1;
const [pendingFiles, setPendingFiles] = useState<File[]>([]);
// Minting the preview URL inline in the markup would hand out a fresh blob on
// every keystroke in the story field and never give one back.
const pendingPreviews = useMemo(() => pendingFiles.map((f) => URL.createObjectURL(f)), [pendingFiles]);
useEffect(
() => () => {
pendingPreviews.forEach((url) => URL.revokeObjectURL(url));
},
[pendingPreviews]
);
const [pendingLinkIds, setPendingLinkIds] = useState<number[]>([]);
const [pendingProviderGroups, setPendingProviderGroups] = useState<PendingProviderGroup[]>([]);
const [showGalleryPick, setShowGalleryPick] = useState(false);
// Which verdict row to put the caret in after the next render. Enter adds a row
// and the caret has to follow it, or the key does half a job.
const verdictFocusRef = useRef<string | null>(null);
// A save that creates the entry and then fails on the photos keeps the form open;
// without the id of what was just created, the retry would create a second entry (#1808).
const persistedEntryIdRef = useRef<number | null>(entry.id > 0 ? entry.id : null);
const isDirty =
title !== (entry.title || '') ||
story !== (entry.story || '') ||
entryDate !== (entry.entry_date || localIsoDate()) ||
entryTime !== (entry.entry_time?.slice(0, 5) || '') ||
locationName !== (entry.location_name || '') ||
(dirtyOnCoordinates &&
((locationLat ?? null) !== (entry.location_lat ?? null) ||
(locationLng ?? null) !== (entry.location_lng ?? null))) ||
mood !== (entry.mood || '') ||
weather !== (entry.weather || '') ||
statsExcluded !== (entry.stats_excluded ?? false) ||
isDraft !== (entry.is_draft ?? false) ||
joinRows(pros) !== (entry.pros_cons?.pros ?? []).join('\n') ||
joinRows(cons) !== (entry.pros_cons?.cons ?? []).join('\n') ||
(withTags && tags.join('\n') !== (entry.tags ?? []).join('\n')) ||
pendingFiles.length > 0 ||
pendingLinkIds.length > 0 ||
pendingProviderGroups.length > 0;
const availableGalleryPhotos = galleryPhotos.filter((gp) => !photos.some((p) => p.id === gp.id));
const queuedProviderPhotos = pendingProviderGroups.reduce((sum, group) => sum + group.assetIds.length, 0);
/** The provider assets already on the entry or queued for it, so the picker can mark them. */
const providerAssetIds = (provider: string | null): Set<string> => {
const ids = new Set<string>();
if (!provider) return ids;
photos.forEach((photo) => {
if (photo.provider === provider && photo.asset_id) ids.add(photo.asset_id);
});
pendingProviderGroups.forEach((group) => {
if (group.provider === provider) group.assetIds.forEach((assetId) => ids.add(assetId));
});
return ids;
};
const contextLocation = isValidGeoPoint({ lat: locationLat ?? Number.NaN, lng: locationLng ?? Number.NaN })
? { 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);
// Quick capture locates the traveller once and names the spot and the weather there.
useEffect(() => {
if (!quickCapture || readOnly || entry.location_lat != null || entry.location_lng != null) return;
let active = true;
setLocating(true);
getCurrentPositionOnce({ enableHighAccuracy: true, maximumAge: 60_000, timeout: 10_000 }).then(
async (pos) => {
if (!active) return;
setLocationLat(pos.lat);
setLocationLng(pos.lng);
const [placeResult, weatherResult] = await Promise.allSettled([
mapsApi.reverse(pos.lat, pos.lng, placeLang),
weatherApi.getCurrent(pos.lat, pos.lng, language),
]);
if (!active) return;
if (placeResult.status === 'fulfilled') {
setLocationName(placeResult.value.name || placeResult.value.address || '');
}
if (weatherResult.status === 'fulfilled' && !weatherResult.value.error) {
setWeather(
(current) => current || journeyWeatherCategory(weatherResult.value.main, weatherResult.value.description)
);
}
setLocating(false);
},
(err) => {
if (!active) return;
setLocationError(t(geoOnceErrorKey(err)));
setLocating(false);
}
);
return () => {
active = false;
};
}, [quickCapture, readOnly, entry.location_lat, entry.location_lng, entry.entry_date, t, language, placeLang]);
/**
* Fill the weather in from the forecast once the entry knows where and when. The
* date decides the source on the server: today comes from the forecast, a backdated
* day from the ERA5 archive, so writing up last Tuesday gets last Tuesday's weather.
*
* Only ever fills an empty field, and each place and day is tried once, so a cleared
* icon stays cleared and a chosen one is never overwritten.
*/
const weatherTriedRef = useRef<string | null>(null);
useEffect(() => {
if (!autoFillWeather) return;
if (typeof locationLat !== 'number' || typeof locationLng !== 'number') return;
if (weather) return;
const key = `${locationLat.toFixed(3)},${locationLng.toFixed(3)},${entryDate}`;
if (weatherTriedRef.current === key) return;
weatherTriedRef.current = key;
let active = true;
weatherApi
.get(locationLat, locationLng, entryDate, language)
.then((result) => {
// An error shaped answer carries no `main`, and the dev only schema check
// does not stop it reaching here in production.
if (!active || !result || result.error || typeof result.main !== 'string') return;
const category = journeyWeatherCategory(result.main, result.description ?? '');
// Re-checked rather than trusted from the closure: the request is a
// round trip and the traveller may have picked an icon while it was out.
setWeather((current) => current || category);
})
.catch(() => {
/* no weather is a fine outcome for a journal entry */
});
return () => {
active = false;
};
}, [autoFillWeather, locationLat, locationLng, entryDate, weather, language]);
/**
* Enter in a pro or con opens the next one, directly below the one you are in, so a
* list of short things needs no button press between every item (#2299).
*/
const addVerdictRow = (list: 'pros' | 'cons', index: number) => {
const [values, setValues] = list === 'pros' ? ([pros, setPros] as const) : ([cons, setCons] as const);
const next = [...values];
next.splice(index + 1, 0, '');
setValues(next);
verdictFocusRef.current = `${list}-${index + 1}`;
};
/** Give the caret to the row `addVerdictRow` just made, once React has drawn it. */
const verdictRowRef = (key: string) => (el: HTMLInputElement | null) => {
if (el && verdictFocusRef.current === key) {
verdictFocusRef.current = null;
el.focus();
}
};
const addTag = () => {
// Trailing commas dropped by a scan, not /,+$/: an unanchored ,+ before $ has to
// retry from every comma in the run, so a pasted string of them freezes the tab.
const trimmed = tagInput.trim();
let end = trimmed.length;
while (end > 0 && trimmed[end - 1] === ',') end--;
const value = trimmed.slice(0, end);
if (!value) return;
if (!tags.includes(value)) setTags((prev) => [...prev, value]);
setTagInput('');
};
const handleSave = async () => {
setSaving(true);
try {
const entryId = await onSave(
{
title: title || null,
story: story || null,
entry_date: entryDate,
entry_time: entryTime || null,
location_name: locationName || null,
location_lat: locationLat,
location_lng: locationLng,
stats_excluded: offersStatsToggle ? statsExcluded : undefined,
is_draft: isDraft,
mood: mood || null,
weather: weather || null,
...(withTags ? { tags: tags.filter((tag) => tag.trim()) } : {}),
pros_cons: { pros: pros.filter((p) => p.trim()), cons: cons.filter((c) => c.trim()) },
// An explicit Save is the user saying this suggestion is now their entry;
// it does not need a story to earn that (#2008).
type: entry.type === 'skeleton' ? 'entry' : undefined,
},
persistedEntryIdRef.current ?? undefined
);
if (entryId > 0) persistedEntryIdRef.current = entryId;
// Upload the queued files once the entry exists.
if (pendingFiles.length > 0 && entryId) {
const toUpload = pendingFiles;
setUploadProgress({ done: 0, total: toUpload.length });
try {
const { failed } = await onUploadPhotos(entryId, toUpload, {
onProgress: (p) => setUploadProgress({ done: p.done, total: p.total }),
});
setPendingFiles(failed);
if (failed.length > 0) {
toast.error(
t('journey.editor.uploadPartialFailed', { failed: String(failed.length), total: String(toUpload.length) })
);
}
} catch (err) {
toast.error(getApiErrorMessage(err, t('journey.editor.uploadFailed')));
} finally {
setUploadProgress(null);
}
}
// Link the gallery photos picked before the save.
if (pendingLinkIds.length > 0 && entryId) {
for (const photoId of pendingLinkIds) {
try {
await journeyApi.linkPhoto(entryId, photoId);
} catch {
/* the linked photo stays in the gallery */
}
}
}
if (pendingProviderGroups.length > 0 && entryId && onAddProviderPhotos) {
const failed: PendingProviderGroup[] = [];
for (const group of pendingProviderGroups) {
try {
await onAddProviderPhotos(entryId, group);
} catch {
failed.push(group);
}
}
if (failed.length > 0) {
// Keep the form open with the failed groups queued so the next save
// retries them instead of losing the selection.
setPendingProviderGroups(failed);
toast.error(
t('journey.editor.externalPhotosPartialFailed', {
failed: String(failed.length),
total: String(pendingProviderGroups.length),
})
);
return;
}
setPendingProviderGroups([]);
}
onDone();
} catch (err) {
if (!toastSaveError) throw err;
// Neither the page callback nor journeyStore toasts, so without this the
// whole entry just fails to save with no sign of it.
toast.error(getApiErrorMessage(err, t('journey.settings.saveFailed')));
return;
} finally {
setSaving(false);
}
};
/** A gallery photo picked for the entry: linked at once to a saved entry, queued for a new one. */
const pickGalleryPhoto = async (gp: GalleryPhoto) => {
if (entry.id > 0) {
try {
const linked = await journeyApi.linkPhoto(entry.id, gp.id);
if (linked) setPhotos((prev) => [...prev, linked]);
} catch {
/* keep the picker open on failure */
}
} else {
setPendingLinkIds((prev) => [...prev, gp.id]);
setPhotos((prev) => [...prev, gp]);
}
};
/** A photo taken off the entry; its gallery row is kept. */
const removePhoto = async (p: JourneyPhoto | GalleryPhoto) => {
setPhotos((prev) => prev.filter((x) => x.id !== p.id));
if (entry.id > 0) {
try {
await journeyApi.unlinkPhoto(entry.id, p.id);
} catch {
/* refreshed on the next load */
}
} else {
setPendingLinkIds((prev) => prev.filter((id) => id !== p.id));
}
};
const handleFileChange = async (e: React.ChangeEvent<HTMLInputElement>) => {
const files = e.target.files;
if (!files?.length) return;
// Queue files locally until Save so cancel and close actually discard.
const normalized = await normalizeImageFiles(files);
setPendingFiles((prev) => [...prev, ...normalized]);
};
const searchLocation = (query: string) => {
setLocationQuery(query);
setShowLocationResults(true);
if (locationTimerRef.current) clearTimeout(locationTimerRef.current);
if (query.trim().length < 2) {
setLocationResults([]);
return;
}
locationTimerRef.current = setTimeout(async () => {
setLocationSearching(true);
try {
const res = await mapsApi.search(query, placeLang);
setLocationResults(
(res.places || [])
.slice(0, 6)
.map((p: { name: string; address?: string; lat: number | string; lng: number | string }) => ({
name: p.name,
address: p.address,
lat: Number(p.lat),
lng: Number(p.lng),
}))
);
} catch {
setLocationResults([]);
} finally {
setLocationSearching(false);
}
}, 400);
};
const pickLocation = (r: LocationResult) => {
setLocationName(r.name);
setLocationLat(r.lat);
setLocationLng(r.lng);
setLocationQuery('');
setShowLocationResults(false);
setLocationResults([]);
};
const handleUseCurrentLocation = async () => {
if (locating) return;
setLocating(true);
if (inlineLocateError) setLocationError('');
try {
const pos = await getCurrentPositionOnce();
// Fill coordinates right away; the name is refined below once the
// reverse geocode comes back.
const fallbackName = `${pos.lat.toFixed(5)}, ${pos.lng.toFixed(5)}`;
if (locationTimerRef.current) clearTimeout(locationTimerRef.current);
setLocationSearching(false);
setLocationLat(pos.lat);
setLocationLng(pos.lng);
setLocationName(fallbackName);
setLocationQuery('');
setLocationResults([]);
setShowLocationResults(false);
try {
const data = await mapsApi.reverse(pos.lat, pos.lng, placeLang);
const name = data.name || data.address;
// Only replace the coordinate fallback; don't clobber a search result
// the user may have picked while the reverse call was in flight.
if (name) setLocationName((prev) => (prev === fallbackName ? name : prev));
} catch {
/* best effort: keep the coordinate fallback */
}
} catch (err) {
if (inlineLocateError) setLocationError(t(geoOnceErrorKey(err)));
else toast.error(t(geoOnceErrorKey(err)));
} finally {
setLocating(false);
}
};
return {
title,
setTitle,
story,
setStory,
entryDate,
setEntryDate,
entryTime,
setEntryTime,
locationName,
locationLat,
locationLng,
locationQuery,
locationResults,
locationSearching,
showLocationResults,
setShowLocationResults,
locating,
locationError,
mood,
setMood,
weather,
setWeather,
statsExcluded,
setStatsExcluded,
tripSuggestion,
isDraft,
setIsDraft,
pros,
setPros,
cons,
setCons,
tags,
setTags,
tagInput,
setTagInput,
saving,
uploadProgress,
photos,
photoOrder,
canReorder,
pendingFiles,
setPendingFiles,
pendingPreviews,
pendingProviderGroups,
setPendingProviderGroups,
showGalleryPick,
setShowGalleryPick,
isDirty,
availableGalleryPhotos,
queuedProviderPhotos,
providerAssetIds,
contextLocation,
offersStatsToggle,
addVerdictRow,
verdictRowRef,
addTag,
handleSave,
handleFileChange,
pickGalleryPhoto,
removePhoto,
searchLocation,
pickLocation,
handleUseCurrentLocation,
};
}
@@ -1,41 +1,24 @@
import { useEffect, useMemo, useRef, useState } from 'react'
import { localIsoDate } from '../../../utils/localDate'
import { Briefcase, Camera, Plus, Image, Images, X, MapPin, Locate, Trash2, CheckCircle2, MinusCircle, ChevronUp, ChevronDown, EyeOff, Play } from 'lucide-react'
import MSheet from '../../components/MSheet'
import MIconBtn from '../../components/MIconBtn'
import MToggle from '../../components/MToggle'
import { useTranslation } from '../../../i18n'
import { useToast } from '../../../components/shared/Toast'
import CustomTimePicker from '../../../components/shared/CustomTimePicker'
import { CustomDatePicker } from '../../../components/shared/CustomDateTimePicker'
import { journeyApi, mapsApi, weatherApi } from '../../../api/client'
import { getApiErrorMessage } from '../../../types'
import { normalizeImageFiles } from '../../../utils/convertHeic'
import { isVideoFile } from '../../../utils/videoPoster'
import { getCurrentPositionOnce } from '../../../hooks/useGeolocation'
import type { ResilientResult, UploadProgress } from '../../../utils/uploadQueue'
import type { JourneyEntry, JourneyPhoto, GalleryPhoto, JourneyTrip } from '../../../store/journeyStore'
import { useAddonStore } from '../../../store/addonStore'
import { photoUrl, posterlessVideo, geoOnceErrorKey, isValidGeoPoint } from '../../../pages/journeyDetail/JourneyDetailPage.helpers'
import { photoUrl, posterlessVideo } from '../../../pages/journeyDetail/JourneyDetailPage.helpers'
import JournalBody from '../../../components/Journey/JournalBody'
import { ProviderPicker, type ProviderPhotoGroup } from '../../../components/Journey/JourneyDetailPageProviderPicker'
import { journeyWeatherCategory, MOBILE_MOODS, MOBILE_WEATHERS } from './mobileJourneyMeta'
import { useJourneyTripSuggestion } from '../../../components/Journey/useJourneyTripSuggestion'
import { useEntryPhotoOrder } from '../../../components/Journey/useEntryPhotoOrder'
import { usePlaceLanguage } from '../../../hooks/usePlaceLanguage'
import { ProviderPicker } from '../../../components/Journey/JourneyDetailPageProviderPicker'
import { MOBILE_MOODS, MOBILE_WEATHERS } from './mobileJourneyMeta'
import { useJourneyEntryForm, type PendingProviderGroup } from '../../../components/Journey/useJourneyEntryForm'
const PRO_COLOR = '#2FA37A'
const CON_COLOR = '#D6273B'
interface LocationResult {
name: string
address?: string
lat: number
lng: number
}
type PendingProviderGroup = ProviderPhotoGroup & { provider: string }
// A clip with no poster to show: the tinted tile and play mark the gallery grid
// gives it, since an <img> asked for its thumbnail draws the broken glyph (#2341).
function ClipTile() {
@@ -89,54 +72,26 @@ export default function MJourneyEntrySheet({
showVerdict = true, showMood = true, showWeather = true, onMoveEarlier, onMoveLater, onDismiss,
onClose, onSave, onUploadPhotos, onAddProviderPhotos, onDelete, onDone,
}: MJourneyEntrySheetProps) {
const { t, language } = useTranslation()
const placeLang = usePlaceLanguage()
const toast = useToast()
// Which verdict row to hand the caret after the next render — see addVerdictRow.
const verdictFocusRef = useRef<string | null>(null)
const [title, setTitle] = useState(entry.title || '')
const [story, setStory] = useState(entry.story || '')
const [entryDate, setEntryDate] = useState(entry.entry_date || localIsoDate())
const [entryTime, setEntryTime] = useState(entry.entry_time?.slice(0, 5) || '')
const [locationName, setLocationName] = useState(entry.location_name || '')
const [locationLat, setLocationLat] = useState<number | null>(entry.location_lat ?? null)
const [locationLng, setLocationLng] = useState<number | null>(entry.location_lng ?? null)
const [locationQuery, setLocationQuery] = useState('')
const [locationResults, setLocationResults] = useState<LocationResult[]>([])
const [showLocationResults, setShowLocationResults] = useState(false)
const locationTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
const [mood, setMood] = useState(entry.mood || '')
const [weather, setWeather] = useState(entry.weather || '')
const [statsExcluded, setStatsExcluded] = useState(entry.stats_excluded ?? false)
// The trip this day belongs to, when the journey does not follow it yet (#2265).
const tripSuggestion = useJourneyTripSuggestion(entry.journey_id, trips.map(tr => tr.trip_id), entryDate, !readOnly)
const [isDraft, setIsDraft] = useState(entry.is_draft ?? false)
const [pros, setPros] = useState<string[]>(entry.pros_cons?.pros ?? [])
const [cons, setCons] = useState<string[]>(entry.pros_cons?.cons ?? [])
const [tags, setTags] = useState<string[]>(entry.tags ?? [])
const [tagInput, setTagInput] = useState('')
const [photos, setPhotos] = useState<(JourneyPhoto | GalleryPhoto)[]>(entry.photos || [])
// Sends a photo to the front in one request, the same way the desktop editor does (#824).
const photoOrder = useEntryPhotoOrder(entry.id, photos, setPhotos)
const [pendingFiles, setPendingFiles] = useState<File[]>([])
const [pendingLinkIds, setPendingLinkIds] = useState<number[]>([])
const [pendingProviderGroups, setPendingProviderGroups] = useState<PendingProviderGroup[]>([])
const [showGalleryPick, setShowGalleryPick] = useState(false)
const { t } = useTranslation()
const {
title, setTitle, story, setStory, entryDate, setEntryDate, entryTime, setEntryTime,
locationName, locationLat, locationQuery, locationResults, showLocationResults, setShowLocationResults,
locating, locationError, mood, setMood, weather, setWeather, statsExcluded, setStatsExcluded, tripSuggestion,
isDraft, setIsDraft, pros, setPros, cons, setCons, tags, setTags, tagInput, setTagInput, saving, uploadProgress,
photos, photoOrder, pendingFiles, setPendingFiles, pendingPreviews, setPendingProviderGroups,
showGalleryPick, setShowGalleryPick, isDirty, availableGalleryPhotos, queuedProviderPhotos, providerAssetIds,
contextLocation, offersStatsToggle, addVerdictRow, verdictRowRef, addTag, handleSave, handleFileChange,
pickGalleryPhoto, removePhoto, searchLocation, pickLocation, handleUseCurrentLocation,
} = useJourneyEntryForm({
entry, journeyId: entry.journey_id, trips, galleryPhotos, onSave, onUploadPhotos, onAddProviderPhotos, onDone,
readOnly, quickCapture, withTags: true, inlineLocateError: true,
})
const [showExternal, setShowExternal] = useState(false)
const [externalProvider, setExternalProvider] = useState<string | null>(null)
const [providers, setProviders] = useState<{ id: string; name: string }[]>([])
const [saving, setSaving] = useState(false)
const [captureOnly, setCaptureOnly] = useState(quickCapture)
const [locating, setLocating] = useState(false)
const [locationError, setLocationError] = useState('')
const [uploadProgress, setUploadProgress] = useState<{ done: number; total: number } | null>(null)
const fileRef = useRef<HTMLInputElement>(null)
const cameraRef = useRef<HTMLInputElement>(null)
// A save that creates the entry and then fails on the provider photos keeps
// the sheet open; without the id of what was just created, the retry would
// create a second entry (#1808).
const persistedEntryIdRef = useRef<number | null>(entry.id > 0 ? entry.id : null)
// The addon list is already in the store — this only probes which of the
// photo providers is actually connected for this user.
@@ -147,11 +102,6 @@ export default function MJourneyEntrySheet({
[addons],
)
// Minting the preview inside the tile markup would hand out a fresh blob URL on
// every keystroke in the story field, and nothing would ever release them.
const pendingPreviews = useMemo(() => pendingFiles.map(f => URL.createObjectURL(f)), [pendingFiles])
useEffect(() => () => { pendingPreviews.forEach(url => URL.revokeObjectURL(url)) }, [pendingPreviews])
useEffect(() => {
if (readOnly) return
// App.tsx loads the addon list on boot, so this only reacts to it. Kicking off
@@ -174,236 +124,13 @@ export default function MJourneyEntrySheet({
}, [readOnly, addonsLoaded, photoProviders])
const activeProvider = externalProvider || providers[0]?.id || null
const queuedProviderPhotos = pendingProviderGroups.reduce((sum, group) => sum + group.assetIds.length, 0)
const providerExistingAssetIds = new Set<string>()
if (activeProvider) {
photos.forEach(photo => {
if (photo.provider === activeProvider && photo.asset_id) providerExistingAssetIds.add(photo.asset_id)
})
pendingProviderGroups.forEach(group => {
if (group.provider === activeProvider) group.assetIds.forEach(assetId => providerExistingAssetIds.add(assetId))
})
}
const contextLocation = isValidGeoPoint({ lat: locationLat ?? Number.NaN, lng: locationLng ?? Number.NaN })
? { 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
let active = true
setLocating(true)
getCurrentPositionOnce({ enableHighAccuracy: true, maximumAge: 60_000, timeout: 10_000 })
.then(async pos => {
if (!active) return
setLocationLat(pos.lat)
setLocationLng(pos.lng)
const [placeResult, weatherResult] = await Promise.allSettled([
mapsApi.reverse(pos.lat, pos.lng, placeLang),
weatherApi.getCurrent(pos.lat, pos.lng, language),
])
if (!active) return
if (placeResult.status === 'fulfilled') {
setLocationName(placeResult.value.name || placeResult.value.address || '')
}
if (weatherResult.status === 'fulfilled' && !weatherResult.value.error) {
setWeather(current => current || journeyWeatherCategory(weatherResult.value.main, weatherResult.value.description))
}
setLocating(false)
}, err => {
if (!active) return
setLocationError(t(geoOnceErrorKey(err)))
setLocating(false)
})
return () => { active = false }
}, [quickCapture, readOnly, entry.location_lat, entry.location_lng, entry.entry_date, t, language, placeLang])
const isDirty =
title !== (entry.title || '') ||
story !== (entry.story || '') ||
entryDate !== (entry.entry_date || localIsoDate()) ||
entryTime !== (entry.entry_time?.slice(0, 5) || '') ||
locationName !== (entry.location_name || '') ||
mood !== (entry.mood || '') ||
weather !== (entry.weather || '') ||
statsExcluded !== (entry.stats_excluded ?? false) ||
isDraft !== (entry.is_draft ?? 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') ||
pendingFiles.length > 0 ||
pendingLinkIds.length > 0 ||
pendingProviderGroups.length > 0
/**
* Enter opens the next pro or con, directly below the one you are in.
*
* Same behaviour and same reason as the desktop editor: a list of short things
* should not need a button press between every item (discussion #2299). On a
* phone keyboard the return key is right there, which is more of a gain than
* it is with a mouse.
*/
const addVerdictRow = (list: 'pros' | 'cons', index: number) => {
const [values, setValues] = list === 'pros' ? [pros, setPros] as const : [cons, setCons] as const
const next = [...values]
next.splice(index + 1, 0, '')
setValues(next)
verdictFocusRef.current = `${list}-${index + 1}`
}
const verdictRowRef = (key: string) => (el: HTMLInputElement | null) => {
if (el && verdictFocusRef.current === key) {
verdictFocusRef.current = null
el.focus()
}
}
const providerExistingAssetIds = providerAssetIds(activeProvider)
const handleClose = () => {
if (!captureOnly && !readOnly && isDirty && !window.confirm(t('journey.editor.discardChangesConfirm'))) return
onClose()
}
const handleSave = async () => {
setSaving(true)
try {
const entryId = await onSave({
title: title || null,
story: story || null,
entry_date: entryDate,
entry_time: entryTime || null,
location_name: locationName || null,
location_lat: locationLat,
location_lng: locationLng,
stats_excluded: offersStatsToggle ? statsExcluded : undefined,
is_draft: isDraft,
mood: mood || null,
weather: weather || null,
tags: tags.filter(tag => tag.trim()),
pros_cons: { pros: pros.filter(p => p.trim()), cons: cons.filter(c => c.trim()) },
// An explicit Save is the user saying this suggestion is now their entry —
// it does not need a story to earn that (#2008).
type: entry.type === 'skeleton' ? 'entry' : undefined,
}, persistedEntryIdRef.current ?? undefined)
if (entryId > 0) persistedEntryIdRef.current = entryId
if (pendingFiles.length > 0 && entryId) {
const toUpload = pendingFiles
setUploadProgress({ done: 0, total: toUpload.length })
try {
const { failed } = await onUploadPhotos(entryId, toUpload, {
onProgress: p => setUploadProgress({ done: p.done, total: p.total }),
})
setPendingFiles(failed)
if (failed.length > 0) {
toast.error(t('journey.editor.uploadPartialFailed', { failed: String(failed.length), total: String(toUpload.length) }))
}
} catch (err) {
toast.error(getApiErrorMessage(err, t('journey.editor.uploadFailed')))
} finally {
setUploadProgress(null)
}
}
if (pendingLinkIds.length > 0 && entryId) {
for (const photoId of pendingLinkIds) {
try { await journeyApi.linkPhoto(entryId, photoId) } catch { /* linked photo stays in gallery */ }
}
}
if (pendingProviderGroups.length > 0 && entryId && onAddProviderPhotos) {
const failed: PendingProviderGroup[] = []
for (const group of pendingProviderGroups) {
try { await onAddProviderPhotos(entryId, group) } catch { failed.push(group) }
}
if (failed.length > 0) {
// Keep the sheet open with the failed groups queued so the next save
// retries them instead of losing the selection.
setPendingProviderGroups(failed)
toast.error(t('journey.editor.externalPhotosPartialFailed', { failed: String(failed.length), total: String(pendingProviderGroups.length) }))
return
}
setPendingProviderGroups([])
}
onDone()
} finally {
setSaving(false)
}
}
const handleFileChange = async (e: React.ChangeEvent<HTMLInputElement>) => {
const files = e.target.files
if (!files?.length) return
const normalized = await normalizeImageFiles(files)
setPendingFiles(prev => [...prev, ...normalized])
}
const searchLocation = (query: string) => {
setLocationQuery(query)
setShowLocationResults(true)
if (locationTimerRef.current) clearTimeout(locationTimerRef.current)
if (query.trim().length < 2) {
setLocationResults([])
return
}
locationTimerRef.current = setTimeout(async () => {
try {
const res = await mapsApi.search(query, placeLang)
setLocationResults((res.places || []).slice(0, 6).map((p: { name: string; address?: string; lat: number | string; lng: number | string }) => ({
name: p.name, address: p.address, lat: Number(p.lat), lng: Number(p.lng),
})))
} catch {
setLocationResults([])
}
}, 400)
}
const handleUseCurrentLocation = async () => {
if (locating) return
setLocating(true)
setLocationError('')
try {
const pos = await getCurrentPositionOnce()
// Fill coordinates right away; the name is refined below once the
// reverse geocode comes back.
const fallbackName = `${pos.lat.toFixed(5)}, ${pos.lng.toFixed(5)}`
if (locationTimerRef.current) clearTimeout(locationTimerRef.current)
setLocationLat(pos.lat)
setLocationLng(pos.lng)
setLocationName(fallbackName)
setLocationQuery('')
setLocationResults([])
setShowLocationResults(false)
try {
const data = await mapsApi.reverse(pos.lat, pos.lng, placeLang)
const name = data.name || data.address
// Only replace the coordinate fallback — don't clobber a search
// result the user may have picked while the reverse call was in flight.
if (name) setLocationName(prev => (prev === fallbackName ? name : prev))
} catch { /* best effort — keep the coordinate fallback */ }
} catch (err) {
setLocationError(t(geoOnceErrorKey(err)))
} finally {
setLocating(false)
}
}
const addTag = () => {
// Trailing commas dropped by a scan, not /,+$/: an unanchored ,+ before $ has to
// retry from every comma in the run, so a pasted string of them freezes the tab.
const trimmed = tagInput.trim()
let end = trimmed.length
while (end > 0 && trimmed[end - 1] === ',') end--
const value = trimmed.slice(0, end)
if (!value) return
if (!tags.includes(value)) setTags(prev => [...prev, value])
setTagInput('')
}
const availableGalleryPhotos = galleryPhotos.filter(gp => !photos.some(p => p.id === gp.id))
const eyebrow = 'font-geist text-[0.625rem] font-bold uppercase tracking-[.09em] text-m-faint'
const fieldShell = 'rounded-xl border border-[color:var(--m-rowbr)] bg-[color:var(--m-ic)]'
@@ -603,17 +330,7 @@ export default function MJourneyEntrySheet({
type="button"
className="relative w-full overflow-hidden rounded-lg"
style={{ paddingTop: '100%' }}
onClick={async () => {
if (entry.id > 0) {
try {
const linked = await journeyApi.linkPhoto(entry.id, gp.id)
if (linked) setPhotos(prev => [...prev, linked])
} catch { /* keep picker open on failure */ }
} else {
setPendingLinkIds(prev => [...prev, gp.id])
setPhotos(prev => [...prev, gp])
}
}}
onClick={() => pickGalleryPhoto(gp)}
>
{posterlessVideo(gp) ? (
<ClipTile />
@@ -654,14 +371,7 @@ export default function MJourneyEntrySheet({
{!readOnly && (
<button
type="button"
onClick={async () => {
setPhotos(prev => prev.filter(x => x.id !== p.id))
if (entry.id > 0) {
try { await journeyApi.unlinkPhoto(entry.id, p.id) } catch { /* refreshed on next load */ }
} else {
setPendingLinkIds(prev => prev.filter(id => id !== p.id))
}
}}
onClick={() => removePhoto(p)}
aria-label={t('common.delete')}
className="absolute end-[3px] top-[3px] flex h-5 w-5 items-center justify-center rounded-full bg-black/60 text-white"
>
@@ -868,14 +578,7 @@ export default function MJourneyEntrySheet({
<button
key={i}
type="button"
onClick={() => {
setLocationName(r.name)
setLocationLat(r.lat)
setLocationLng(r.lng)
setLocationQuery('')
setShowLocationResults(false)
setLocationResults([])
}}
onClick={() => pickLocation(r)}
className="flex w-full items-start gap-2 border-b border-[color:var(--m-rowbr)] px-3 py-[10px] text-start last:border-0"
>
<MapPin size={13} className="mt-[2px] flex-none text-m-faint" />
@@ -45,17 +45,6 @@ export function weatherMeta(id: string | null | undefined): WeatherMeta | undefi
return id ? MOBILE_WEATHERS.find(w => w.id === id) : undefined
}
export function journeyWeatherCategory(main: string, description: string): string {
const normalizedMain = main.toLowerCase()
const normalizedDescription = description.toLowerCase()
if (normalizedMain === 'clear') return 'sunny'
if (normalizedMain === 'thunderstorm') return 'stormy'
if (normalizedMain === 'snow') return 'cold'
if (normalizedMain === 'rain' || normalizedMain === 'drizzle') return 'rainy'
if (normalizedDescription.includes('partly') || normalizedDescription.includes('teilweise')) return 'partly'
return 'cloudy'
}
/** Journey cover_image is stored relative — prefix /uploads/ unless it already is. */
export function journeyCoverSrc(coverImage: string | null | undefined): string | null {
if (!coverImage) return null
@@ -1,21 +1,10 @@
import { PROVIDER_SELECT_ALL_MAX_PAGES } from '@trek/shared';
import { GeoOnceError } from '../../hooks/useGeolocation';
import { isValidGeoPoint, type GeoPoint } from '../../components/Journey/journeyGeo';
import type { JourneyEntry } from '../../store/journeyStore';
import { localIsoDate } from '../../utils/localDate';
import { GRADIENTS } from './JourneyDetailPage.constants';
// Shared by the desktop entry editor and the mobile entry sheet so a failed
// one-shot position fix surfaces the same translated message everywhere.
export function geoOnceErrorKey(err: unknown): string {
const code = err instanceof GeoOnceError ? err.code : 'unavailable';
return code === 'permission-denied'
? 'journey.editor.locationPermissionDenied'
: code === 'timeout'
? 'journey.editor.locationTimeout'
: code === 'insecure-context'
? 'journey.editor.locationInsecureContext'
: 'journey.editor.locationUnavailable';
}
export { geoOnceErrorKey, isValidGeoPoint, type GeoPoint } from '../../components/Journey/journeyGeo';
export function pickGradient(id: number): string {
return GRADIENTS[id % GRADIENTS.length];
@@ -187,23 +176,6 @@ export interface ProviderPhotoAsset {
[key: string]: unknown;
}
export interface GeoPoint {
lat: number;
lng: number;
}
export function isValidGeoPoint(point: Partial<GeoPoint> | null | undefined): point is GeoPoint {
return (
!!point &&
Number.isFinite(point.lat) &&
Number.isFinite(point.lng) &&
point.lat >= -90 &&
point.lat <= 90 &&
point.lng >= -180 &&
point.lng <= 180
);
}
/** Return the great-circle distance in metres between two coordinates. */
export function distanceBetweenGeoPoints(a: GeoPoint, b: GeoPoint): number {
const earthRadius = 6371000;
@@ -1,5 +1,5 @@
import { describe, expect, it } from 'vitest';
import { journeyWeatherCategory } from '../../../../src/mobile/screens/journey/mobileJourneyMeta';
import { journeyWeatherCategory } from '../../../../src/components/Journey/journeyWeather';
describe('journeyWeatherCategory', () => {
it.each([