mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-11-13 15:51:00 +00:00
94c369b913
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.
477 lines
16 KiB
TypeScript
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 }),
|
|
}))
|