mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-18 21:16:47 +00:00
feat(journey): the enhancements from discussion #2299
roel-de-vries pitched seventeen changes to Journey. Four were already built or rest on a misunderstanding, one cannot work as described, and three would take away more than they give; the rest are here, plus two things the list did not ask for. **The entry you write on the move now has a name.** Quick capture hid the title field outright and offered a note instead, so every entry caught on a phone arrived nameless and a day's timeline read as a column of identical placeholders. One line of input; it is the single change in this branch that users will feel most. **The map stops yanking.** Stepping through the timeline called flyTo with a zoom floor of 12, so reading a journey from a country view snapped to street level on the first scroll and stayed there — every marker filling the screen, and no sense of where anything was. Both renderers pan now and leave the frame the reader chose. The initial fitBounds still decides how close a journey opens. **The phone timeline is photos.** A 64px thumbnail beside three lines of text spent the card on a story nobody reads at that size and truncated the title after a few characters. The picture is the card now, with the name and place on it, the country's flag and the date in the corners, and the day carried as a colour rather than as a number nobody was reading. Above the carousel, a segment per day: it says how long the journey is, how far in you are, and takes you to day nine without swiping past days three to eight. Everything else, briefly: - A plus in each day header, starting a draft on that day instead of on today. - A journey opens on today when today is part of it, and at the beginning when it is not — a journey that ended last spring is something you read. - Suggestions can be waved away one at a time. The row survives so the trip sync does not offer the same place again; journey settings brings them all back. - Mood, weather and the pros/cons list can be switched off per journey. Nothing written is erased — the form stops asking, the values stay. - The weather fills itself in at the desk the way it already did on the phone. The date picks the source, so writing up last Tuesday gets last Tuesday's. - Enter opens the next pro or con, below the one you are in. - The verdict moved behind the story's fold, and the fold itself came in from nine lines to four. - The external picker lost the count it was printing twice and some padding it could not afford inside the editor. - The phone can reorder entries within a day at last; the desktop arrows finally have translated labels. - Marker labels are off where a carousel already names the entry, and on where they are the only name a marker has. - Search over a journey's titles, stories, places and tags, accent-blind. - An entry knows which country it happened in, resolved once from its coordinates against the bundled boundaries. Not built, and why: the photos-for-markers idea needs clustering first or thirty markers become soup, and the active entry's photo is already on screen in the card below. Removing mood, weather and the verdict outright would take them from everyone who uses them, so they became switches. `capture` on the file picker takes the photo library away on a phone, which is why the camera is a second input; it is hidden on desktop now instead. The suggestion strip in the new-entry flow would be a third surface over the same data — tapping a suggestion already opens the editor filled in. The one gap the list missed and this branch does not close: Journey is the only domain with no offline path. That belongs in its own change.
This commit is contained in:
@@ -4821,6 +4821,53 @@ function runMigrations(db: Database.Database): void {
|
||||
}
|
||||
if (stays.length > 0) console.log(`[DB] Caught up ${stays.length} booked night(s) missed during the upgrade`);
|
||||
},
|
||||
/**
|
||||
* A suggestion the traveller has waved away.
|
||||
*
|
||||
* Skeletons are real rows, and the trip sync decides what to create by asking
|
||||
* which source places already have one. Deleting a dismissed suggestion would
|
||||
* therefore bring it straight back on the next sync. So it stays, marked, and
|
||||
* drops out of every read instead — which also leaves a way back.
|
||||
*
|
||||
* Appended LAST: the array is index-addressed against schema_version.
|
||||
*/
|
||||
() => {
|
||||
const cols = db.prepare("SELECT name FROM pragma_table_info('journey_entries')").all() as Array<{ name: string }>;
|
||||
if (!cols.some((c) => c.name === 'dismissed')) {
|
||||
db.exec('ALTER TABLE journey_entries ADD COLUMN dismissed INTEGER NOT NULL DEFAULT 0');
|
||||
}
|
||||
},
|
||||
/**
|
||||
* The country an entry happened in, resolved once from its coordinates.
|
||||
*
|
||||
* For the flag on the timeline card. Resolved on write rather than on read
|
||||
* because the answer never changes and the polygon test should not run on
|
||||
* every render of every entry.
|
||||
*/
|
||||
() => {
|
||||
const cols = db.prepare("SELECT name FROM pragma_table_info('journey_entries')").all() as Array<{ name: string }>;
|
||||
if (!cols.some((c) => c.name === 'country_code')) {
|
||||
db.exec('ALTER TABLE journey_entries ADD COLUMN country_code TEXT');
|
||||
}
|
||||
},
|
||||
/**
|
||||
* Which of the optional entry fields a journey uses.
|
||||
*
|
||||
* Mood, weather and the pros/cons list are the three things that make the
|
||||
* editor feel like a form. Not everybody journals that way, and a journey
|
||||
* kept by one person for their family should be able to put them away
|
||||
* without the fields being taken from everybody else (discussion #2299).
|
||||
*
|
||||
* DEFAULT 1: every existing journey keeps all three, which is what it had.
|
||||
*/
|
||||
() => {
|
||||
const cols = db.prepare("SELECT name FROM pragma_table_info('journeys')").all() as Array<{ name: string }>;
|
||||
for (const col of ['show_verdict', 'show_mood', 'show_weather']) {
|
||||
if (!cols.some((c) => c.name === col)) {
|
||||
db.exec(`ALTER TABLE journeys ADD COLUMN ${col} INTEGER NOT NULL DEFAULT 1`);
|
||||
}
|
||||
}
|
||||
},
|
||||
];
|
||||
|
||||
if (currentVersion < migrations.length) {
|
||||
|
||||
@@ -230,7 +230,7 @@ export class JourneyDomainService {
|
||||
if (!journey) return null;
|
||||
|
||||
const entries = this.db
|
||||
.prepare('SELECT * FROM journey_entries WHERE journey_id = ? ORDER BY entry_date ASC, sort_order ASC, id ASC')
|
||||
.prepare('SELECT * FROM journey_entries WHERE journey_id = ? AND dismissed = 0 ORDER BY entry_date ASC, sort_order ASC, id ASC')
|
||||
.all(journeyId) as JourneyEntry[];
|
||||
|
||||
const photos = this.db
|
||||
@@ -317,6 +317,13 @@ export class JourneyDomainService {
|
||||
contributors,
|
||||
stats: { entries: entryCount, photos: photoCount, places: places.length },
|
||||
hide_skeletons: !!userPrefs?.hide_skeletons,
|
||||
// What the eye toggle cannot say: how much was waved away one at a time, and
|
||||
// so whether a way back is worth any room at all.
|
||||
dismissed_count: (
|
||||
this.db
|
||||
.prepare('SELECT COUNT(*) AS n FROM journey_entries WHERE journey_id = ? AND dismissed = 1')
|
||||
.get(journeyId) as { n: number }
|
||||
).n,
|
||||
my_role: myRole,
|
||||
};
|
||||
}
|
||||
@@ -338,10 +345,20 @@ export class JourneyDomainService {
|
||||
if (!this.isOwner(journeyId, userId)) return null;
|
||||
|
||||
const ALLOWED_STATUSES = ['draft', 'active', 'completed', 'archived'];
|
||||
const allowed = ['title', 'subtitle', 'cover_gradient', 'cover_image', 'status', 'show_trip_tracks'];
|
||||
const allowed = [
|
||||
'title',
|
||||
'subtitle',
|
||||
'cover_gradient',
|
||||
'cover_image',
|
||||
'status',
|
||||
'show_trip_tracks',
|
||||
'show_verdict',
|
||||
'show_mood',
|
||||
'show_weather',
|
||||
];
|
||||
// Stored as INTEGER, and better-sqlite3 refuses to bind a JS boolean, so the
|
||||
// one flag on this table is coerced rather than passed through.
|
||||
const BOOLEAN_FIELDS = new Set(['show_trip_tracks']);
|
||||
// flags on this table are coerced rather than passed through.
|
||||
const BOOLEAN_FIELDS = new Set(['show_trip_tracks', 'show_verdict', 'show_mood', 'show_weather']);
|
||||
const fields: string[] = [];
|
||||
const values: unknown[] = [];
|
||||
for (const [key, val] of Object.entries(data)) {
|
||||
@@ -375,6 +392,23 @@ export class JourneyDomainService {
|
||||
return { hide_skeletons: !!row.hide_skeletons };
|
||||
}
|
||||
|
||||
/**
|
||||
* Bring every waved-away suggestion back.
|
||||
*
|
||||
* All of them at once rather than one at a time: dismissing is a per-card
|
||||
* gesture, undoing it is a change of mind about the plan, and a list of
|
||||
* things you have already said you did not want is not a screen worth
|
||||
* building. The count comes back so the caller can say what happened.
|
||||
*/
|
||||
restoreDismissedSuggestions(journeyId: number, userId: number): { restored: number } | null {
|
||||
if (!this.canEdit(journeyId, userId)) return null;
|
||||
const res = this.db
|
||||
.prepare('UPDATE journey_entries SET dismissed = 0 WHERE journey_id = ? AND dismissed = 1')
|
||||
.run(journeyId);
|
||||
if (res.changes > 0) this.broadcastJourneyEvent(journeyId, 'journey:entry:updated', { restored: res.changes });
|
||||
return { restored: res.changes };
|
||||
}
|
||||
|
||||
deleteJourney(journeyId: number, userId: number): boolean {
|
||||
if (!this.isOwner(journeyId, userId)) return false;
|
||||
this.db.prepare('DELETE FROM journeys WHERE id = ?').run(journeyId);
|
||||
@@ -619,6 +653,22 @@ export class JourneyDomainService {
|
||||
}
|
||||
|
||||
// Shared skeleton INSERT, reused by syncTripPlaces / onPlaceCreated / reconcileTripSkeletons.
|
||||
/**
|
||||
* The country a pair of coordinates falls in, or null when there is no pair.
|
||||
*
|
||||
* Bundled polygons, no network: the timeline card wants a flag, and a flag is
|
||||
* not worth a geocoding request per entry. Wrapped because the boundary data is
|
||||
* an optional asset — an install without it should lose the flag, not the save.
|
||||
*/
|
||||
private countryFor(lat: number | null | undefined, lng: number | null | undefined): string | null {
|
||||
if (typeof lat !== 'number' || typeof lng !== 'number') return null;
|
||||
try {
|
||||
return getCountryFromCoords(lat, lng);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private insertSkeletonEntry(p: {
|
||||
journeyId: number;
|
||||
tripId: number;
|
||||
@@ -635,8 +685,8 @@ 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, sort_order, created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, 'skeleton', ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
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', ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`,
|
||||
).run(
|
||||
p.journeyId,
|
||||
@@ -649,6 +699,7 @@ export class JourneyDomainService {
|
||||
p.locationName,
|
||||
p.lat,
|
||||
p.lng,
|
||||
this.countryFor(p.lat, p.lng),
|
||||
p.sortOrder,
|
||||
p.now,
|
||||
p.now,
|
||||
@@ -903,7 +954,7 @@ export class JourneyDomainService {
|
||||
SELECT id, title, location_name, location_lat, location_lng, entry_date, source_trip_id,
|
||||
source_place_id, stats_excluded
|
||||
FROM journey_entries
|
||||
WHERE journey_id = ?
|
||||
WHERE journey_id = ? AND dismissed = 0
|
||||
ORDER BY entry_date ASC, sort_order ASC, id ASC
|
||||
`).all(journeyId) as {
|
||||
id: number; title: string | null; location_name: string | null;
|
||||
@@ -1120,7 +1171,7 @@ export class JourneyDomainService {
|
||||
if (!this.canAccessJourney(journeyId, userId)) return null;
|
||||
|
||||
const entries = this.db
|
||||
.prepare('SELECT * FROM journey_entries WHERE journey_id = ? ORDER BY entry_date ASC, sort_order ASC, id ASC')
|
||||
.prepare('SELECT * FROM journey_entries WHERE journey_id = ? AND dismissed = 0 ORDER BY entry_date ASC, sort_order ASC, id ASC')
|
||||
.all(journeyId) as JourneyEntry[];
|
||||
|
||||
const photos = this.db
|
||||
@@ -1180,8 +1231,8 @@ export class JourneyDomainService {
|
||||
const res = this.db
|
||||
.prepare(
|
||||
`
|
||||
INSERT INTO journey_entries (journey_id, author_id, type, title, story, entry_date, entry_time, location_name, location_lat, location_lng, mood, weather, tags, pros_cons, visibility, sort_order, created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
INSERT INTO journey_entries (journey_id, author_id, type, title, story, entry_date, entry_time, location_name, location_lat, location_lng, country_code, mood, weather, tags, pros_cons, visibility, sort_order, created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`,
|
||||
)
|
||||
.run(
|
||||
@@ -1195,6 +1246,7 @@ export class JourneyDomainService {
|
||||
data.location_name || null,
|
||||
data.location_lat ?? null,
|
||||
data.location_lng ?? null,
|
||||
this.countryFor(data.location_lat, data.location_lng),
|
||||
data.mood || null,
|
||||
data.weather || null,
|
||||
data.tags?.length ? JSON.stringify(data.tags) : null,
|
||||
@@ -1233,6 +1285,7 @@ export class JourneyDomainService {
|
||||
visibility: string;
|
||||
sort_order: number;
|
||||
stats_excluded: boolean;
|
||||
dismissed: boolean;
|
||||
}>,
|
||||
sid?: string,
|
||||
): JourneyEntryWire | null {
|
||||
@@ -1262,6 +1315,7 @@ export class JourneyDomainService {
|
||||
'visibility',
|
||||
'sort_order',
|
||||
'stats_excluded',
|
||||
'dismissed',
|
||||
]);
|
||||
|
||||
for (const [key, val] of Object.entries(data)) {
|
||||
@@ -1273,9 +1327,9 @@ export class JourneyDomainService {
|
||||
} else if (key === 'pros_cons') {
|
||||
fields.push('pros_cons = ?');
|
||||
values.push(val && typeof val === 'object' ? JSON.stringify(val) : val);
|
||||
} else if (key === 'stats_excluded') {
|
||||
// INTEGER column, and better-sqlite3 refuses to bind a boolean.
|
||||
fields.push('stats_excluded = ?');
|
||||
} else if (key === 'stats_excluded' || key === 'dismissed') {
|
||||
// INTEGER columns, and better-sqlite3 refuses to bind a boolean.
|
||||
fields.push(`${key} = ?`);
|
||||
values.push(val ? 1 : 0);
|
||||
} else {
|
||||
fields.push(`${key} = ?`);
|
||||
@@ -1283,6 +1337,17 @@ export class JourneyDomainService {
|
||||
}
|
||||
}
|
||||
|
||||
// The pin moved, so the flag has to follow it. Either half of the pair may be
|
||||
// the one being changed, so the other is read off the stored row — and the
|
||||
// test is `!== undefined`, not `??`: a null here means "take this entry off the
|
||||
// map", which has to clear the country rather than fall back to the old one.
|
||||
if (data.location_lat !== undefined || data.location_lng !== undefined) {
|
||||
const lat = data.location_lat !== undefined ? data.location_lat : entry.location_lat;
|
||||
const lng = data.location_lng !== undefined ? data.location_lng : entry.location_lng;
|
||||
fields.push('country_code = ?');
|
||||
values.push(this.countryFor(lat, lng));
|
||||
}
|
||||
|
||||
// if adding story to a skeleton, promote to entry
|
||||
if (entry.type === 'skeleton' && data.story && data.story.trim()) {
|
||||
fields.push('type = ?');
|
||||
|
||||
@@ -26,19 +26,21 @@ import type { JourneyEntry } from '../../types';
|
||||
* the reason the JSON does, so that a client comparing it with `=== true` is
|
||||
* comparing against something that can be true on every path.
|
||||
*/
|
||||
export interface JourneyEntryWire extends Omit<JourneyEntry, 'tags' | 'pros_cons' | 'stats_excluded'> {
|
||||
export interface JourneyEntryWire extends Omit<JourneyEntry, 'tags' | 'pros_cons' | 'stats_excluded' | 'dismissed'> {
|
||||
tags: string[];
|
||||
pros_cons: { pros: string[]; cons: string[] } | null;
|
||||
stats_excluded: boolean;
|
||||
dismissed: boolean;
|
||||
}
|
||||
|
||||
/** Decode a row's JSON columns and its flag. The one place that knows how they are stored. */
|
||||
/** Decode a row's JSON columns and its flags. The one place that knows how they are stored. */
|
||||
export function decodeEntryRow(row: JourneyEntry): JourneyEntryWire {
|
||||
const { tags, pros_cons, stats_excluded, ...rest } = row;
|
||||
const { tags, pros_cons, stats_excluded, dismissed, ...rest } = row;
|
||||
return {
|
||||
...rest,
|
||||
tags: tags ? JSON.parse(tags) : [],
|
||||
pros_cons: pros_cons ? JSON.parse(pros_cons) : null,
|
||||
stats_excluded: !!stats_excluded,
|
||||
dismissed: !!dismissed,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -164,7 +164,7 @@ export class JourneyShareService {
|
||||
// Entries with photos
|
||||
const entries = this.db.prepare(`
|
||||
SELECT je.* FROM journey_entries je
|
||||
WHERE je.journey_id = ? AND je.type != 'skeleton'
|
||||
WHERE je.journey_id = ? AND je.type != 'skeleton' AND je.dismissed = 0
|
||||
ORDER BY je.entry_date, je.sort_order
|
||||
`).all(row.journey_id) as any[];
|
||||
|
||||
|
||||
@@ -694,6 +694,17 @@ export class JourneyController {
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
// ── Suggestions ─────────────────────────────────────────────────────────
|
||||
@Post(':id/suggestions/restore')
|
||||
@HttpCode(200)
|
||||
restoreSuggestions(@CurrentUser() user: User, @Param('id') id: string) {
|
||||
const result = this.journey.restoreDismissedSuggestions(Number(id), user.id);
|
||||
if (!result) {
|
||||
throw new HttpException({ error: 'Not allowed' }, 403);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
// ── User Preferences ────────────────────────────────────────────────────
|
||||
@Patch(':id/preferences')
|
||||
preferences(@CurrentUser() user: User, @Param('id') id: string, @Body() body: JourneyPreferencesDto) {
|
||||
|
||||
@@ -247,21 +247,44 @@ export class JourneyMcp {
|
||||
title: z.string().min(1).max(200).optional(),
|
||||
subtitle: z.string().max(300).optional(),
|
||||
status: z.enum(['draft', 'active', 'completed', 'archived']).optional(),
|
||||
show_verdict: z.boolean().optional().describe('Whether entries in this journey offer a pros/cons list'),
|
||||
show_mood: z.boolean().optional().describe('Whether entries in this journey offer a mood'),
|
||||
show_weather: z.boolean().optional().describe('Whether entries in this journey offer a weather note'),
|
||||
},
|
||||
annotations: TOOL_ANNOTATIONS_WRITE,
|
||||
when: journeyAddonOn,
|
||||
access: { group: 'journey', mode: 'write' },
|
||||
})
|
||||
updateJourney(
|
||||
{ journeyId, title, subtitle, status }: { journeyId: number; title?: string; subtitle?: string; status?: string },
|
||||
{ journeyId, ...data }: {
|
||||
journeyId: number; title?: string; subtitle?: string; status?: string;
|
||||
show_verdict?: boolean; show_mood?: boolean; show_weather?: boolean;
|
||||
},
|
||||
ctx: McpContext,
|
||||
) {
|
||||
if (this.auth.isDemoUser(ctx.userId)) return demoDenied();
|
||||
const journey = this.journey.updateJourney(journeyId, ctx.userId, { title, subtitle, status });
|
||||
const journey = this.journey.updateJourney(journeyId, ctx.userId, data);
|
||||
if (!journey) return notFound('Journey not found or access denied.');
|
||||
return ok({ journey });
|
||||
}
|
||||
|
||||
@Tool({
|
||||
name: 'restore_journey_suggestions',
|
||||
description: 'Bring back every trip-derived suggestion that was dismissed from this journey. Answers with how many came back.',
|
||||
inputSchema: {
|
||||
journeyId: z.number().int().positive(),
|
||||
},
|
||||
annotations: TOOL_ANNOTATIONS_WRITE,
|
||||
when: journeyAddonOn,
|
||||
access: { group: 'journey', mode: 'write' },
|
||||
})
|
||||
restoreJourneySuggestions({ journeyId }: { journeyId: number }, ctx: McpContext) {
|
||||
if (this.auth.isDemoUser(ctx.userId)) return demoDenied();
|
||||
const result = this.journey.restoreDismissedSuggestions(journeyId, ctx.userId);
|
||||
if (!result) return notFound('Journey not found or access denied.');
|
||||
return ok(result);
|
||||
}
|
||||
|
||||
@Tool({
|
||||
name: 'delete_journey',
|
||||
description: 'Delete a journey. Owner only — this cannot be undone.',
|
||||
@@ -366,6 +389,7 @@ export class JourneyMcp {
|
||||
type: ENTRY_TYPE.optional().describe('Promote a trip-derived "skeleton" to a real "entry" once it has been written up'),
|
||||
sort_order: z.number().int().min(0).optional(),
|
||||
stats_excluded: z.boolean().optional().describe('True leaves the entry in the journal but takes it off the journey route and out of its distance, countries and step count (see get_journey_stats); false puts it back. For the home airport, a stopover, the place the trip was planned from'),
|
||||
dismissed: z.boolean().optional().describe('True waves a trip-derived suggestion away: it leaves the journey without being deleted, so the trip sync does not offer it again. restore_journey_suggestions brings every dismissed one back'),
|
||||
},
|
||||
annotations: TOOL_ANNOTATIONS_WRITE,
|
||||
when: journeyAddonOn,
|
||||
@@ -378,6 +402,7 @@ export class JourneyMcp {
|
||||
location_lng?: number | null; mood?: string | null; weather?: string | null;
|
||||
tags?: string[] | null; pros_cons?: { pros: string[]; cons: string[] } | null;
|
||||
visibility?: EntryVisibility; type?: EntryType; sort_order?: number; stats_excluded?: boolean;
|
||||
dismissed?: boolean;
|
||||
},
|
||||
ctx: McpContext,
|
||||
) {
|
||||
|
||||
@@ -44,6 +44,7 @@ export class JourneyService {
|
||||
getSuggestions(userId: number) { return this.journey.getSuggestions(userId); }
|
||||
listUserTrips(userId: number) { return this.journey.listUserTrips(userId); }
|
||||
updateJourneyPreferences(id: number, userId: number, data: Parameters<typeof this.journey.updateJourneyPreferences>[2]) { return this.journey.updateJourneyPreferences(id, userId, data); }
|
||||
restoreDismissedSuggestions(id: number, userId: number) { return this.journey.restoreDismissedSuggestions(id, userId); }
|
||||
|
||||
// Trips
|
||||
addTripToJourney(id: number, tripId: number, userId: number) { return this.journey.addTripToJourney(id, tripId, userId); }
|
||||
|
||||
@@ -406,6 +406,13 @@ export interface JourneyEntry {
|
||||
pros_cons?: string | null;
|
||||
visibility: 'private' | 'shared' | 'public';
|
||||
sort_order: number;
|
||||
/** ISO 3166-1 alpha-2, resolved from the coordinates when the entry was written. */
|
||||
country_code?: string | null;
|
||||
/**
|
||||
* 0/1 as the row holds it. A suggestion the traveller waved away: the row stays,
|
||||
* so the trip sync does not create it again, and every read leaves it out.
|
||||
*/
|
||||
dismissed: number;
|
||||
/**
|
||||
* 0/1 as the row holds it. Switched on, the entry stays in the journal but
|
||||
* is left out of the route and the figures Studio prints (discussion #2064).
|
||||
|
||||
@@ -49,7 +49,7 @@ vi.mock('../../src/nest/memories/photo-resolver.service', async (importOriginal)
|
||||
const { jsvc } = vi.hoisted(() => ({
|
||||
jsvc: {
|
||||
listJourneys: vi.fn(), createJourney: vi.fn(), getJourneyFull: vi.fn(),
|
||||
journeyStats: vi.fn(), updateEntry: vi.fn(),
|
||||
journeyStats: vi.fn(), updateEntry: vi.fn(), restoreDismissedSuggestions: vi.fn(),
|
||||
},
|
||||
}));
|
||||
import { JourneyDomainService } from '../../src/nest/journey/journey-domain.service';
|
||||
@@ -395,6 +395,26 @@ describe('Journey e2e (real auth guard + temp SQLite)', () => {
|
||||
expect(res.status).toBe(400);
|
||||
});
|
||||
|
||||
it('restoring suggestions answers 200 with the count, not 201', async () => {
|
||||
// POST defaults to 201 in Nest, and the route carries @HttpCode(200) to match
|
||||
// every other action-shaped POST in this controller.
|
||||
jsvc.restoreDismissedSuggestions.mockReturnValueOnce({ restored: 2 });
|
||||
const res = await request(server)
|
||||
.post('/api/journeys/9/suggestions/restore')
|
||||
.set('Cookie', sessionCookie(1));
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body).toEqual({ restored: 2 });
|
||||
});
|
||||
|
||||
it('restoring suggestions 403 for someone who may not edit the journey', async () => {
|
||||
jsvc.restoreDismissedSuggestions.mockReturnValueOnce(null);
|
||||
const res = await request(server)
|
||||
.post('/api/journeys/9/suggestions/restore')
|
||||
.set('Cookie', sessionCookie(1));
|
||||
expect(res.status).toBe(403);
|
||||
expect(res.body).toEqual({ error: 'Not allowed' });
|
||||
});
|
||||
|
||||
it('public journey 404 for an unknown token', async () => {
|
||||
sharesvc.getPublicJourney.mockReturnValueOnce(null);
|
||||
const res = await request(server).get('/api/public/journey/bad');
|
||||
|
||||
@@ -2334,3 +2334,186 @@ describe('addTripToJourney guards', () => {
|
||||
expect(testDb.prepare('SELECT 1 FROM journey_photos WHERE journey_id = ?').all(journey.id)).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
// -- Dismissing a suggestion (discussion #2299) --------------------------------
|
||||
|
||||
describe('dismissed suggestions', () => {
|
||||
it('JOURNEY-SVC-103: a dismissed suggestion leaves every read but keeps its row', () => {
|
||||
const { user } = createUser(testDb);
|
||||
const journey = createJourney(testDb, user.id);
|
||||
const keep = createJourneyEntry(testDb, journey.id, user.id, { type: 'skeleton', title: 'Museum' });
|
||||
const drop = createJourneyEntry(testDb, journey.id, user.id, { type: 'skeleton', title: 'Aquarium' });
|
||||
|
||||
svc.updateEntry(drop.id, user.id, { dismissed: true });
|
||||
|
||||
const full = svc.getJourneyFull(journey.id, user.id)!;
|
||||
expect(full.entries.map((e: { id: number }) => e.id)).toEqual([keep.id]);
|
||||
expect(svc.listEntries(journey.id, user.id)!.map((e) => e.id)).toEqual([keep.id]);
|
||||
// The row has to survive, or syncTripPlaces offers the same place again.
|
||||
expect(testDb.prepare('SELECT dismissed FROM journey_entries WHERE id = ?').get(drop.id)).toEqual({
|
||||
dismissed: 1,
|
||||
});
|
||||
});
|
||||
|
||||
it('JOURNEY-SVC-104: the journey reports how many were dismissed', () => {
|
||||
const { user } = createUser(testDb);
|
||||
const journey = createJourney(testDb, user.id);
|
||||
const a = createJourneyEntry(testDb, journey.id, user.id, { type: 'skeleton' });
|
||||
const b = createJourneyEntry(testDb, journey.id, user.id, { type: 'skeleton' });
|
||||
createJourneyEntry(testDb, journey.id, user.id, { type: 'skeleton' });
|
||||
|
||||
svc.updateEntry(a.id, user.id, { dismissed: true });
|
||||
svc.updateEntry(b.id, user.id, { dismissed: true });
|
||||
|
||||
expect(svc.getJourneyFull(journey.id, user.id)!.dismissed_count).toBe(2);
|
||||
});
|
||||
|
||||
it('JOURNEY-SVC-105: a dismissed place is not offered again by the trip sync', () => {
|
||||
const { user } = createUser(testDb);
|
||||
const journey = createJourney(testDb, user.id);
|
||||
const trip = createTrip(testDb, user.id);
|
||||
const day = createDay(testDb, trip.id, { day_number: 1, date: '2026-01-15' });
|
||||
const place = createPlace(testDb, trip.id, { name: 'Aquarium' });
|
||||
createDayAssignment(testDb, day.id, place.id);
|
||||
|
||||
svc.addTripToJourney(journey.id, trip.id, user.id);
|
||||
const skeleton = svc.listEntries(journey.id, user.id)!.find((e) => e.type === 'skeleton')!;
|
||||
svc.updateEntry(skeleton.id, user.id, { dismissed: true });
|
||||
|
||||
svc.syncTripPlaces(journey.id, trip.id, user.id);
|
||||
|
||||
expect(svc.listEntries(journey.id, user.id)!.filter((e) => e.type === 'skeleton')).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('JOURNEY-SVC-106: restoring brings them all back and says how many', () => {
|
||||
const { user } = createUser(testDb);
|
||||
const journey = createJourney(testDb, user.id);
|
||||
const a = createJourneyEntry(testDb, journey.id, user.id, { type: 'skeleton' });
|
||||
const b = createJourneyEntry(testDb, journey.id, user.id, { type: 'skeleton' });
|
||||
svc.updateEntry(a.id, user.id, { dismissed: true });
|
||||
svc.updateEntry(b.id, user.id, { dismissed: true });
|
||||
|
||||
expect(svc.restoreDismissedSuggestions(journey.id, user.id)).toEqual({ restored: 2 });
|
||||
expect(svc.listEntries(journey.id, user.id)).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('JOURNEY-SVC-107: restoring nothing is not an error', () => {
|
||||
const { user } = createUser(testDb);
|
||||
const journey = createJourney(testDb, user.id);
|
||||
|
||||
expect(svc.restoreDismissedSuggestions(journey.id, user.id)).toEqual({ restored: 0 });
|
||||
});
|
||||
|
||||
it('JOURNEY-SVC-108: a viewer may not restore', () => {
|
||||
const { user: owner } = createUser(testDb);
|
||||
const { user: viewer } = createUser(testDb);
|
||||
const journey = createJourney(testDb, owner.id);
|
||||
addJourneyContributor(testDb, journey.id, viewer.id, 'viewer');
|
||||
|
||||
expect(svc.restoreDismissedSuggestions(journey.id, viewer.id)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
// -- The country behind an entry's coordinates ---------------------------------
|
||||
|
||||
describe('country_code', () => {
|
||||
it('JOURNEY-SVC-109: a created entry resolves its country from its coordinates', () => {
|
||||
const { user } = createUser(testDb);
|
||||
const journey = createJourney(testDb, user.id);
|
||||
|
||||
const entry = svc.createEntry(journey.id, user.id, {
|
||||
entry_date: '2026-01-15',
|
||||
location_lat: 52.52,
|
||||
location_lng: 13.405,
|
||||
})!;
|
||||
|
||||
expect(entry.country_code).toBe('DE');
|
||||
});
|
||||
|
||||
it('JOURNEY-SVC-110: an entry with no coordinates has no country', () => {
|
||||
const { user } = createUser(testDb);
|
||||
const journey = createJourney(testDb, user.id);
|
||||
|
||||
const entry = svc.createEntry(journey.id, user.id, { entry_date: '2026-01-15' })!;
|
||||
|
||||
expect(entry.country_code).toBeNull();
|
||||
});
|
||||
|
||||
it('JOURNEY-SVC-111: moving the pin moves the country with it', () => {
|
||||
const { user } = createUser(testDb);
|
||||
const journey = createJourney(testDb, user.id);
|
||||
const entry = svc.createEntry(journey.id, user.id, {
|
||||
entry_date: '2026-01-15',
|
||||
location_lat: 52.52,
|
||||
location_lng: 13.405,
|
||||
})!;
|
||||
|
||||
const moved = svc.updateEntry(entry.id, user.id, { location_lat: 48.8584, location_lng: 2.2945 })!;
|
||||
|
||||
expect(moved.country_code).toBe('FR');
|
||||
});
|
||||
|
||||
it('JOURNEY-SVC-112: taking the pin off clears the country', () => {
|
||||
const { user } = createUser(testDb);
|
||||
const journey = createJourney(testDb, user.id);
|
||||
const entry = svc.createEntry(journey.id, user.id, {
|
||||
entry_date: '2026-01-15',
|
||||
location_lat: 52.52,
|
||||
location_lng: 13.405,
|
||||
})!;
|
||||
|
||||
const cleared = svc.updateEntry(entry.id, user.id, {
|
||||
location_lat: null as unknown as number,
|
||||
location_lng: null as unknown as number,
|
||||
})!;
|
||||
|
||||
expect(cleared.country_code).toBeNull();
|
||||
});
|
||||
|
||||
it('JOURNEY-SVC-113: an edit that does not touch the pin leaves the country alone', () => {
|
||||
const { user } = createUser(testDb);
|
||||
const journey = createJourney(testDb, user.id);
|
||||
const entry = svc.createEntry(journey.id, user.id, {
|
||||
entry_date: '2026-01-15',
|
||||
location_lat: 52.52,
|
||||
location_lng: 13.405,
|
||||
})!;
|
||||
|
||||
const renamed = svc.updateEntry(entry.id, user.id, { title: 'Berlin' })!;
|
||||
|
||||
expect(renamed.country_code).toBe('DE');
|
||||
});
|
||||
});
|
||||
|
||||
// -- Which optional fields a journey keeps -------------------------------------
|
||||
|
||||
describe('entry field switches', () => {
|
||||
it('JOURNEY-SVC-114: a fresh journey keeps all three', () => {
|
||||
const { user } = createUser(testDb);
|
||||
const journey = createJourney(testDb, user.id);
|
||||
|
||||
const row = svc.getJourneyFull(journey.id, user.id)! as unknown as Record<string, number>;
|
||||
|
||||
expect([row.show_verdict, row.show_mood, row.show_weather]).toEqual([1, 1, 1]);
|
||||
});
|
||||
|
||||
it('JOURNEY-SVC-115: the owner can put one away, and booleans reach the INTEGER column', () => {
|
||||
const { user } = createUser(testDb);
|
||||
const journey = createJourney(testDb, user.id);
|
||||
|
||||
svc.updateJourney(journey.id, user.id, { show_mood: false, show_weather: false });
|
||||
|
||||
const row = svc.getJourneyFull(journey.id, user.id)! as unknown as Record<string, number>;
|
||||
expect([row.show_verdict, row.show_mood, row.show_weather]).toEqual([1, 0, 0]);
|
||||
});
|
||||
|
||||
it('JOURNEY-SVC-116: an editor may not reshape the journey', () => {
|
||||
const { user: owner } = createUser(testDb);
|
||||
const { user: editor } = createUser(testDb);
|
||||
const journey = createJourney(testDb, owner.id);
|
||||
addJourneyContributor(testDb, journey.id, editor.id, 'editor');
|
||||
|
||||
expect(svc.updateJourney(journey.id, editor.id, { show_mood: false })).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -356,6 +356,15 @@ describe('JourneyController', () => {
|
||||
expect(ctl(svc({ reorderEntries: vi.fn().mockReturnValue(true) } as Partial<JourneyService>)).reorderEntries(user, '9', { orderedIds: [3, 1, 2] })).toEqual({ success: true });
|
||||
});
|
||||
|
||||
it('restoreSuggestions answers with the count, and refuses a viewer the same way its siblings do', () => {
|
||||
const restore = vi.fn().mockReturnValue({ restored: 3 });
|
||||
expect(ctl(svc({ restoreDismissedSuggestions: restore } as Partial<JourneyService>)).restoreSuggestions(user, '9')).toEqual({ restored: 3 });
|
||||
expect(restore).toHaveBeenCalledWith(9, 1);
|
||||
expect(
|
||||
thrown(() => ctl(svc({ restoreDismissedSuggestions: vi.fn().mockReturnValue(null) } as Partial<JourneyService>)).restoreSuggestions(user, '9')),
|
||||
).toEqual({ status: 403, body: { error: 'Not allowed' } });
|
||||
});
|
||||
|
||||
it('preferences returns the result on success', () => {
|
||||
expect(ctl(svc({ updateJourneyPreferences: vi.fn().mockReturnValue({ ok: true }) } as Partial<JourneyService>)).preferences(user, '9', { theme: 'dark' })).toEqual({ ok: true });
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user