mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-18 21:16:47 +00:00
feat(journey): let a shared journey read newest-first
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.
This commit is contained in:
@@ -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(<JourneyPublicPage />);
|
||||
|
||||
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');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 && (
|
||||
<EmptyState scene="journey" title={t('journey.detail.noEntries')} />
|
||||
)}
|
||||
{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 && (
|
||||
<div className="flex justify-end">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setNewestFirst(!newestFirst)}
|
||||
aria-pressed={newestFirst}
|
||||
className="inline-flex items-center gap-1.5 rounded-full border border-zinc-200 px-3 py-1.5 text-[11px] font-semibold text-zinc-600 hover:border-zinc-400 dark:border-zinc-700 dark:text-zinc-300 dark:hover:border-zinc-500"
|
||||
>
|
||||
<ArrowUpDown size={12} />
|
||||
{newestFirst ? t('memories.newest') : t('memories.oldest')}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
{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 (
|
||||
<div key={date}>
|
||||
|
||||
@@ -83,6 +83,9 @@ function buildHook(over: Record<string, unknown> = {}): Record<string, unknown>
|
||||
sortedDates: [...grouped.keys()].sort(),
|
||||
sidebarMapItems: [],
|
||||
stopNumberById: new Map<string, number>(),
|
||||
displayDates: [...grouped.keys()].sort(),
|
||||
newestFirst: false,
|
||||
setNewestFirst: vi.fn(),
|
||||
allPhotos: [] as PublicGalleryPhoto[],
|
||||
desktopTwoColumn: true,
|
||||
...over,
|
||||
|
||||
@@ -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<boolean | null>(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,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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<typeof journeyShareLinkRequestSchema>;
|
||||
|
||||
|
||||
Reference in New Issue
Block a user