mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-18 21:16:47 +00:00
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:
@@ -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) {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
Reference in New Issue
Block a user