mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-20 05:56:53 +00:00
fix(journey): the gallery runs in trip chronology instead of upload order (#2200)
journey_photos.sort_order is nothing but the insert counter, so photos added late landed behind days they belong before. Both gallery readers (the app and the shared journey) order by capture time now, falling back to the timestamp of the stop the photo belongs to and then to upload time, through one shared SQL fragment; ties keep the old order, so existing galleries only move where chronology demands it. The order inside an entry stays the user's manual arrangement.
This commit is contained in:
@@ -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);
|
||||
|
||||
|
||||
@@ -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
|
||||
`;
|
||||
@@ -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
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -635,6 +635,30 @@ describe('getPublicJourney', () => {
|
||||
expect((result.entries[0] as Record<string, unknown>).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<string, unknown>[];
|
||||
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);
|
||||
|
||||
Reference in New Issue
Block a user