fix(journey): fix issue #704 — active logic, archive, places rename, search, trip reminders

- Derive journey lifecycle from linked trip dates (live/upcoming/completed/draft)
  instead of relying solely on status field; status=archived always wins
- Add Archive/Restore Journey action in journey settings dialog
- Rename cities → places end-to-end (SQL alias, TS types, stats field, all locales)
- Wire up search icon: toggles inline input, filters by title+subtitle client-side
- Fix channelConfigured check: trip reminders enabled by default since inapp is
  always available; remove channel check, controlled solely by admin setting
- Expose notify_trip_reminder toggle in Admin → Settings → Notifications
- Add trip_date_min/trip_date_max to listJourneys SQL for client-side lifecycle
- Add archived status to Journey type (server + client)
- Update all 15 locale files with new keys (search, archive, places, trip reminders)
This commit is contained in:
jubnl
2026-04-17 16:59:23 +02:00
parent 4a5a461d25
commit 3b94727c07
31 changed files with 507 additions and 89 deletions
+2 -7
View File
@@ -166,14 +166,9 @@ function startTripReminders(): void {
const reminderEnabled = getSetting('notify_trip_reminder') !== 'false';
const channelsRaw = getSetting('notification_channels') || getSetting('notification_channel') || 'none';
const activeChannels = channelsRaw === 'none' ? [] : channelsRaw.split(',').map((c: string) => c.trim());
const hasEmail = activeChannels.includes('email') && !!(getSetting('smtp_host') || '').trim();
const hasWebhook = activeChannels.includes('webhook');
const channelReady = hasEmail || hasWebhook;
if (!channelReady || !reminderEnabled) {
if (!reminderEnabled) {
const { logInfo: li } = require('./services/auditLog');
const reason = !channelReady ? 'no notification channels configured' : 'trip reminders disabled in settings';
li(`Trip reminders: disabled (${reason})`);
li('Trip reminders: disabled in settings');
return;
}
+2 -2
View File
@@ -31,6 +31,7 @@ const ADMIN_SETTINGS_KEYS = [
'allow_registration', 'allowed_file_types', 'require_mfa',
'smtp_host', 'smtp_port', 'smtp_user', 'smtp_pass', 'smtp_from', 'smtp_skip_tls_verify',
'notification_channels', 'admin_webhook_url', 'admin_ntfy_server', 'admin_ntfy_topic', 'admin_ntfy_token',
'notify_trip_reminder',
'password_login', 'password_registration', 'oidc_login', 'oidc_registration',
];
@@ -227,8 +228,7 @@ export function getAppConfig(authenticatedUser: { id: number } | null) {
const notifChannelsRaw = (db.prepare("SELECT value FROM app_settings WHERE key = 'notification_channels'").get() as { value: string } | undefined)?.value || notifChannel;
const activeChannels = notifChannelsRaw === 'none' ? [] : notifChannelsRaw.split(',').map((c: string) => c.trim()).filter(Boolean);
const hasWebhookEnabled = activeChannels.includes('webhook');
const channelConfigured = (activeChannels.includes('email') && hasSmtpHost) || hasWebhookEnabled;
const tripRemindersEnabled = channelConfigured && tripReminderSetting !== 'false';
const tripRemindersEnabled = tripReminderSetting !== 'false';
const setupComplete = userCount > 0 && !(db.prepare("SELECT id FROM users WHERE role = 'admin' AND must_change_password = 1 LIMIT 1").get());
return {
+8 -4
View File
@@ -59,12 +59,14 @@ export function listJourneys(userId: number) {
SELECT DISTINCT j.*,
(SELECT COUNT(*) FROM journey_entries je WHERE je.journey_id = j.id AND je.type != 'skeleton') as entry_count,
(SELECT COUNT(*) FROM journey_photos jp JOIN journey_entries je2 ON jp.entry_id = je2.id WHERE je2.journey_id = j.id) as photo_count,
(SELECT COUNT(DISTINCT je3.location_name) FROM journey_entries je3 WHERE je3.journey_id = j.id AND je3.location_name IS NOT NULL AND je3.location_name != '') as city_count
(SELECT COUNT(DISTINCT je3.location_name) FROM journey_entries je3 WHERE je3.journey_id = j.id AND je3.location_name IS NOT NULL AND je3.location_name != '') as place_count,
(SELECT MIN(t.start_date) FROM journey_trips jt JOIN trips t ON jt.trip_id = t.id WHERE jt.journey_id = j.id) as trip_date_min,
(SELECT MAX(t.end_date) FROM journey_trips jt JOIN trips t ON jt.trip_id = t.id WHERE jt.journey_id = j.id) as trip_date_max
FROM journeys j
LEFT JOIN journey_contributors jc ON j.id = jc.journey_id AND jc.user_id = ?
WHERE j.user_id = ? OR jc.user_id = ?
ORDER BY j.updated_at DESC
`).all(userId, userId, userId) as (Journey & { entry_count: number; photo_count: number; city_count: number })[];
`).all(userId, userId, userId) as (Journey & { entry_count: number; photo_count: number; place_count: number; trip_date_min: string | null; trip_date_max: string | null })[];
}
export function createJourney(userId: number, data: {
@@ -159,7 +161,7 @@ export function getJourneyFull(journeyId: number, userId: number) {
// stats
const entryCount = entries.filter(e => e.type === 'entry').length;
const photoCount = photos.length;
const cities = [...new Set(entries.map(e => e.location_name).filter(Boolean))];
const places = [...new Set(entries.map(e => e.location_name).filter(Boolean))];
const userPrefs = db.prepare(
'SELECT hide_skeletons FROM journey_contributors WHERE journey_id = ? AND user_id = ?'
@@ -170,7 +172,7 @@ export function getJourneyFull(journeyId: number, userId: number) {
entries: enrichedEntries,
trips,
contributors,
stats: { entries: entryCount, photos: photoCount, cities: cities.length },
stats: { entries: entryCount, photos: photoCount, places: places.length },
hide_skeletons: !!(userPrefs?.hide_skeletons),
};
}
@@ -184,11 +186,13 @@ export function updateJourney(journeyId: number, userId: number, data: Partial<{
}>): Journey | null {
if (!canEdit(journeyId, userId)) return null;
const ALLOWED_STATUSES = ['draft', 'active', 'completed', 'archived'];
const allowed = ['title', 'subtitle', 'cover_gradient', 'cover_image', 'status'];
const fields: string[] = [];
const values: unknown[] = [];
for (const [key, val] of Object.entries(data)) {
if (val !== undefined && allowed.includes(key)) {
if (key === 'status' && !ALLOWED_STATUSES.includes(val as string)) continue;
fields.push(`${key} = ?`);
values.push(val);
}
+1 -1
View File
@@ -132,7 +132,7 @@ export function getPublicJourney(token: string) {
const stats = {
entries: entries.length,
photos: photos.length,
cities: new Set(entries.filter(e => e.location_name).map(e => e.location_name)).size,
places: new Set(entries.filter(e => e.location_name).map(e => e.location_name)).size,
};
return {
+1 -1
View File
@@ -313,7 +313,7 @@ export interface Journey {
subtitle?: string | null;
cover_gradient?: string | null;
cover_image?: string | null;
status: 'draft' | 'active' | 'completed';
status: 'draft' | 'active' | 'completed' | 'archived';
created_at: number;
updated_at: number;
}
@@ -202,7 +202,7 @@ describe('listJourneys', () => {
expect(result).toHaveLength(1);
expect(result[0].title).toBe('Road Trip');
expect(result[0].entry_count).toBe(2);
expect(result[0].city_count).toBe(2);
expect(result[0].place_count).toBe(2);
});
it('JOURNEY-SVC-012: includes journeys where user is contributor', () => {
@@ -226,6 +226,21 @@ describe('listJourneys', () => {
expect(result).toHaveLength(0);
});
it('JOURNEY-SVC-013b: returns trip_date_min/max aggregated from linked trips', () => {
const { user } = createUser(testDb);
const journey = createJourney(testDb, user.id, { title: 'Multi Trip' });
const trip1 = createTrip(testDb, user.id, { title: 'Trip A', start_date: '2025-06-01', end_date: '2025-06-10' });
const trip2 = createTrip(testDb, user.id, { title: 'Trip B', start_date: '2026-03-15', end_date: '2026-03-20' });
addTripToJourney(journey.id, trip1.id, user.id);
addTripToJourney(journey.id, trip2.id, user.id);
const result = listJourneys(user.id);
expect(result).toHaveLength(1);
expect(result[0].trip_date_min).toBe('2025-06-01');
expect(result[0].trip_date_max).toBe('2026-03-20');
});
});
describe('createJourney (service)', () => {
@@ -335,6 +350,26 @@ describe('updateJourney', () => {
expect(result).not.toBeNull();
expect(result!.title).toBe('Same');
});
it('JOURNEY-SVC-021b: accepts archived status', () => {
const { user } = createUser(testDb);
const journey = createJourney(testDb, user.id, { title: 'To Archive' });
const result = updateJourney(journey.id, user.id, { status: 'archived' });
expect(result).not.toBeNull();
expect(result!.status).toBe('archived');
});
it('JOURNEY-SVC-021c: ignores invalid status value', () => {
const { user } = createUser(testDb);
const journey = createJourney(testDb, user.id, { title: 'Stay Active' });
const result = updateJourney(journey.id, user.id, { status: 'bogus' });
expect(result).not.toBeNull();
expect(result!.status).toBe('active');
});
});
describe('deleteJourney', () => {
@@ -336,7 +336,7 @@ describe('getPublicJourney', () => {
expect(result!.entries).toHaveLength(2);
expect(result!.stats.entries).toBe(2);
expect(result!.stats.photos).toBe(1);
expect(result!.stats.cities).toBe(2);
expect(result!.stats.places).toBe(2);
expect(result!.permissions.share_timeline).toBe(true);
expect(result!.permissions.share_gallery).toBe(true);
expect(result!.permissions.share_map).toBe(false);
@@ -397,6 +397,6 @@ describe('getPublicJourney', () => {
expect(result!.entries).toEqual([]);
expect(result!.stats.entries).toBe(0);
expect(result!.stats.photos).toBe(0);
expect(result!.stats.cities).toBe(0);
expect(result!.stats.places).toBe(0);
});
});