Files
TREK/client/src/store/journeyStore.ts
T
Maurice 94c369b913 feat(journey): prepare the recorded days of a journey's trips in the background, switched on per journey, with each day's state
Journey Settings get "Prepare recorded days automatically" under GPX Tracks,
off by default and set by the owner. With it on, the Dawarich background
check also reads the linked trips of the journey for the owner and every
editor with a connection of their own, within the same 400 day reach and
windows as the trip sync, and only for new and changed stays: a trip the
person travels on is not asked twice, and the stays of a trip they are not
on are filed under no trip. Nothing is written into the journey; the stays
wait at their day, and a stay taken over or left out is never offered again.

Under the switch the caller sees when their stays were last fetched, or why
not (no connection, no dated trip, background check off, the reason of the
last failure beside the state still shown), with a "Fetch now" that the
switch also runs when it is turned on. A day of the trips that is not final
yet carries a dot on its take-over button, on the phone in the stay sheet,
and the tooltip says whether it is still running, was fetched before it
ended, was not fetched yet or shows an older state after a failed refresh.
The day review says so for today too.

Server: migration 046180 adds journeys.auto_prepare_recordings and
journey_recording_prep (the last attempt, last success and last error per
journey and person). JourneyRecordingPrepService in the journey domain lists
the prepared trips and keeps the status; DawarichSyncService walks those
trips in the same loop as the person's own and records each journey's
outcome. GET /api/journeys/:id/recordings/prep answers the caller's status
under a @trek/shared contract, the MCP tool get_journey_recording_prep reads
the same, and update_journey takes auto_prepare_recordings like the PUT
contract. The column stays out of the plugin output. All 27 locales carry
the strings; the Journey, Dawarich and MCP tool wiki pages describe it.
2026-10-21 17:39:33 +02:00

477 lines
16 KiB
TypeScript

import { create } from 'zustand'
import { journeyApi } from '../api/client'
import type { ProviderPhotosAdded } from '../api/providerPhotoBatches'
import { uploadFilesResilient, type ResilientResult, type UploadProgress } from '../utils/uploadQueue'
import { captureVideoPoster, isVideoFile } from '../utils/videoPoster'
export interface Journey {
id: number
user_id: number
title: string
subtitle?: string | null
cover_gradient?: string | null
cover_image?: string | null
status: 'draft' | 'active' | 'completed' | 'archived'
/** The state the owner set by hand (#762); null or absent follows the trip dates. */
status_override?: 'draft' | 'live' | 'completed' | null
/**
* Draw the GPX tracks of the journey's linked trips on its map (#2194).
* Off unless the owner asks for it — see the migration for why the default
* flipped. Optional so a cached pre-#2194 journey still parses.
*
* Typed as the wire shape, not as the concept: the column is INTEGER and the
* journey rows are read with SELECT *, so what arrives is 0 or 1. Declaring it
* boolean would make `=== true` compile and never hold.
*/
show_trip_tracks?: number
/** Entries without a place take the position of their first geotagged photo (#1003); 0/1, off by default. */
photo_location?: number
/** The Dawarich sync prepares the recorded days of the linked trips (JRN-03); 0/1, off by default. */
auto_prepare_recordings?: number
/**
* Which of the optional entry fields this journey uses (discussion #2299).
*
* Same wire shape and same reason as `show_trip_tracks` above: INTEGER columns
* read with SELECT *, so 0 or 1 arrives, and `undefined` on a cached journey
* from before the migration — which reads as on, matching the column default.
*/
show_verdict?: number
show_mood?: number
show_weather?: number
created_at: number
updated_at: number
}
export interface JourneyEntry {
id: number
journey_id: number
source_trip_id?: number | null
source_place_id?: number | null
source_trip_name?: string | null
author_id: number
type: 'entry' | 'checkin' | 'skeleton'
title?: string | null
story?: string | null
entry_date: string
entry_time?: string | null
location_name?: string | null
location_lat?: number | null
location_lng?: number | null
/** ISO 3166-1 alpha-2, resolved server-side from the coordinates. Drives the card's flag. */
country_code?: string | null
mood?: string | null
weather?: string | null
tags?: string[]
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
/** A trip-derived suggestion the traveller waved away. Never sent by the server; the read paths drop it. */
dismissed?: boolean
/** A draft (#696): contributors see it, the journey's public share page does not. */
is_draft?: boolean
photos: JourneyPhoto[]
created_at: number
updated_at: number
}
export interface JourneyPhoto {
id: number
entry_id: number
photo_id: number
caption?: string | null
sort_order: number
shared: number
created_at: number
// Joined from trek_photos for display
provider?: string
asset_id?: string | null
owner_id?: number | null
file_path?: string | null
thumbnail_path?: string | null
width?: number | null
height?: number | null
// 'image' (default) or 'video' (#823)
media_type?: string | null
duration_ms?: number | null
}
export interface GalleryPhoto {
id: number
journey_id: number
photo_id: number
caption?: string | null
shared: number
sort_order: number
created_at: number
// Joined from trek_photos for display
provider?: string
asset_id?: string | null
owner_id?: number | null
file_path?: string | null
thumbnail_path?: string | null
width?: number | null
height?: number | null
// 'image' (default) or 'video' (#823)
media_type?: string | null
duration_ms?: number | null
/** Where and when it was taken, from EXIF or the provider (#1614); null when unknown. */
taken_at?: string | null
lat?: number | null
lng?: number | null
}
export interface JourneyTrip {
trip_id: number
added_at: number
title: string
start_date?: string | null
end_date?: string | null
cover_image?: string | null
currency?: string
place_count: number
}
export interface JourneyContributor {
journey_id: number
user_id: number
role: 'owner' | 'editor' | 'viewer'
added_at: number
username: string
avatar?: string | null
}
/** Someone a trip's linked journal lets in, with the trip they come through (#2683). */
export interface JourneyTripAccess {
user_id: number
role: 'editor' | 'viewer'
username: string
avatar?: string | null
trip_id: number
trip_title: string
}
export interface JourneyDetail extends Journey {
entries: JourneyEntry[]
gallery: GalleryPhoto[]
trips: JourneyTrip[]
contributors: JourneyContributor[]
/** Who the trips' journal links let in besides the contributors, managed in those trips. */
trip_access?: JourneyTripAccess[]
stats: { entries: number; photos: number; places: number }
hide_skeletons?: boolean
/** How many suggestions were waved away one at a time, so the settings sheet can offer them back. */
dismissed_count?: number
my_role?: 'owner' | 'editor' | 'viewer'
}
/** Assets picked from one provider, with what the add needs besides their ids. */
interface ProviderAssets {
assetIds: string[]
passphrase?: string
mediaTypes?: string[]
}
interface JourneyState {
journeys: Journey[]
current: JourneyDetail | null
loading: boolean
notFound: boolean
/**
* The phone's journey screen is showing its Gallery tab.
*
* Lives here because the dock's FAB is a sibling of that screen: on the
* Gallery the one big action is uploading a photo, not adding an entry, and a
* sibling cannot read another component's state any other way.
*/
mobileGalleryOpen: boolean
setMobileGalleryOpen: (open: boolean) => void
loadJourneys: () => Promise<void>
loadJourney: (id: number) => Promise<void>
createJourney: (data: { title: string; subtitle?: string; trip_ids?: number[] }) => Promise<Journey>
updateJourney: (id: number, data: Record<string, unknown>) => Promise<void>
deleteJourney: (id: number) => Promise<void>
createEntry: (journeyId: number, data: Record<string, unknown>) => Promise<JourneyEntry>
updateEntry: (entryId: number, data: Record<string, unknown>) => Promise<void>
deleteEntry: (entryId: number) => Promise<void>
reorderEntries: (journeyId: number, orderedIds: number[]) => Promise<void>
uploadPhotos: (entryId: number, files: File[], cbs?: { onProgress?: (p: UploadProgress) => void }) => Promise<ResilientResult<JourneyPhoto>>
uploadGalleryPhotos: (journeyId: number, files: File[], cbs?: { onProgress?: (p: UploadProgress) => void }) => Promise<ResilientResult<GalleryPhoto>>
/**
* Photos from a connected provider (Immich, Synology Photos) onto one entry,
* or onto the gallery. They answer what was added and leave `current` alone:
* the caller reloads the journey, because where a new photo sorts depends on
* the capture time the server looks up after answering (#1587). A large add
* goes out in batches, and one that fails part way rejects with a
* ProviderPhotoBatchError that carries what the earlier batches stored.
*/
addProviderPhotos: (entryId: number, provider: string, group: ProviderAssets) => Promise<ProviderPhotosAdded>
addProviderPhotosToGallery: (journeyId: number, provider: string, group: ProviderAssets) => Promise<ProviderPhotosAdded>
unlinkPhoto: (entryId: number, journeyPhotoId: number) => Promise<void>
deleteGalleryPhoto: (journeyId: number, journeyPhotoId: number) => Promise<void>
deletePhoto: (photoId: number) => Promise<void>
clear: () => void
}
/**
* The gallery rows behind pictures that were uploaded onto an entry.
*
* 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 upload route answers with
* the entry's view of that row and, on the paths that have it, the gallery's
* as well; this derives the missing ones, so a picture added from anywhere
* shows up wherever the gallery is read rather than after the journey has been
* fetched again. Rows already in the gallery are skipped, since a retry can
* hand back a picture that is in it.
*/
function galleryRowsFor(
journey: JourneyDetail,
uploaded: JourneyPhoto[],
answered: GalleryPhoto[] | undefined,
): GalleryPhoto[] {
const known = new Set([...(journey.gallery || []), ...(answered || [])].map(p => p.id))
const derived = uploaded
.filter(p => !known.has(p.id))
.map(({ entry_id: _entry, ...rest }) => ({ ...rest, journey_id: journey.id }))
return [...(answered || []), ...derived]
}
export const useJourneyStore = create<JourneyState>((set, get) => ({
journeys: [],
current: null,
loading: false,
notFound: false,
mobileGalleryOpen: false,
setMobileGalleryOpen: (open: boolean) => set({ mobileGalleryOpen: open }),
loadJourneys: async () => {
set({ loading: true })
try {
const data = await journeyApi.list()
set({ journeys: data.journeys || [] })
} finally {
set({ loading: false })
}
},
loadJourney: async (id) => {
const cold = get().current?.id !== id
if (cold) set({ loading: true, notFound: false })
try {
const data = await journeyApi.get(id)
set({ current: data })
} catch (err: any) {
if (err?.response?.status === 404) {
set({ current: null, notFound: true })
}
throw err
} finally {
if (cold) set({ loading: false })
}
},
createJourney: async (data) => {
const journey = await journeyApi.create(data)
set(s => ({ journeys: [journey, ...s.journeys] }))
return journey
},
updateJourney: async (id, data) => {
const updated = await journeyApi.update(id, data)
set(s => ({
journeys: s.journeys.map(j => j.id === id ? { ...j, ...updated } : j),
current: s.current?.id === id ? { ...s.current, ...updated } : s.current,
}))
},
deleteJourney: async (id) => {
await journeyApi.delete(id)
set(s => ({
journeys: s.journeys.filter(j => j.id !== id),
current: s.current?.id === id ? null : s.current,
}))
},
createEntry: async (journeyId, data) => {
const entry = await journeyApi.createEntry(journeyId, data)
entry.photos = entry.photos || []
set(s => {
if (s.current?.id !== journeyId) return s
return { current: { ...s.current, entries: [...s.current.entries, entry] } }
})
return entry
},
updateEntry: async (entryId, data) => {
const updated = await journeyApi.updateEntry(entryId, data)
set(s => {
if (!s.current) return s
return { current: { ...s.current, entries: s.current.entries.map(e => e.id === entryId ? { ...e, ...updated } : e) } }
})
},
deleteEntry: async (entryId) => {
await journeyApi.deleteEntry(entryId)
set(s => {
if (!s.current) return s
return { current: { ...s.current, entries: s.current.entries.filter(e => e.id !== entryId) } }
})
},
reorderEntries: async (journeyId, orderedIds) => {
// Optimistic: push the new sort_order and re-sort locally so the UI
// updates immediately. Server mirrors the same ordering. On failure we
// reload the journey to recover the authoritative state.
const prev = get().current
set(s => {
if (!s.current || s.current.id !== journeyId) return s
const sortMap = new Map(orderedIds.map((id, idx) => [id, idx]))
const entries = s.current.entries.map(e =>
sortMap.has(e.id) ? { ...e, sort_order: sortMap.get(e.id)! } : e
)
entries.sort((a, b) => {
if (a.entry_date !== b.entry_date) return a.entry_date.localeCompare(b.entry_date)
if (a.sort_order !== b.sort_order) return (a.sort_order || 0) - (b.sort_order || 0)
return a.id - b.id
})
return { current: { ...s.current, entries } }
})
try {
await journeyApi.reorderEntries(journeyId, orderedIds)
} catch (err) {
// Roll back to last-known-good state.
if (prev && prev.id === journeyId) set({ current: prev })
throw err
}
},
uploadPhotos: async (entryId, files, cbs) => {
return uploadFilesResilient<JourneyPhoto>(
files,
async (file, opts) => {
const fd = new FormData()
let data: { photos?: JourneyPhoto[]; gallery?: GalleryPhoto[] }
if (isVideoFile(file)) {
// The same two-part upload the gallery has done since #823. Without it
// a clip went to the images-only route and came back 400 (issue #2341).
const { poster, durationMs } = await captureVideoPoster(file)
fd.append('video', file)
if (poster) fd.append('poster', poster, 'poster.jpg')
if (durationMs != null) fd.append('duration_ms', String(durationMs))
data = await journeyApi.uploadEntryVideo(entryId, fd, opts)
} else {
fd.append('photos', file)
data = await journeyApi.uploadPhotos(entryId, fd, opts)
}
const photos: JourneyPhoto[] = data.photos || []
set(s => {
if (!s.current) return s
const gallery = galleryRowsFor(s.current, photos, data.gallery)
return {
current: {
...s.current,
entries: s.current.entries.map(e =>
e.id === entryId ? { ...e, photos: [...(e.photos || []), ...photos] } : e
),
gallery: [...(s.current.gallery || []), ...gallery],
},
}
})
return photos
},
{ onProgress: cbs?.onProgress },
)
},
uploadGalleryPhotos: async (journeyId, files, cbs) => {
return uploadFilesResilient<GalleryPhoto>(
files,
async (file, opts) => {
const fd = new FormData()
let data: { photos?: GalleryPhoto[] }
if (isVideoFile(file)) {
// Video: grab a poster frame + duration in the browser, then upload the
// raw video + poster (#823). No server-side transcoding.
const { poster, durationMs } = await captureVideoPoster(file)
fd.append('video', file)
if (poster) fd.append('poster', poster, 'poster.jpg')
if (durationMs != null) fd.append('duration_ms', String(durationMs))
data = await journeyApi.uploadGalleryVideo(journeyId, fd, opts)
} else {
fd.append('photos', file)
data = await journeyApi.uploadGalleryPhotos(journeyId, fd, opts)
}
const photos: GalleryPhoto[] = data.photos || []
set(s => {
if (!s.current || s.current.id !== journeyId) return s
return { current: { ...s.current, gallery: [...(s.current.gallery || []), ...photos] } }
})
return photos
},
{ onProgress: cbs?.onProgress },
)
},
addProviderPhotos: (entryId, provider, group) =>
journeyApi.addProviderPhotos(entryId, provider, group.assetIds, undefined, group.passphrase, group.mediaTypes),
addProviderPhotosToGallery: (journeyId, provider, group) =>
journeyApi.addProviderPhotosToGallery(journeyId, provider, group.assetIds, group.passphrase, group.mediaTypes),
unlinkPhoto: async (entryId, journeyPhotoId) => {
await journeyApi.unlinkPhoto(entryId, journeyPhotoId)
set(s => {
if (!s.current) return s
return {
current: {
...s.current,
entries: s.current.entries.map(e =>
e.id === entryId ? { ...e, photos: (e.photos || []).filter(p => p.id !== journeyPhotoId) } : e
),
},
}
})
},
deleteGalleryPhoto: async (journeyId, journeyPhotoId) => {
await journeyApi.deleteGalleryPhoto(journeyId, journeyPhotoId)
set(s => {
if (!s.current) return s
return {
current: {
...s.current,
gallery: (s.current.gallery || []).filter(p => p.id !== journeyPhotoId),
entries: s.current.entries.map(e => ({
...e,
photos: (e.photos || []).filter(p => p.id !== journeyPhotoId),
})),
},
}
})
},
deletePhoto: async (photoId) => {
await journeyApi.deletePhoto(photoId)
set(s => {
if (!s.current) return s
return {
current: {
...s.current,
entries: s.current.entries.map(e => ({
...e,
photos: (e.photos || []).filter(p => p.id !== photoId),
})),
},
}
})
},
clear: () => set({ journeys: [], current: null, loading: false, notFound: false }),
}))