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;