From 11686be26409fb04f35a36fe16b9fc49b7f20786 Mon Sep 17 00:00:00 2001 From: Maurice Date: Tue, 18 Aug 2026 20:41:24 +0200 Subject: [PATCH] feat(journey): let a shared journey read newest-first MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A journey shared while the trip is still running is read like a blog: the family following along wants the latest entry at the top, not to scroll past three weeks to find it (#1614). The owner sets it per share link, because it is a property of how the link is meant to be read rather than of the journey, and the reader can flip it either way. Off by default, so an already-published link does not reorder itself under its readers. Only the reading order changes. Day colours and stop numbers stay chronological, so the timeline still matches the map when the feed is upside down — which is why the day index is looked up rather than taken from the render loop. Reuses memories.newest/oldest, already translated in all 23 locales. --- client/src/pages/JourneyPublicPage.test.tsx | 22 +++++++++++++++++ client/src/pages/JourneyPublicPage.tsx | 24 ++++++++++++++++++- .../pages/JourneyPublicPage.wiring.test.tsx | 3 +++ .../pages/journeyPublic/useJourneyPublic.ts | 15 +++++++++++- server/src/db/migrations.ts | 11 +++++++++ .../src/nest/journey/journey-share.service.ts | 14 +++++++---- shared/src/journey/journey.schema.ts | 2 ++ 7 files changed, 85 insertions(+), 6 deletions(-) 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;