Merge pull request #2373 from liketrek/fix/journey-skeleton-per-day

fix(journey): a place kept across two days is two days of the journal
This commit is contained in:
Maurice
2026-09-17 15:31:37 +02:00
committed by GitHub
7 changed files with 354 additions and 72 deletions
+49
View File
@@ -4900,6 +4900,55 @@ function runMigrations(db: Database.Database): void {
END
`);
},
/*
* A journal skeleton belongs to a day, not just to a place (#2329).
*
* The sync engine keyed a skeleton by `source_place_id` alone, so the same
* place standing on two days — the city you land in at dusk and walk through
* the next morning, the hotel you sleep in three nights — produced one entry
* on the first of them and nothing on the rest. There was nowhere to put the
* second evening's photographs but the first evening's entry.
*
* The assignment row, not the day, is the missing half of that key: moving a
* stop to another day updates `day_assignments.day_id` in place, so keying on
* the assignment lets an entry follow the move the way it always has, while
* still telling two days of the same place apart.
*
* Deliberately no REFERENCES clause. Unassigning a stop has to leave the id
* stale rather than NULL, because reconciliation reads a key that no longer
* matches as "this stop left the plan" — which is what happened — and a NULL
* would be indistinguishable from a row this migration could not resolve. The
* id is safe to dangle: `day_assignments.id` is AUTOINCREMENT, so it is never
* handed out twice.
*
* Existing rows are backfilled to the place's earliest assignment, which is
* the one the old engine would have picked, so reconciliation matches what is
* already there instead of writing a duplicate beside it.
*/
() => {
const hasColumn = db
.prepare("SELECT 1 FROM pragma_table_info('journey_entries') WHERE name = 'source_assignment_id'")
.get();
if (!hasColumn) db.exec('ALTER TABLE journey_entries ADD COLUMN source_assignment_id INTEGER');
// `source_assignment_id IS NULL` keeps the replay from re-resolving a row that
// has since followed its stop to another day.
db.exec(`
UPDATE journey_entries
SET source_assignment_id = (
SELECT da.id
FROM day_assignments da
JOIN days d ON d.id = da.day_id
WHERE da.place_id = journey_entries.source_place_id
ORDER BY d.day_number ASC, d.date ASC, da.order_index ASC, da.id ASC
LIMIT 1
)
WHERE source_place_id IS NOT NULL AND source_assignment_id IS NULL
`);
db.exec(
'CREATE INDEX IF NOT EXISTS idx_journey_entries_source_assignment ON journey_entries(source_place_id, source_assignment_id)',
);
},
];
if (currentVersion < migrations.length) {
+138 -69
View File
@@ -32,6 +32,20 @@ function regionNames(): Intl.DisplayNames | null {
}
}
/**
* What makes a journal skeleton unique: the place *and* the day assignment it
* stands on (#2329). Keyed on the place alone, a stop kept across two days — the
* city you land in at dusk and walk through the next morning — got one entry, and
* the second day's photographs had nowhere to go.
*
* The assignment rather than the day, because moving a stop updates
* `day_assignments.day_id` in place: keyed this way an entry follows the move
* instead of being read as one stop leaving the plan and another arriving.
*/
function skeletonKey(placeId: number, assignmentId: number | null | undefined): string {
return `${placeId}:${assignmentId ?? ''}`;
}
function countryNamesFor(points: { country: string | null }[]): Record<string, string> {
const out: Record<string, string> = {};
const display = regionNames();
@@ -467,7 +481,7 @@ export class JourneyDomainService {
// detach filled entries from this trip
this.db.prepare(
`
UPDATE journey_entries SET source_trip_id = NULL, source_place_id = NULL
UPDATE journey_entries SET source_trip_id = NULL, source_place_id = NULL, source_assignment_id = NULL
WHERE journey_id = ? AND source_trip_id = ? AND type != 'skeleton'
`,
).run(journeyId, tripId);
@@ -482,7 +496,7 @@ export class JourneyDomainService {
const places = this.db
.prepare(
`
SELECT p.*, da.day_id, d.date as day_date, da.assignment_time, da.assignment_end_time, d.day_number
SELECT p.*, da.id AS assignment_id, da.day_id, d.date as day_date, da.assignment_time, da.assignment_end_time, d.day_number
FROM places p
INNER JOIN day_assignments da ON da.place_id = p.id
INNER JOIN days d ON da.day_id = d.id
@@ -494,9 +508,9 @@ export class JourneyDomainService {
const now = this.ts();
const existing = this.db
.prepare('SELECT source_place_id FROM journey_entries WHERE journey_id = ? AND source_trip_id = ?')
.all(journeyId, tripId) as { source_place_id: number }[];
const existingPlaceIds = new Set(existing.map((e) => e.source_place_id));
.prepare('SELECT source_place_id, source_assignment_id FROM journey_entries WHERE journey_id = ? AND source_trip_id = ?')
.all(journeyId, tripId) as { source_place_id: number; source_assignment_id: number | null }[];
const existingKeys = new Set(existing.map((e) => skeletonKey(e.source_place_id, e.source_assignment_id)));
// Track next sort_order per date so synced skeletons get unique, sequential positions.
const dateMaxOrder = new Map<string, number>();
@@ -508,8 +522,9 @@ export class JourneyDomainService {
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 key = skeletonKey(place.id, place.assignment_id);
if (existingKeys.has(key)) continue;
existingKeys.add(key);
const entryDate = place.day_date || new Date().toISOString().split('T')[0];
const entryTime = place.assignment_time || place.place_time || null;
@@ -520,6 +535,7 @@ export class JourneyDomainService {
journeyId,
tripId,
placeId: place.id,
assignmentId: place.assignment_id ?? null,
authorId,
title: place.name,
entryDate,
@@ -540,47 +556,55 @@ export class JourneyDomainService {
}[];
if (!links.length) return;
const place = this.db
// One row per assignment, not one per place: a place can already stand on
// several days by the time this fires, and each of those days is its own
// entry (#2329).
const assignments = this.db
.prepare(
`
SELECT p.*, da.day_id, d.date as day_date, da.assignment_time, d.day_number
SELECT p.*, da.id AS assignment_id, da.day_id, d.date as day_date, da.assignment_time, d.day_number
FROM places p
INNER JOIN day_assignments da ON da.place_id = p.id
INNER JOIN days d ON da.day_id = d.id
WHERE p.id = ?
ORDER BY d.day_number ASC, da.order_index ASC
`,
)
.get(placeId) as any;
if (!place) return; // not assigned to a day yet — skip
.all(placeId) as any[];
if (!assignments.length) return; // not assigned to a day yet — skip
const now = this.ts();
for (const link of links) {
const already = this.db
.prepare('SELECT 1 FROM journey_entries WHERE journey_id = ? AND source_place_id = ?')
.get(link.journey_id, placeId);
if (already) continue;
const journey = this.db.prepare('SELECT user_id FROM journeys WHERE id = ?').get(link.journey_id) as { user_id: number };
const entryDate = place.day_date;
const maxOrder = this.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;
this.insertSkeletonEntry({
journeyId: link.journey_id,
tripId,
placeId,
authorId: journey.user_id,
title: place.name,
entryDate,
entryTime: place.assignment_time || place.place_time || null,
locationName: place.address || place.name,
lat: place.lat || null,
lng: place.lng || null,
sortOrder: nextOrder,
now,
});
for (const place of assignments) {
const already = this.db
.prepare('SELECT 1 FROM journey_entries WHERE journey_id = ? AND source_place_id = ? AND source_assignment_id IS ?')
.get(link.journey_id, placeId, place.assignment_id ?? null);
if (already) continue;
const entryDate = place.day_date;
const maxOrder = this.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;
this.insertSkeletonEntry({
journeyId: link.journey_id,
tripId,
placeId,
assignmentId: place.assignment_id ?? null,
authorId: journey.user_id,
title: place.name,
entryDate,
entryTime: place.assignment_time || place.place_time || null,
locationName: place.address || place.name,
lat: place.lat || null,
lng: place.lng || null,
sortOrder: nextOrder,
now,
});
}
}
}
@@ -589,21 +613,30 @@ export class JourneyDomainService {
const entries = this.db.prepare('SELECT * FROM journey_entries WHERE source_place_id = ?').all(placeId) as JourneyEntry[];
if (!entries.length) return;
const place = this.db
const place = this.db.prepare('SELECT * FROM places WHERE id = ?').get(placeId) as any;
if (!place) return;
// Every day this place stands on, so each entry can follow its own rather
// than all of them collapsing onto whichever assignment the join returned
// first (#2329).
const assignments = this.db
.prepare(
`
SELECT p.*, da.day_id, d.date as day_date, da.assignment_time, d.day_number
FROM places p
LEFT JOIN day_assignments da ON da.place_id = p.id
LEFT JOIN days d ON da.day_id = d.id
WHERE p.id = ?
SELECT da.id AS assignment_id, d.date as day_date, da.assignment_time
FROM day_assignments da
INNER JOIN days d ON d.id = da.day_id
WHERE da.place_id = ?
ORDER BY d.day_number ASC, da.order_index ASC
`,
)
.get(placeId) as any;
if (!place) return;
.all(placeId) as { assignment_id: number; day_date: string; assignment_time: string | null }[];
const byAssignment = new Map(assignments.map((a) => [a.assignment_id, a]));
const assignmentFor = (entry: JourneyEntry) =>
(entry.source_assignment_id != null ? byAssignment.get(entry.source_assignment_id) : undefined) ?? assignments[0];
const now = this.ts();
for (const entry of entries) {
const assignment = assignmentFor(entry);
if (entry.type === 'skeleton') {
// update everything on skeletons
this.db.prepare(
@@ -613,8 +646,8 @@ export class JourneyDomainService {
`,
).run(
place.name,
place.day_date || entry.entry_date,
place.assignment_time || place.place_time || entry.entry_time,
assignment?.day_date || entry.entry_date,
assignment?.assignment_time || place.place_time || entry.entry_time,
place.address || place.name,
place.lat || null,
place.lng || null,
@@ -656,7 +689,7 @@ export class JourneyDomainService {
const note = '\n\n> _Note: the original trip place was removed from the trip plan_';
const newStory = (entry.story || '') + note;
this.db.prepare(
'UPDATE journey_entries SET source_place_id = NULL, source_trip_id = NULL, type = ?, story = ?, updated_at = ? WHERE id = ?',
'UPDATE journey_entries SET source_place_id = NULL, source_trip_id = NULL, source_assignment_id = NULL, type = ?, story = ?, updated_at = ? WHERE id = ?',
).run(entry.type === 'skeleton' ? 'entry' : entry.type, newStory, this.ts(), entry.id);
}
}
@@ -682,6 +715,7 @@ export class JourneyDomainService {
journeyId: number;
tripId: number;
placeId: number;
assignmentId: number | null;
authorId: number;
title: string;
entryDate: string;
@@ -694,13 +728,14 @@ export class JourneyDomainService {
}) {
this.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, country_code, sort_order, created_at, updated_at)
VALUES (?, ?, ?, ?, 'skeleton', ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
INSERT INTO journey_entries (journey_id, source_trip_id, source_place_id, source_assignment_id, author_id, type, title, entry_date, entry_time, location_name, location_lat, location_lng, country_code, sort_order, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, 'skeleton', ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`,
).run(
p.journeyId,
p.tripId,
p.placeId,
p.assignmentId,
p.authorId,
p.title,
p.entryDate,
@@ -715,12 +750,13 @@ export class JourneyDomainService {
);
}
// Make every journey linked to `tripId` mirror the trip's currently day-assigned
// places: add skeletons for newly-assigned places, refresh skeleton snapshots when a
// place is moved to another day / its time changes, and drop skeletons for places no
// longer assigned. Filled entries are never destroyed — only detached + annotated,
// mirroring onPlaceDeleted. Idempotent: a second call with no underlying change is a
// no-op (no writes, no broadcast). Called from every assignment mutation path.
// Make every journey linked to `tripId` mirror the trip's current day assignments:
// one skeleton per assignment, so a place standing on two days is two entries (#2329);
// refresh skeleton snapshots when a stop is moved to another day / its time changes;
// and drop skeletons whose assignment is gone. Filled entries are never destroyed —
// only detached + annotated, mirroring onPlaceDeleted. Idempotent: a second call with
// no underlying change is a no-op (no writes, no broadcast). Called from every
// assignment mutation path.
reconcileTripSkeletons(tripId: number, sid?: string | number) {
const links = this.db.prepare('SELECT journey_id FROM journey_trips WHERE trip_id = ?').all(tripId) as {
journey_id: number;
@@ -730,7 +766,7 @@ export class JourneyDomainService {
const places = this.db
.prepare(
`
SELECT p.*, da.day_id, d.date as day_date, da.assignment_time, d.day_number, da.order_index
SELECT p.*, da.id AS assignment_id, da.day_id, d.date as day_date, da.assignment_time, d.day_number, da.order_index
FROM places p
INNER JOIN day_assignments da ON da.place_id = p.id
INNER JOIN days d ON da.day_id = d.id
@@ -740,11 +776,10 @@ export class JourneyDomainService {
)
.all(tripId) as any[];
// One skeleton per place (a place on multiple days keeps its first-by-day/order row),
// matching the one-skeleton-per-place model used by onPlaceCreated.
const placeById = new Map<number, any>();
for (const place of places) if (!placeById.has(place.id)) placeById.set(place.id, place);
const assignedPlaceIds = new Set(placeById.keys());
// One skeleton per assignment, not per place: a stop kept across two days is
// two days of the journal (#2329).
const assignedKeys = new Set(places.map((p) => skeletonKey(p.id, p.assignment_id)));
const assignedPlaceIds = new Set<number>(places.map((p) => p.id));
const now = this.ts();
for (const { journey_id } of links) {
@@ -756,12 +791,13 @@ export class JourneyDomainService {
let changed = false;
const existing = this.db
.prepare(
`SELECT id, source_place_id, type, story, title, entry_date, entry_time, location_name, location_lat, location_lng
`SELECT id, source_place_id, source_assignment_id, type, story, title, entry_date, entry_time, location_name, location_lat, location_lng
FROM journey_entries WHERE journey_id = ? AND source_trip_id = ?`,
)
.all(journey_id, tripId) as {
id: number;
source_place_id: number | null;
source_assignment_id: number | null;
type: string;
story: string | null;
title: string | null;
@@ -771,8 +807,21 @@ export class JourneyDomainService {
location_lat: number | null;
location_lng: number | null;
}[];
const existingByPlace = new Map<number, (typeof existing)[number]>();
for (const e of existing) if (e.source_place_id != null) existingByPlace.set(e.source_place_id, e);
const existingByKey = new Map<string, (typeof existing)[number]>();
// Rows from before the assignment link existed, and any the backfill could not
// resolve. The place's earliest assignment claims one below, rather than the row
// being read as "no longer in the plan" and annotated out from under its author.
const unclaimed = new Map<number, (typeof existing)[number][]>();
for (const e of existing) {
if (e.source_place_id == null) continue;
if (e.source_assignment_id == null) {
const pool = unclaimed.get(e.source_place_id);
if (pool) pool.push(e);
else unclaimed.set(e.source_place_id, [e]);
} else {
existingByKey.set(skeletonKey(e.source_place_id, e.source_assignment_id), e);
}
}
// Next sort_order per date for freshly inserted skeletons.
const dateMaxOrder = new Map<string, number>();
@@ -783,14 +832,27 @@ export class JourneyDomainService {
.all(journey_id) as { entry_date: string; m: number }[];
for (const row of maxRows) dateMaxOrder.set(row.entry_date, row.m);
// 1) Upsert a skeleton for every currently-assigned place.
for (const place of placeById.values()) {
// 1) Upsert a skeleton for every current day assignment.
for (const place of places) {
const entryDate = place.day_date || new Date().toISOString().split('T')[0];
const entryTime = place.assignment_time || place.place_time || null;
const locationName = place.address || place.name;
const lat = place.lat || null;
const lng = place.lng || null;
const found = existingByPlace.get(place.id);
let found = existingByKey.get(skeletonKey(place.id, place.assignment_id));
if (!found) {
// `places` is ordered by day, so the earliest assignment claims it.
const adopted = unclaimed.get(place.id)?.shift();
if (adopted) {
this.db
.prepare('UPDATE journey_entries SET source_assignment_id = ? WHERE id = ?')
.run(place.assignment_id ?? null, adopted.id);
adopted.source_assignment_id = place.assignment_id ?? null;
existingByKey.set(skeletonKey(place.id, place.assignment_id), adopted);
found = adopted;
}
}
if (!found) {
const nextOrder = (dateMaxOrder.get(entryDate) ?? -1) + 1;
@@ -799,6 +861,7 @@ export class JourneyDomainService {
journeyId: journey_id,
tripId,
placeId: place.id,
assignmentId: place.assignment_id ?? null,
authorId: journey.user_id,
title: place.name,
entryDate,
@@ -838,9 +901,15 @@ export class JourneyDomainService {
}
}
// 2) Drop skeletons whose place is no longer assigned to a day in this trip.
// 2) Drop skeletons whose assignment is gone. One still waiting to be claimed is
// spared while its place is on the plan somewhere: having no link yet is not
// the same as the stop having left.
for (const e of existing) {
if (e.source_place_id == null || assignedPlaceIds.has(e.source_place_id)) continue;
if (e.source_place_id == null) continue;
if (e.source_assignment_id == null && assignedPlaceIds.has(e.source_place_id)) continue;
if (e.source_assignment_id != null && assignedKeys.has(skeletonKey(e.source_place_id, e.source_assignment_id))) {
continue;
}
if (e.type === 'skeleton') {
const hasPhotos = this.db.prepare('SELECT 1 FROM journey_entry_photos WHERE entry_id = ?').get(e.id);
if (!hasPhotos && !e.story) {
@@ -852,7 +921,7 @@ export class JourneyDomainService {
const note = '\n\n> _Note: the original trip place was removed from the trip plan_';
const newStory = (e.story || '') + note;
this.db.prepare(
'UPDATE journey_entries SET source_place_id = NULL, source_trip_id = NULL, type = ?, story = ?, updated_at = ? WHERE id = ?',
'UPDATE journey_entries SET source_place_id = NULL, source_trip_id = NULL, source_assignment_id = NULL, type = ?, story = ?, updated_at = ? WHERE id = ?',
).run(e.type === 'skeleton' ? 'entry' : e.type, newStory, now, e.id);
changed = true;
}
+1 -1
View File
@@ -488,7 +488,7 @@ export class TripsService {
`).run(tripId);
// Detach filled entries (keep user's written content, just remove trip link)
this.db.prepare(`
UPDATE journey_entries SET source_trip_id = NULL, source_place_id = NULL
UPDATE journey_entries SET source_trip_id = NULL, source_place_id = NULL, source_assignment_id = NULL
WHERE source_trip_id = ?
`).run(tripId);
+6
View File
@@ -391,6 +391,12 @@ export interface JourneyEntry {
journey_id: number;
source_trip_id?: number | null;
source_place_id?: number | null;
/**
* The `day_assignments` row this entry was derived from (#2329). A place standing
* on two days is two entries, and only this tells them apart. May dangle: an
* unassigned stop keeps the id so reconciliation can see the assignment is gone.
*/
source_assignment_id?: number | null;
author_id: number;
type: 'entry' | 'checkin' | 'skeleton';
title?: string | null;
+1 -1
View File
@@ -39,7 +39,7 @@ const { db } = vi.hoisted(() => {
created_at DATETIME DEFAULT CURRENT_TIMESTAMP);`);
// deleteTrip cleans up synced journey entries before dropping the trip row.
tmp.exec(`CREATE TABLE journey_entries (id INTEGER PRIMARY KEY AUTOINCREMENT, journey_id INTEGER,
source_trip_id INTEGER, source_place_id INTEGER, type TEXT NOT NULL);`);
source_trip_id INTEGER, source_place_id INTEGER, source_assignment_id INTEGER, type TEXT NOT NULL);`);
// bundle()'s todoItems now runs TodoService's real SQL (DI-injected, no mock).
tmp.exec(`CREATE TABLE todo_items (id INTEGER PRIMARY KEY AUTOINCREMENT, trip_id INTEGER NOT NULL,
name TEXT NOT NULL, checked INTEGER NOT NULL DEFAULT 0, category TEXT, sort_order INTEGER NOT NULL DEFAULT 0,
@@ -1888,6 +1888,164 @@ describe('reconcileTripSkeletons', () => {
});
});
// -- the same place on two days (#2329) ---------------------------------------
describe('a place standing on more than one day', () => {
function linkedJourneyTrip() {
const { user } = createUser(testDb);
const journey = createJourney(testDb, user.id);
const trip = createTrip(testDb, user.id, {
title: 'Repeat Trip',
start_date: '2026-05-01',
end_date: '2026-05-03',
});
svc.addTripToJourney(journey.id, trip.id, user.id);
return { user, journey, trip };
}
function daysOf(tripId: number) {
return testDb.prepare('SELECT id, date FROM days WHERE trip_id = ? ORDER BY date ASC').all(tripId) as {
id: number;
date: string;
}[];
}
function skeletonsFor(journeyId: number, placeId: number) {
return testDb
.prepare('SELECT * FROM journey_entries WHERE journey_id = ? AND source_place_id = ? ORDER BY entry_date ASC')
.all(journeyId, placeId) as any[];
}
it('JOURNEY-SVC-REPEAT-001: syncTripPlaces writes one skeleton per day, not one per place', () => {
const { user } = createUser(testDb);
const journey = createJourney(testDb, user.id);
const trip = createTrip(testDb, user.id, { title: 'Two Nights', start_date: '2026-05-01', end_date: '2026-05-03' });
const days = daysOf(trip.id);
const place = createPlace(testDb, trip.id, { name: 'Reykjavík' });
createDayAssignment(testDb, days[0].id, place.id);
createDayAssignment(testDb, days[1].id, place.id);
svc.syncTripPlaces(journey.id, trip.id, user.id);
expect(skeletonsFor(journey.id, place.id).map((e) => e.entry_date)).toEqual([days[0].date, days[1].date]);
});
it('JOURNEY-SVC-REPEAT-002: a second call adds nothing', () => {
const { user } = createUser(testDb);
const journey = createJourney(testDb, user.id);
const trip = createTrip(testDb, user.id, { title: 'Idempotent', start_date: '2026-05-01', end_date: '2026-05-03' });
const days = daysOf(trip.id);
const place = createPlace(testDb, trip.id, { name: 'Vík' });
createDayAssignment(testDb, days[0].id, place.id);
createDayAssignment(testDb, days[1].id, place.id);
svc.syncTripPlaces(journey.id, trip.id, user.id);
svc.syncTripPlaces(journey.id, trip.id, user.id);
expect(skeletonsFor(journey.id, place.id)).toHaveLength(2);
});
it('JOURNEY-SVC-REPEAT-003: onPlaceCreated fires once per day the place already stands on', () => {
const { journey, trip } = linkedJourneyTrip();
const days = daysOf(trip.id);
const place = createPlace(testDb, trip.id, { name: 'Höfn' });
createDayAssignment(testDb, days[1].id, place.id);
createDayAssignment(testDb, days[2].id, place.id);
svc.onPlaceCreated(trip.id, place.id);
expect(skeletonsFor(journey.id, place.id).map((e) => e.entry_date)).toEqual([days[1].date, days[2].date]);
});
it('JOURNEY-SVC-REPEAT-004: assigning the place to a second day adds a second entry and keeps the first', () => {
const { journey, trip } = linkedJourneyTrip();
const days = daysOf(trip.id);
const place = createPlace(testDb, trip.id, { name: 'Akureyri' });
createDayAssignment(testDb, days[0].id, place.id);
svc.reconcileTripSkeletons(trip.id);
const first = skeletonsFor(journey.id, place.id)[0];
testDb.prepare("UPDATE journey_entries SET type = 'entry', story = 'Sunset' WHERE id = ?").run(first.id);
createDayAssignment(testDb, days[1].id, place.id);
svc.reconcileTripSkeletons(trip.id);
const both = skeletonsFor(journey.id, place.id);
expect(both.map((e) => e.entry_date)).toEqual([days[0].date, days[1].date]);
expect(both[0].id).toBe(first.id);
expect(both[0].story).toBe('Sunset');
expect(both[1].type).toBe('skeleton');
});
it('JOURNEY-SVC-REPEAT-005: unassigning one day drops only that day', () => {
const { journey, trip } = linkedJourneyTrip();
const days = daysOf(trip.id);
const place = createPlace(testDb, trip.id, { name: 'Selfoss' });
createDayAssignment(testDb, days[0].id, place.id);
const second = createDayAssignment(testDb, days[1].id, place.id);
svc.reconcileTripSkeletons(trip.id);
expect(skeletonsFor(journey.id, place.id)).toHaveLength(2);
testDb.prepare('DELETE FROM day_assignments WHERE id = ?').run(second.id);
svc.reconcileTripSkeletons(trip.id);
expect(skeletonsFor(journey.id, place.id).map((e) => e.entry_date)).toEqual([days[0].date]);
});
it('JOURNEY-SVC-REPEAT-006: moving one of the two assignments moves its entry rather than replacing it', () => {
const { journey, trip } = linkedJourneyTrip();
const days = daysOf(trip.id);
const place = createPlace(testDb, trip.id, { name: 'Geysir' });
createDayAssignment(testDb, days[0].id, place.id);
const second = createDayAssignment(testDb, days[1].id, place.id);
svc.reconcileTripSkeletons(trip.id);
const movedId = skeletonsFor(journey.id, place.id)[1].id;
testDb.prepare('UPDATE day_assignments SET day_id = ? WHERE id = ?').run(days[2].id, second.id);
svc.reconcileTripSkeletons(trip.id);
const after = skeletonsFor(journey.id, place.id);
expect(after.map((e) => e.entry_date)).toEqual([days[0].date, days[2].date]);
expect(after[1].id).toBe(movedId);
});
it('JOURNEY-SVC-REPEAT-007: editing the place leaves each entry on its own day', () => {
const { journey, trip } = linkedJourneyTrip();
const days = daysOf(trip.id);
const place = createPlace(testDb, trip.id, { name: 'Old Name' });
createDayAssignment(testDb, days[0].id, place.id);
createDayAssignment(testDb, days[1].id, place.id);
svc.reconcileTripSkeletons(trip.id);
testDb.prepare('UPDATE places SET name = ? WHERE id = ?').run('New Name', place.id);
svc.onPlaceUpdated(place.id);
const after = skeletonsFor(journey.id, place.id);
expect(after.map((e) => e.entry_date)).toEqual([days[0].date, days[1].date]);
expect(after.map((e) => e.title)).toEqual(['New Name', 'New Name']);
});
it('JOURNEY-SVC-REPEAT-008: an entry with no assignment link is claimed, not annotated out', () => {
const { journey, trip } = linkedJourneyTrip();
const days = daysOf(trip.id);
const place = createPlace(testDb, trip.id, { name: 'Legacy Stop' });
createDayAssignment(testDb, days[0].id, place.id);
svc.reconcileTripSkeletons(trip.id);
const legacy = skeletonsFor(journey.id, place.id)[0];
// What an install upgraded from before the column existed looks like.
testDb
.prepare("UPDATE journey_entries SET source_assignment_id = NULL, type = 'entry', story = 'Kept' WHERE id = ?")
.run(legacy.id);
svc.reconcileTripSkeletons(trip.id);
const after = skeletonsFor(journey.id, place.id);
expect(after).toHaveLength(1);
expect(after[0].id).toBe(legacy.id);
expect(after[0].source_assignment_id).not.toBeNull();
expect(after[0].story).toBe('Kept');
});
});
// ---------------------------------------------------------------------------
// Skeleton lifecycle, the photo gallery and the per-user preference row. These
// paths were reachable only through the REST controller before the fold, so the
+1 -1
View File
@@ -16,7 +16,7 @@ When the admin has enabled the Journey addon, a **Journey** entry appears in the
## Creating a journey
From the Journey list, click **Create journey**. Give it a title and optional subtitle, then select one or more existing trips to link. Linking a trip imports the trip's places as location anchors for your entries. You can link additional trips later from the journal settings.
From the Journey list, click **Create journey**. Give it a title and optional subtitle, then select one or more existing trips to link. Linking a trip imports the trip's places as location anchors for your entries — one anchor per day the place stands on, so a place you kept across two days gives you an entry on each of them. You can link additional trips later from the journal settings.
## Journal entries