diff --git a/server/src/nest/journey/journey-domain.service.ts b/server/src/nest/journey/journey-domain.service.ts index d5dfe7a5e..e58d943e2 100644 --- a/server/src/nest/journey/journey-domain.service.ts +++ b/server/src/nest/journey/journey-domain.service.ts @@ -2,6 +2,7 @@ import { Injectable } from '@nestjs/common'; import { avatarUrl } from '../common/avatarUrl'; import type { Journey, JourneyEntry, JourneyPhoto, JourneyContributor } from '../../types'; import { decodeEntryRow, type JourneyEntryWire } from './journey-entry-row'; +import { GALLERY_CHRONOLOGICAL_ORDER } from './journey-gallery-order'; import { DatabaseService } from '../database/database.service'; import { RealtimeService } from '../realtime/realtime.service'; import type { JourneyStats, JourneyTrack, TrekWsUserEventName } from '@trek/shared'; @@ -246,7 +247,7 @@ export class JourneyDomainService { const gallery = this.db .prepare( - `SELECT ${GALLERY_SELECT} FROM ${GALLERY_JOIN} WHERE gp.journey_id = ? ORDER BY gp.sort_order ASC, gp.id ASC`, + `SELECT ${GALLERY_SELECT} FROM ${GALLERY_JOIN} WHERE gp.journey_id = ? ${GALLERY_CHRONOLOGICAL_ORDER}`, ) .all(journeyId); diff --git a/server/src/nest/journey/journey-gallery-order.ts b/server/src/nest/journey/journey-gallery-order.ts new file mode 100644 index 000000000..eb1e03dea --- /dev/null +++ b/server/src/nest/journey/journey-gallery-order.ts @@ -0,0 +1,38 @@ +/** + * How the journey gallery is ordered (#2200). + * + * `journey_photos.sort_order` is nothing but MAX+1 at insert time and no surface + * ever writes it again, so ordering by it is ordering by "whenever this got + * added". Upload day one's pictures after day two's and the gallery stays + * shuffled, with nothing in the UI to straighten it out. + * + * The anchor is instead the best time TREK knows for the picture: + * + * 1. its capture time, when EXIF or the photo provider gave one up, + * 2. otherwise the date of the earliest entry it hangs on, which is where the + * journey itself says the reader was standing when it was taken, + * 3. otherwise the moment it was added, the old behaviour and the only thing + * left to go on for a loose photo whose EXIF was stripped. + * + * All three are compared as ISO text. Entry dates are local wall clock and + * capture times are UTC, which only disagree in the hours either side of + * midnight, and a gallery is read by the day. + * + * `sort_order`, then the row id, break ties: insertion order still decides + * between two pictures taken in the same minute, and the id makes the order + * total, so paging through the gallery cannot show or skip a photo twice. + * + * Consumers must alias journey_photos as `gp` and trek_photos as `tp`. + */ +export const GALLERY_CHRONOLOGICAL_ORDER = ` + ORDER BY COALESCE( + NULLIF(tp.taken_at, ''), + (SELECT MIN(je.entry_date || 'T' || COALESCE(NULLIF(je.entry_time, ''), '00:00')) + FROM journey_entry_photos jep + JOIN journey_entries je ON je.id = jep.entry_id + WHERE jep.journey_photo_id = gp.id), + strftime('%Y-%m-%dT%H:%M:%SZ', gp.created_at / 1000, 'unixepoch') + ) ASC, + gp.sort_order ASC, + gp.id ASC +`; diff --git a/server/src/nest/journey/journey-share.service.ts b/server/src/nest/journey/journey-share.service.ts index a0f120aeb..b80d62660 100644 --- a/server/src/nest/journey/journey-share.service.ts +++ b/server/src/nest/journey/journey-share.service.ts @@ -3,6 +3,7 @@ import crypto from 'crypto'; import { DatabaseService } from '../database/database.service'; import { JourneyDomainService } from './journey-domain.service'; import { decodeEntryRow } from './journey-entry-row'; +import { GALLERY_CHRONOLOGICAL_ORDER } from './journey-gallery-order'; import { SettingsService } from '../settings/settings.service'; interface JourneySharePermissions { @@ -185,12 +186,12 @@ export class JourneyShareService { const gallery = this.db.prepare(` SELECT gp.id, gp.journey_id, gp.photo_id, gp.caption, gp.shared, gp.sort_order, gp.created_at, - tkp.provider, tkp.asset_id, tkp.owner_id, tkp.file_path, tkp.thumbnail_path, tkp.width, tkp.height, - tkp.media_type, tkp.duration_ms, tkp.taken_at, tkp.lat, tkp.lng + tp.provider, tp.asset_id, tp.owner_id, tp.file_path, tp.thumbnail_path, tp.width, tp.height, + tp.media_type, tp.duration_ms, tp.taken_at, tp.lat, tp.lng FROM journey_photos gp - JOIN trek_photos tkp ON tkp.id = gp.photo_id + JOIN trek_photos tp ON tp.id = gp.photo_id WHERE gp.journey_id = ? - ORDER BY gp.sort_order + ${GALLERY_CHRONOLOGICAL_ORDER} `).all(row.journey_id) as any[]; const enrichedEntries = entries diff --git a/server/tests/unit/nest/journey-domain.service.test.ts b/server/tests/unit/nest/journey-domain.service.test.ts index abb73df04..e7fe2afee 100644 --- a/server/tests/unit/nest/journey-domain.service.test.ts +++ b/server/tests/unit/nest/journey-domain.service.test.ts @@ -2059,6 +2059,55 @@ describe('journey gallery', () => { const [gallery] = svc.uploadGalleryPhotos(journey.id, user.id, [{ path: 'journey/a.jpg' }]); expect(svc.deleteGalleryPhoto(gallery.id, stranger.id)).toBeNull(); }); + + // #2200: the gallery used to come out in upload order, so pictures caught up + // with after the trip landed behind the ones added while it was running. + it('JOURNEY-SVC-PHOTO-008: the gallery reads in capture order, not upload order', () => { + const { user } = createUser(testDb); + const journey = svc.createJourney(user.id, { title: 'J' }); + + const shot = (path: string, takenAt: string) => { + const [row] = svc.uploadGalleryPhotos(journey.id, user.id, [{ path }]); + testDb.prepare('UPDATE trek_photos SET taken_at = ? WHERE id = ?').run(takenAt, row.photo_id); + }; + + shot('journey/day3.jpg', '2026-05-03T18:00:00.000Z'); + shot('journey/day1.jpg', '2026-05-01T08:30:00.000Z'); + shot('journey/day2.jpg', '2026-05-02T12:00:00.000Z'); + + const gallery = svc.getJourneyFull(journey.id, user.id)!.gallery as { file_path: string }[]; + expect(gallery.map((p) => p.file_path)).toEqual([ + 'journey/day1.jpg', + 'journey/day2.jpg', + 'journey/day3.jpg', + ]); + }); + + it('JOURNEY-SVC-PHOTO-009: a photo with no capture time rides on the date of the stop it hangs on', () => { + const { user } = createUser(testDb); + const journey = svc.createJourney(user.id, { title: 'J' }); + const day1 = svc.createEntry(journey.id, user.id, { entry_date: '2026-05-01', title: 'Day 1' })!; + const day2 = svc.createEntry(journey.id, user.id, { entry_date: '2026-05-02', title: 'Day 2' })!; + + // Day two was sorted out first and day one caught up afterwards, with the + // EXIF gone (an iPhone upload is converted before it leaves the browser). + const [dayTwoPhoto] = svc.uploadGalleryPhotos(journey.id, user.id, [{ path: 'journey/day2.jpg' }]); + const [dayOnePhoto] = svc.uploadGalleryPhotos(journey.id, user.id, [{ path: 'journey/day1.jpg' }]); + const [loose] = svc.uploadGalleryPhotos(journey.id, user.id, [{ path: 'journey/loose.jpg' }]); + svc.linkPhotoToEntry(day2.id, dayTwoPhoto.id, user.id); + svc.linkPhotoToEntry(day1.id, dayOnePhoto.id, user.id); + // Pin the loose photo's upload time so the run does not depend on today's date. + testDb + .prepare('UPDATE journey_photos SET created_at = ? WHERE id = ?') + .run(Date.parse('2026-06-01T00:00:00Z'), loose.id); + + const gallery = svc.getJourneyFull(journey.id, user.id)!.gallery as { file_path: string }[]; + expect(gallery.map((p) => p.file_path)).toEqual([ + 'journey/day1.jpg', + 'journey/day2.jpg', + 'journey/loose.jpg', + ]); + }); }); describe('per-user journey preferences', () => { diff --git a/server/tests/unit/nest/journey-share.service.test.ts b/server/tests/unit/nest/journey-share.service.test.ts index fe0acbece..1da104750 100644 --- a/server/tests/unit/nest/journey-share.service.test.ts +++ b/server/tests/unit/nest/journey-share.service.test.ts @@ -635,6 +635,30 @@ describe('getPublicJourney', () => { expect((result.entries[0] as Record).photos).toEqual([]); // inline photos withheld too }); + // #2200: the reader of a shared journey gets the same chronology as the owner, + // so the public gallery cannot fall back to upload order. + it('JOURNEY-SHARE-031: the public gallery reads in capture order, not upload order', () => { + const { user } = createUser(testDb); + const journey = createJourney(testDb, user.id); + const day1 = createJourneyEntry(testDb, journey.id, user.id, { + type: 'entry', title: 'Day 1', entry_date: '2026-05-01', + }); + const day2 = createJourneyEntry(testDb, journey.id, user.id, { + type: 'entry', title: 'Day 2', entry_date: '2026-05-02', + }); + + const late = insertJourneyPhoto(day2.id, { filePath: '/photos/day2.jpg' }); + insertJourneyPhoto(day1.id, { filePath: '/photos/day1.jpg' }); + testDb.prepare('UPDATE trek_photos SET taken_at = ? WHERE id = ?').run('2026-05-02T16:00:00.000Z', late); + + const { token } = svc.createOrUpdateJourneyShareLink(journey.id, user.id, { + share_timeline: true, share_gallery: true, share_map: true, + }); + + const gallery = svc.getPublicJourney(token)!.gallery as Record[]; + expect(gallery.map(p => p.file_path)).toEqual(['/photos/day1.jpg', '/photos/day2.jpg']); + }); + it('JOURNEY-SHARE-030: cartoApiKey resolves owner setting → admin instance default → empty (#2054)', () => { const { user } = createUser(testDb); const journey = createJourney(testDb, user.id);