fix(journey): make sort_order authoritative for within-day entry ordering

Reorder buttons appeared broken because the server ORDER BY put entry_time
before sort_order, so entries synced from trip places with differing times
would always sort by time regardless of sort_order writes. The client store
mirrored the same comparator, making even the optimistic update invisible.

- Change ORDER BY to (entry_date, sort_order, id) in getJourneyFull and listEntries
- Fix syncTripPlaces and onPlaceCreated to assign MAX+1 sort_order per day instead of day_number/0
- Update client store comparator to match
- Add DB migration to backfill sort_order using old effective key (entry_time, id) so existing journeys retain their visual order
- Add tests: JOURNEY-SVC-089–093, FE-STORE-JOURNEY-018–019

Closes #846
This commit is contained in:
jubnl
2026-04-23 10:28:21 +02:00
parent 311647fd46
commit 70459dc085
5 changed files with 180 additions and 9 deletions
+23
View File
@@ -2107,6 +2107,29 @@ function runMigrations(db: Database.Database): void {
!= substr(reservations.reservation_time, 1, 10)
`);
},
// #846: make sort_order authoritative within a day. Previous ORDER BY put
// entry_time before sort_order, silently ignoring reorder clicks when two
// same-date entries had different times. Backfill renumbers using the old
// effective key (entry_time ASC, id ASC) so existing journeys retain their
// current visual order.
() => {
db.exec(`
WITH ranked AS (
SELECT id,
ROW_NUMBER() OVER (
PARTITION BY journey_id, entry_date
ORDER BY entry_time ASC, id ASC
) - 1 AS rn
FROM journey_entries
)
UPDATE journey_entries
SET sort_order = (SELECT rn FROM ranked WHERE ranked.id = journey_entries.id)
`);
db.exec(
'CREATE INDEX IF NOT EXISTS idx_journey_entries_order ' +
'ON journey_entries(journey_id, entry_date, sort_order)'
);
},
];
if (currentVersion < migrations.length) {
+18 -5
View File
@@ -120,7 +120,7 @@ export function getJourneyFull(journeyId: number, userId: number) {
if (!journey) return null;
const entries = db.prepare(
'SELECT * FROM journey_entries WHERE journey_id = ? ORDER BY entry_date ASC, entry_time ASC, sort_order ASC'
'SELECT * FROM journey_entries WHERE journey_id = ? ORDER BY entry_date ASC, sort_order ASC, id ASC'
).all(journeyId) as JourneyEntry[];
const photos = db.prepare(
@@ -306,12 +306,21 @@ export function syncTripPlaces(journeyId: number, tripId: number, authorId: numb
).all(journeyId, tripId) as { source_place_id: number }[];
const existingPlaceIds = new Set(existing.map(e => e.source_place_id));
// Track next sort_order per date so synced skeletons get unique, sequential positions.
const dateMaxOrder = new Map<string, number>();
const maxRows = db.prepare(
'SELECT entry_date, COALESCE(MAX(sort_order), -1) AS m FROM journey_entries WHERE journey_id = ? GROUP BY entry_date'
).all(journeyId) as { entry_date: string; m: number }[];
for (const row of maxRows) dateMaxOrder.set(row.entry_date, row.m);
for (const place of places) {
if (existingPlaceIds.has(place.id)) continue;
existingPlaceIds.add(place.id);
const entryDate = place.day_date || new Date().toISOString().split('T')[0];
const entryTime = place.assignment_time || place.place_time || null;
const nextOrder = (dateMaxOrder.get(entryDate) ?? -1) + 1;
dateMaxOrder.set(entryDate, nextOrder);
db.prepare(`
INSERT INTO journey_entries (journey_id, source_trip_id, source_place_id, author_id, type, title, entry_date, entry_time, location_name, location_lat, location_lng, sort_order, created_at, updated_at)
@@ -320,7 +329,7 @@ export function syncTripPlaces(journeyId: number, tripId: number, authorId: numb
journeyId, tripId, place.id, authorId,
place.name, entryDate, entryTime,
place.address || place.name, place.lat || null, place.lng || null,
place.day_number || 0, now, now
nextOrder, now, now
);
}
}
@@ -367,15 +376,19 @@ export function onPlaceCreated(tripId: number, placeId: number) {
const journey = db.prepare('SELECT user_id FROM journeys WHERE id = ?').get(link.journey_id) as { user_id: number };
const entryDate = place.day_date;
const maxOrder = db.prepare(
'SELECT MAX(sort_order) AS m FROM journey_entries WHERE journey_id = ? AND entry_date = ?'
).get(link.journey_id, entryDate) as { m: number | null };
const nextOrder = (maxOrder?.m ?? -1) + 1;
db.prepare(`
INSERT INTO journey_entries (journey_id, source_trip_id, source_place_id, author_id, type, title, entry_date, entry_time, location_name, location_lat, location_lng, sort_order, created_at, updated_at)
VALUES (?, ?, ?, ?, 'skeleton', ?, ?, ?, ?, ?, ?, 0, ?, ?)
VALUES (?, ?, ?, ?, 'skeleton', ?, ?, ?, ?, ?, ?, ?, ?, ?)
`).run(
link.journey_id, tripId, placeId, journey.user_id,
place.name, entryDate, place.assignment_time || place.place_time || null,
place.address || place.name, place.lat || null, place.lng || null,
now, now
nextOrder, now, now
);
}
}
@@ -451,7 +464,7 @@ export function listEntries(journeyId: number, userId: number) {
if (!canAccessJourney(journeyId, userId)) return null;
const entries = db.prepare(
'SELECT * FROM journey_entries WHERE journey_id = ? ORDER BY entry_date ASC, entry_time ASC, sort_order ASC'
'SELECT * FROM journey_entries WHERE journey_id = ? ORDER BY entry_date ASC, sort_order ASC, id ASC'
).all(journeyId) as JourneyEntry[];
const photos = db.prepare(