diff --git a/client/src/pages/JourneyPublicPage.test.tsx b/client/src/pages/JourneyPublicPage.test.tsx index e1d1adbad..71a5937b7 100644 --- a/client/src/pages/JourneyPublicPage.test.tsx +++ b/client/src/pages/JourneyPublicPage.test.tsx @@ -558,4 +558,26 @@ describe('JourneyPublicPage', () => { expect(badge(shibuya)).toBe('1'); expect(badge(sensoji)).toBe('2'); }); + + // #1614 — a journey shared mid-trip reads like a blog. Only the reading order + // flips; the stop numbers stay chronological so the map still matches. + it('FE-PAGE-PUBLICJOURNEY-022: the reader can flip the order without renumbering the trip', async () => { + setupSuccess(); + render(); + + await waitFor(() => expect(screen.getByText('Shibuya Crossing')).toBeInTheDocument()); + + const order = () => + Array.from(document.querySelectorAll('[data-entry-id]')).map(el => el.getAttribute('data-entry-id')); + expect(order()).toEqual(['10', '11']); + + fireEvent.click(screen.getByRole('button', { name: /first/i })); + + await waitFor(() => expect(order()).toEqual(['11', '10'])); + + // Numbering is unchanged: entry 10 is still stop 1 even though it is shown last. + const shibuya = document.querySelector('[data-entry-id="10"]') as HTMLElement; + const badge = Array.from(shibuya.querySelectorAll('span')).find(s => /^\d+$/.test(s.textContent || '')); + expect(badge?.textContent).toBe('1'); + }); }); diff --git a/client/src/pages/JourneyPublicPage.tsx b/client/src/pages/JourneyPublicPage.tsx index 381d8ff79..a5dadfe1a 100644 --- a/client/src/pages/JourneyPublicPage.tsx +++ b/client/src/pages/JourneyPublicPage.tsx @@ -5,6 +5,7 @@ import { Cloud, CloudLightning, CloudRain, + ArrowUpDown, CloudSun, Frown, Grid, @@ -110,6 +111,9 @@ export default function JourneyPublicPage() { timelineEntries, groupedEntries, sortedDates, + displayDates, + newestFirst, + setNewestFirst, sidebarMapItems, allPhotos, stopNumberById, @@ -150,9 +154,27 @@ export default function JourneyPublicPage() { {sortedDates.length === 0 && ( )} - {sortedDates.map((date, dayIdx) => { + {/* The owner publishes a reading order; the reader may flip it. Only the order + changes — the day colours and the stop numbers stay chronological. */} + {sortedDates.length > 1 && ( +
+ +
+ )} + {displayDates.map((date) => { const dayEntries = groupedEntries.get(date)!; const fd = formatDate(date, locale); + // Day index stays chronological even when the feed reads newest-first, so a + // day keeps its colour and its number whichever way round it is shown. + const dayIdx = sortedDates.indexOf(date); const dayColor = DAY_COLORS[dayIdx % DAY_COLORS.length]; return (
diff --git a/client/src/pages/JourneyPublicPage.wiring.test.tsx b/client/src/pages/JourneyPublicPage.wiring.test.tsx index dec7adb13..e88780ad8 100644 --- a/client/src/pages/JourneyPublicPage.wiring.test.tsx +++ b/client/src/pages/JourneyPublicPage.wiring.test.tsx @@ -83,6 +83,9 @@ function buildHook(over: Record = {}): Record sortedDates: [...grouped.keys()].sort(), sidebarMapItems: [], stopNumberById: new Map(), + displayDates: [...grouped.keys()].sort(), + newestFirst: false, + setNewestFirst: vi.fn(), allPhotos: [] as PublicGalleryPhoto[], desktopTwoColumn: true, ...over, diff --git a/client/src/pages/journeyPublic/useJourneyPublic.ts b/client/src/pages/journeyPublic/useJourneyPublic.ts index 5d38bb9f0..b3b67f43d 100644 --- a/client/src/pages/journeyPublic/useJourneyPublic.ts +++ b/client/src/pages/journeyPublic/useJourneyPublic.ts @@ -53,6 +53,8 @@ export function useJourneyPublic() { const timelineEntries = useMemo(() => entries, [entries]) const groupedEntries = useMemo(() => groupByDate(timelineEntries), [timelineEntries]) + // Chronological throughout: this is what the day colours and the stop numbers are + // derived from, so flipping the reading order must not renumber the trip. const sortedDates = useMemo(() => [...groupedEntries.keys()].sort(), [groupedEntries]) const mapEntries = useMemo( () => timelineEntries.filter(e => e.location_lat && e.location_lng), @@ -95,6 +97,16 @@ export function useJourneyPublic() { return m }, [sidebarMapItems]) + // A journey shared while the trip is still running reads like a blog, so the owner + // can publish it newest-first (#1614). The reader may flip it either way; only the + // display order changes, never the numbering. + const [newestFirst, setNewestFirst] = useState(null) + const effectiveNewestFirst = newestFirst ?? !!perms.newest_first + const displayDates = useMemo( + () => (effectiveNewestFirst ? [...sortedDates].reverse() : sortedDates), + [sortedDates, effectiveNewestFirst], + ) + // Two-column desktop layout: timeline feed left + sticky map right const desktopTwoColumn = !isMobile && perms.share_timeline && perms.share_map @@ -114,7 +126,8 @@ export function useJourneyPublic() { view, setView, lightbox, setLightbox, showLangPicker, setShowLangPicker, mapRef, activeEntryId, setActiveEntryId, viewingEntry, setViewingEntry, handleMarkerClick, perms, journey, stats, - timelineEntries, groupedEntries, sortedDates, sidebarMapItems, allPhotos, stopNumberById, + timelineEntries, groupedEntries, sortedDates, displayDates, sidebarMapItems, allPhotos, stopNumberById, + newestFirst: effectiveNewestFirst, setNewestFirst, desktopTwoColumn, } } diff --git a/server/src/db/migrations.ts b/server/src/db/migrations.ts index cd87deb13..5001745da 100644 --- a/server/src/db/migrations.ts +++ b/server/src/db/migrations.ts @@ -3970,6 +3970,17 @@ function runMigrations(db: Database.Database): void { // Answering "which photos of this journey have coordinates" without a scan. db.exec('CREATE INDEX IF NOT EXISTS idx_trek_photos_geo ON trek_photos(lat, lng) WHERE lat IS NOT NULL AND lng IS NOT NULL'); }, + // A journey shared while the trip is still running reads like a blog, and a + // blog puts the newest entry first (#1614). The owner decides per share link, + // because it is a property of how the link is meant to be read, not of the + // journey. Off by default: an already-published link must not reorder itself + // under its readers. + () => { + const cols = db.prepare("SELECT name FROM pragma_table_info('journey_share_tokens')").all() as Array<{ name: string }>; + if (!cols.some(c => c.name === 'newest_first')) { + db.exec('ALTER TABLE journey_share_tokens ADD COLUMN newest_first INTEGER NOT NULL DEFAULT 0'); + } + }, ]; if (currentVersion < migrations.length) { diff --git a/server/src/nest/journey/journey-share.service.ts b/server/src/nest/journey/journey-share.service.ts index c75519626..56a7ec1c9 100644 --- a/server/src/nest/journey/journey-share.service.ts +++ b/server/src/nest/journey/journey-share.service.ts @@ -7,6 +7,8 @@ interface JourneySharePermissions { share_timeline?: boolean; share_gallery?: boolean; share_map?: boolean; + /** Read the journey newest-first, like a blog, rather than in trip order. */ + newest_first?: boolean; } interface JourneyShareTokenInfo { @@ -15,6 +17,7 @@ interface JourneyShareTokenInfo { share_timeline: boolean; share_gallery: boolean; share_map: boolean; + newest_first: boolean; } /** @@ -45,18 +48,19 @@ export class JourneyShareService { share_timeline = true, share_gallery = true, share_map = true, + newest_first = false, } = permissions; const existing = this.db.prepare('SELECT token FROM journey_share_tokens WHERE journey_id = ?').get(journeyId) as { token: string } | undefined; if (existing) { - this.db.prepare('UPDATE journey_share_tokens SET share_timeline = ?, share_gallery = ?, share_map = ? WHERE journey_id = ?') - .run(share_timeline ? 1 : 0, share_gallery ? 1 : 0, share_map ? 1 : 0, journeyId); + this.db.prepare('UPDATE journey_share_tokens SET share_timeline = ?, share_gallery = ?, share_map = ?, newest_first = ? WHERE journey_id = ?') + .run(share_timeline ? 1 : 0, share_gallery ? 1 : 0, share_map ? 1 : 0, newest_first ? 1 : 0, journeyId); return { token: existing.token, created: false }; } const token = crypto.randomBytes(24).toString('base64url'); - this.db.prepare('INSERT INTO journey_share_tokens (journey_id, token, created_by, share_timeline, share_gallery, share_map) VALUES (?, ?, ?, ?, ?, ?)') - .run(journeyId, token, createdBy, share_timeline ? 1 : 0, share_gallery ? 1 : 0, share_map ? 1 : 0); + this.db.prepare('INSERT INTO journey_share_tokens (journey_id, token, created_by, share_timeline, share_gallery, share_map, newest_first) VALUES (?, ?, ?, ?, ?, ?, ?)') + .run(journeyId, token, createdBy, share_timeline ? 1 : 0, share_gallery ? 1 : 0, share_map ? 1 : 0, newest_first ? 1 : 0); return { token, created: true }; } @@ -69,6 +73,7 @@ export class JourneyShareService { share_timeline: !!row.share_timeline, share_gallery: !!row.share_gallery, share_map: !!row.share_map, + newest_first: !!row.newest_first, }; } @@ -219,6 +224,7 @@ export class JourneyShareService { share_timeline: shareTimeline, share_gallery: shareGallery, share_map: shareMap, + newest_first: !!row.newest_first, }, }; } diff --git a/shared/src/journey/journey.schema.ts b/shared/src/journey/journey.schema.ts index fe41a134c..057060a29 100644 --- a/shared/src/journey/journey.schema.ts +++ b/shared/src/journey/journey.schema.ts @@ -128,6 +128,8 @@ export const journeyShareLinkRequestSchema = z.looseObject({ share_timeline: z.unknown().optional(), share_gallery: z.unknown().optional(), share_map: z.unknown().optional(), + /** Show the newest entry first, blog-style, rather than in trip order (#1614). */ + newest_first: z.unknown().optional(), }); export type JourneyShareLinkRequest = z.infer;