diff --git a/client/src/components/Planner/DayDetailPanel.test.tsx b/client/src/components/Planner/DayDetailPanel.test.tsx
index 03494c12c..d0734c600 100644
--- a/client/src/components/Planner/DayDetailPanel.test.tsx
+++ b/client/src/components/Planner/DayDetailPanel.test.tsx
@@ -1917,8 +1917,9 @@ describe('DayDetailPanel time format', () => {
);
render();
+ // The arrival day shows only the check-in; the check-out belongs to the last day (#2393).
expect(await screen.findByText('15:00')).toBeInTheDocument();
- expect(screen.getByText('11:00')).toBeInTheDocument();
+ expect(screen.queryByText('11:00')).not.toBeInTheDocument();
});
});
diff --git a/client/src/components/Planner/LocationSelect.test.tsx b/client/src/components/Planner/LocationSelect.test.tsx
index 22ec2eb15..f625ebdca 100644
--- a/client/src/components/Planner/LocationSelect.test.tsx
+++ b/client/src/components/Planner/LocationSelect.test.tsx
@@ -99,7 +99,8 @@ describe('LocationSelect', () => {
await screen.findByText('Gare du Nord');
expect(body.query).toBe('Gare du Nord');
- expect(lang).toBe('en-US');
+ // The place name language, which follows the app's language when none is picked (#1799).
+ expect(lang).toBe('en');
});
it('FE-PLANNER-LOCSEL-008: a hit whose address equals its name shows no duplicate subtitle', async () => {
diff --git a/client/src/components/Vacay/VacayCalendar.test.tsx b/client/src/components/Vacay/VacayCalendar.test.tsx
index d401e0847..1ea2a516b 100644
--- a/client/src/components/Vacay/VacayCalendar.test.tsx
+++ b/client/src/components/Vacay/VacayCalendar.test.tsx
@@ -369,7 +369,7 @@ describe('VacayCalendar', () => {
// Now click a month card cell
await user.click(screen.getByText('click-0'))
- expect(toggleCompanyHoliday).toHaveBeenCalledWith('2025-01-01')
+ expect(toggleCompanyHoliday.mock.calls[0][0]).toBe('2025-01-01')
expect(toggleEntry).not.toHaveBeenCalled()
})
diff --git a/client/src/pages/JourneyPublicPage.tsx b/client/src/pages/JourneyPublicPage.tsx
index 79caf9279..85840f512 100644
--- a/client/src/pages/JourneyPublicPage.tsx
+++ b/client/src/pages/JourneyPublicPage.tsx
@@ -28,7 +28,7 @@ import MobileEntryView from '../components/Journey/MobileEntryView';
import MobileMapTimeline from '../components/Journey/MobileMapTimeline';
import PhotoLightbox from '../components/Journey/PhotoLightbox';
import EmptyState from '../components/shared/EmptyState';
-import PublicLanguagePicker from '../components/shared/PublicLanguagePicker';
+import { PAGE_WIDTH, SharedFooter, SharedHero, SharedLoading, SharedTabBar, SharedTopBar } from './sharedTrip/SharedChrome';
import { useTranslation } from '../i18n';
import { formatLocationName } from '../utils/formatters';
import { posterlessVideo } from './journeyDetail/JourneyDetailPage.helpers';
@@ -129,13 +129,7 @@ export default function JourneyPublicPage() {
desktopTwoColumn,
} = useJourneyPublic();
- if (loading) {
- return (
-
- );
- }
+ if (loading) return ;
if (error || !data) {
return (
@@ -578,202 +572,29 @@ export default function JourneyPublicPage() {
);
- // Shared view tab bar
- const renderTabs = (views: typeof availableViews) =>
- views.length > 1 && (
-
- {views.map((v) => (
-
- ))}
-
- );
+ // The same frame as a shared trip (#2541): the planner's top bar, the journey as
+ // a postcard, and the views as tabs that stay in reach. The mobile map view keeps
+ // its own floating switch above the full screen map.
+ const firstDate = sortedDates[0];
+ const lastDate = sortedDates[sortedDates.length - 1];
+ const heroStats = [
+ { key: 'entries', value: stats.entries, label: t('journey.stats.entries') },
+ { key: 'photos', value: stats.photos, label: t('journey.stats.photos') },
+ { key: 'places', value: stats.places, label: t('journey.stats.places') },
+ ].filter(st => st.value > 0);
+ const floatingSwitch = isMobile && view === 'timeline' && perms.share_timeline && perms.share_map;
return (
-
- {/* Hero */}
-
- {journey.cover_image && (
-
- )}
-
+
+
+
-
-
-
-
- {/* Logo */}
-
-

-
-
-
- {t('journey.public.tagline')}
-
-
-
- {journey.title}
-
-
- {journey.subtitle && (
-
- {journey.subtitle}
-
+ {!floatingSwitch && (
+
({ id: v.id, label: v.label, icon: v.icon }))} active={view} onChange={id => setView(id as typeof view)} />
)}
-
- {/* Stats pill */}
-
-
- {stats.entries} {t('journey.stats.entries')}
-
- ·
-
- {stats.photos} {t('journey.stats.photos')}
-
- ·
-
- {stats.places} {t('journey.stats.places')}
-
-
-
-
- {t('journey.public.readOnly')}
-
{/* Content */}
@@ -782,7 +603,6 @@ export default function JourneyPublicPage() {
{/* Left: feed */}
- {renderTabs(availableViews)}
{view === 'timeline' && perms.share_timeline && renderTimeline()}
{view === 'gallery' && perms.share_gallery && renderGallery()}
@@ -843,8 +663,6 @@ export default function JourneyPublicPage() {
)}
- {renderTabs(availableViews)}
-
{/* Mobile combined map+timeline (public, read-only) */}
{isMobile && view === 'timeline' && perms.share_timeline && perms.share_map && (
+
+
{/* Lightbox */}
{lightbox && (
setLightbox(null)} />
diff --git a/client/src/store/vacayStore.test.ts b/client/src/store/vacayStore.test.ts
index 4c8b558ee..0c6220eab 100644
--- a/client/src/store/vacayStore.test.ts
+++ b/client/src/store/vacayStore.test.ts
@@ -153,7 +153,7 @@ describe('vacayStore optimistic updates', () => {
await useVacayStore.getState().toggleCompanyHoliday('2025-12-25');
expect(seen[0]).toEqual([]);
- expect(seen[1]).toEqual([{ date: '2025-12-25' }]);
+ expect(seen[1]).toEqual([{ date: '2025-12-25', fraction: 1 }]);
});
it('FE-STORE-VCY-008: a rejected company-holiday toggle rolls the day back', async () => {
diff --git a/client/tests/unit/mobile/trip/useMPlanTimeline.test.ts b/client/tests/unit/mobile/trip/useMPlanTimeline.test.ts
index 6570a83c6..7e03504b4 100644
--- a/client/tests/unit/mobile/trip/useMPlanTimeline.test.ts
+++ b/client/tests/unit/mobile/trip/useMPlanTimeline.test.ts
@@ -667,6 +667,7 @@ describe('useMPlanTimeline', () => {
expect(result.current.routeModeOptions).toEqual([
{ key: 'driving', label: 'mobileTrip.profileDriving' },
{ key: 'walking', label: 'mobileTrip.profileWalking' },
+ { key: 'cycling', label: 'mobileTrip.profileCycling' },
{ key: 'plugin:ev/eco', label: 'Eco' },
{ key: 'plugin:ev/fast', label: 'Fast' },
])
diff --git a/client/tests/unit/mobile/vacay/useMVacay.test.tsx b/client/tests/unit/mobile/vacay/useMVacay.test.tsx
index f3352bcc5..773dd5466 100644
--- a/client/tests/unit/mobile/vacay/useMVacay.test.tsx
+++ b/client/tests/unit/mobile/vacay/useMVacay.test.tsx
@@ -332,7 +332,7 @@ describe('useMVacay', () => {
mocks.vacay = buildVacay({ plan: buildPlan({ company_holidays_enabled: true }) });
act(() => { rerender(); });
await act(async () => { await result.current.handleDayTap('2026-12-24'); });
- expect(toggleCompanyHoliday).toHaveBeenCalledWith('2026-12-24');
+ expect(toggleCompanyHoliday.mock.calls[0][0]).toBe('2026-12-24');
});
it('FE-MOB-MVAC-019: the entitlement stepper caps at 365 and never drops below used', async () => {
diff --git a/server/tests/e2e/assignments.e2e.test.ts b/server/tests/e2e/assignments.e2e.test.ts
index 30bac8979..876bf2e65 100644
--- a/server/tests/e2e/assignments.e2e.test.ts
+++ b/server/tests/e2e/assignments.e2e.test.ts
@@ -34,7 +34,7 @@ const { db } = vi.hoisted(() => {
tmp.exec(`CREATE TABLE day_assignments (id INTEGER PRIMARY KEY AUTOINCREMENT, day_id INTEGER NOT NULL,
place_id INTEGER NOT NULL, order_index INTEGER NOT NULL DEFAULT 0, notes TEXT,
assignment_time TEXT, assignment_end_time TEXT, leg_transport_mode TEXT,
- accommodation_id INTEGER,
+ accommodation_id INTEGER, route_excluded INTEGER NOT NULL DEFAULT 0,
created_at TEXT DEFAULT (datetime('now')));`);
// The auto-sort reads a booked night's hour off its booking, so the table has to be
// here even though nothing in this file books one.
diff --git a/server/tests/e2e/packing.e2e.test.ts b/server/tests/e2e/packing.e2e.test.ts
index 33bb6b481..41aed38bc 100644
--- a/server/tests/e2e/packing.e2e.test.ts
+++ b/server/tests/e2e/packing.e2e.test.ts
@@ -37,6 +37,7 @@ const { db } = vi.hoisted(() => {
is_private INTEGER NOT NULL DEFAULT 0,
owner_id INTEGER,
updated_at DATETIME,
+ packed_quantity INTEGER,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
);`);
tmp.exec(`CREATE TABLE packing_bags (
diff --git a/server/tests/e2e/places.e2e.test.ts b/server/tests/e2e/places.e2e.test.ts
index dd00e0d9c..2a5db8d46 100644
--- a/server/tests/e2e/places.e2e.test.ts
+++ b/server/tests/e2e/places.e2e.test.ts
@@ -41,8 +41,11 @@ const { db } = vi.hoisted(() => {
tmp.exec('CREATE TABLE days (id INTEGER PRIMARY KEY AUTOINCREMENT, trip_id INTEGER NOT NULL, day_number INTEGER, date TEXT, title TEXT);');
// The GPX export reads the trip title for and the filename.
tmp.exec('CREATE TABLE trips (id INTEGER PRIMARY KEY AUTOINCREMENT, title TEXT);');
+ // The places list reads each place's country and region (#2537).
+ tmp.exec(`CREATE TABLE place_regions (place_id INTEGER PRIMARY KEY, country_code TEXT NOT NULL,
+ region_code TEXT NOT NULL, region_name TEXT NOT NULL);`);
tmp.exec(`CREATE TABLE day_assignments (id INTEGER PRIMARY KEY AUTOINCREMENT, day_id INTEGER NOT NULL,
- place_id INTEGER NOT NULL, order_index INTEGER DEFAULT 0);`);
+ place_id INTEGER NOT NULL, order_index INTEGER DEFAULT 0, route_excluded INTEGER NOT NULL DEFAULT 0);`);
// reclaimPlaceImage ref-counts an uploaded thumbnail across both tables.
// Deleting a place cancels the nights booked at it (#2354), so the delete path
// reads this table even in a file that never books one.
diff --git a/server/tests/e2e/trips.e2e.test.ts b/server/tests/e2e/trips.e2e.test.ts
index acef60fb2..0aceefd03 100644
--- a/server/tests/e2e/trips.e2e.test.ts
+++ b/server/tests/e2e/trips.e2e.test.ts
@@ -34,7 +34,7 @@ const { db } = vi.hoisted(() => {
tmp.exec(`CREATE TABLE day_assignments (id INTEGER PRIMARY KEY AUTOINCREMENT, day_id INTEGER NOT NULL,
place_id INTEGER NOT NULL, order_index INTEGER DEFAULT 0, notes TEXT, reservation_status TEXT,
reservation_notes TEXT, reservation_datetime TEXT, assignment_time TEXT, assignment_end_time TEXT,
- created_at DATETIME DEFAULT CURRENT_TIMESTAMP);`);
+ route_excluded INTEGER NOT NULL DEFAULT 0, created_at DATETIME DEFAULT CURRENT_TIMESTAMP);`);
tmp.exec(`CREATE TABLE day_notes (id INTEGER PRIMARY KEY AUTOINCREMENT, day_id INTEGER NOT NULL,
trip_id INTEGER NOT NULL, text TEXT, time TEXT, icon TEXT, sort_order INTEGER DEFAULT 0,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP);`);
@@ -51,7 +51,7 @@ const { db } = vi.hoisted(() => {
tmp.exec(`CREATE TABLE packing_items (id INTEGER PRIMARY KEY AUTOINCREMENT, trip_id INTEGER NOT NULL,
name TEXT NOT NULL, checked INTEGER DEFAULT 0, category TEXT, sort_order INTEGER DEFAULT 0,
weight_grams INTEGER, bag_id INTEGER, quantity INTEGER NOT NULL DEFAULT 1,
- is_private INTEGER NOT NULL DEFAULT 0, owner_id INTEGER, updated_at DATETIME,
+ is_private INTEGER NOT NULL DEFAULT 0, owner_id INTEGER, updated_at DATETIME, packed_quantity INTEGER,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP);`);
tmp.exec(`CREATE TABLE packing_item_recipients (item_id INTEGER NOT NULL, user_id INTEGER NOT NULL,
PRIMARY KEY (item_id, user_id));`);
@@ -75,6 +75,9 @@ const { db } = vi.hoisted(() => {
needs_review INTEGER DEFAULT 0, day_plan_position REAL, external_source TEXT, sync_enabled INTEGER,
ingest_state TEXT, created_at DATETIME DEFAULT CURRENT_TIMESTAMP);`);
tmp.exec('CREATE TABLE days (id INTEGER PRIMARY KEY AUTOINCREMENT, trip_id INTEGER NOT NULL, day_number INTEGER, date TEXT);');
+ // The places list reads each place's country and region (#2537).
+ tmp.exec(`CREATE TABLE place_regions (place_id INTEGER PRIMARY KEY, country_code TEXT NOT NULL,
+ region_code TEXT NOT NULL, region_name TEXT NOT NULL);`);
tmp.exec(`CREATE TABLE places (id INTEGER PRIMARY KEY AUTOINCREMENT, trip_id INTEGER NOT NULL, name TEXT,
image_url TEXT, address TEXT, lat REAL, lng REAL, category_id INTEGER, description TEXT,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP);`);
diff --git a/server/tests/e2e/vacay.e2e.test.ts b/server/tests/e2e/vacay.e2e.test.ts
index acddcf551..2f66c0e54 100644
--- a/server/tests/e2e/vacay.e2e.test.ts
+++ b/server/tests/e2e/vacay.e2e.test.ts
@@ -51,7 +51,7 @@ const { db } = vi.hoisted(() => {
UNIQUE(user_id, plan_id, date));`);
tmp.exec(`CREATE TABLE vacay_company_holidays (id INTEGER PRIMARY KEY AUTOINCREMENT,
plan_id INTEGER NOT NULL REFERENCES vacay_plans(id) ON DELETE CASCADE,
- date TEXT NOT NULL, note TEXT DEFAULT '', UNIQUE(plan_id, date));`);
+ date TEXT NOT NULL, note TEXT DEFAULT '', fraction REAL NOT NULL DEFAULT 1, UNIQUE(plan_id, date));`);
tmp.exec(`CREATE TABLE vacay_holiday_calendars (id INTEGER PRIMARY KEY AUTOINCREMENT,
plan_id INTEGER NOT NULL REFERENCES vacay_plans(id) ON DELETE CASCADE,
type TEXT NOT NULL DEFAULT 'public_holiday', region TEXT NOT NULL, label TEXT,
diff --git a/server/tests/unit/mcp/tools-assignments.test.ts b/server/tests/unit/mcp/tools-assignments.test.ts
index 6a40a73e6..9d1faf44d 100644
--- a/server/tests/unit/mcp/tools-assignments.test.ts
+++ b/server/tests/unit/mcp/tools-assignments.test.ts
@@ -298,7 +298,7 @@ describe('Tool: clear_day_assignments (#2470)', () => {
expect(data.success).toBe(true);
expect([...data.removedIds].sort()).toEqual([a1.id, a2.id].sort());
expect(testDb.prepare('SELECT COUNT(*) AS n FROM day_assignments WHERE day_id = ?').get(day.id)).toEqual({ n: 0 });
- expect(broadcastMock).toHaveBeenCalledWith(trip.id, 'assignment:deleted', { assignmentId: a1.id, dayId: day.id });
+ expect(broadcastMock).toHaveBeenCalledWith(trip.id, 'assignment:deleted', expect.objectContaining({ assignmentId: a1.id, dayId: day.id }));
});
});
diff --git a/server/tests/unit/nest/budget.service.calc.test.ts b/server/tests/unit/nest/budget.service.calc.test.ts
index 46d6bdfcb..e58578885 100644
--- a/server/tests/unit/nest/budget.service.calc.test.ts
+++ b/server/tests/unit/nest/budget.service.calc.test.ts
@@ -1223,9 +1223,9 @@ describe('applySettlementUpdate', () => {
const res = budget.applySettlementUpdate(7, 1, { from_user_id: 2, to_user_id: 1, amount: 10.126 });
// from, to, rounded amount, currency-flag(0)/value(null), rate-flag(null)/value(1),
- // settled_at-flag(0)/value(null), id.
- // No currency/exchange_rate/settled_at passed → all three CASE guards keep the existing columns.
- expect(run).toHaveBeenCalledWith(2, 1, 10.13, 0, null, null, 1, 0, null, 7);
+ // settled_at-flag(0)/value(null), note-flag(0)/value(null) (#2340), id.
+ // No currency/exchange_rate/settled_at/note passed → every CASE guard keeps the existing column.
+ expect(run).toHaveBeenCalledWith(2, 1, 10.13, 0, null, null, 1, 0, null, 0, null, 7);
expect(res).toMatchObject({ id: 7, from_user_id: 2, to_user_id: 1, amount: 10.13 });
});
});
diff --git a/server/tests/unit/nest/maps.amap.test.ts b/server/tests/unit/nest/maps.amap.test.ts
index 2a8bee677..05db77096 100644
--- a/server/tests/unit/nest/maps.amap.test.ts
+++ b/server/tests/unit/nest/maps.amap.test.ts
@@ -932,14 +932,15 @@ describe('MapsService: Amap first inside China when picked outright (#1636)', ()
mockProviderGet.mockReturnValue({ value: 'amap' });
keys({ amap: 'akey' });
osmAnswers([]);
- const fetchSpy = vi.fn().mockResolvedValue(ok({ count: '0', pois: [] }));
- vi.stubGlobal('fetch', fetchSpy);
+ vi.stubGlobal('fetch', vi.fn().mockResolvedValue(ok({ count: '0', pois: [] })));
+ const searchText = vi.spyOn(AmapPlacesProvider.prototype, 'searchText');
+ spies.push(searchText);
const result = await svc.searchPlaces(1, 'nothing here', 'zh', BEIJING);
expect(result).toEqual({ places: [], source: 'amap' });
expect(trekPlacesSearch).toHaveBeenCalled();
- expect(fetchSpy).toHaveBeenCalledTimes(1);
+ expect(searchText).toHaveBeenCalledTimes(1);
});
it('AMAP-097: a search centred outside China keeps the usual order, so the Eiffel Tower does not land in Macau', async () => {
diff --git a/server/tests/unit/nest/vacay.service.test.ts b/server/tests/unit/nest/vacay.service.test.ts
index b98b1c78f..cfa9ba5e5 100644
--- a/server/tests/unit/nest/vacay.service.test.ts
+++ b/server/tests/unit/nest/vacay.service.test.ts
@@ -1514,7 +1514,7 @@ describe('getSharedCalendars', () => {
const calendars = svc.getSharedCalendars(viewer.id, '2025');
- expect(calendars[0].companyHolidays).toEqual([{ date: '2025-12-24' }]);
+ expect(calendars[0].companyHolidays).toEqual([{ date: '2025-12-24', fraction: 1 }]);
});
it('VACAY-SVC-065: an owner without any plan yields empty arrays (no lazy creation)', () => {
diff --git a/server/tests/unit/plugins/addon-domains-errors.rpc.test.ts b/server/tests/unit/plugins/addon-domains-errors.rpc.test.ts
index 1290b5ba2..d29441522 100644
--- a/server/tests/unit/plugins/addon-domains-errors.rpc.test.ts
+++ b/server/tests/unit/plugins/addon-domains-errors.rpc.test.ts
@@ -236,7 +236,7 @@ describe('addon-gated domains validate their input', () => {
const toggleCompanyHoliday = vi.fn(() => ({}));
const host = build({ vacay: { toggleCompanyHoliday } });
await host.dispatch(req('vacay.toggleCompanyHoliday', { date: '2027-01-01' }), 42);
- expect(toggleCompanyHoliday).toHaveBeenCalledWith(1, '2027-01-01', undefined, undefined);
+ expect(toggleCompanyHoliday).toHaveBeenCalledWith(1, '2027-01-01', undefined, undefined, undefined);
});
it('ADDONERR-010e a collab message without a replyTo passes null', async () => {
diff --git a/server/tests/unit/plugins/rpc-fallbacks.test.ts b/server/tests/unit/plugins/rpc-fallbacks.test.ts
index 1cece291b..c58cc786d 100644
--- a/server/tests/unit/plugins/rpc-fallbacks.test.ts
+++ b/server/tests/unit/plugins/rpc-fallbacks.test.ts
@@ -285,7 +285,7 @@ describe('every schema-validated method rejects a payload its schema refuses', (
createTestPluginRegistry([new VacayRpc(vacay, guards), new TagsRpc(tags)]),
);
await host.dispatch(req('vacay.toggleCompanyHoliday', { date: '2027-01-01', note: 42 }), 42);
- expect(toggleCompanyHoliday).toHaveBeenCalledWith(1, '2027-01-01', undefined, undefined);
+ expect(toggleCompanyHoliday).toHaveBeenCalledWith(1, '2027-01-01', undefined, undefined, undefined);
await host.dispatch(req('tags.update', { tagId: 1, input: { name: 'kept' } }), 42);
expect(update).toHaveBeenCalledWith(1, 'kept', undefined);
});
diff --git a/wiki/Public-API.md b/wiki/Public-API.md
index 3b160bd10..d92dd1c94 100644
--- a/wiki/Public-API.md
+++ b/wiki/Public-API.md
@@ -242,6 +242,8 @@ A Homepage widget then needs no scripting:
| `404` | no such trip, or not one of yours |
| `429` | rate limit exceeded |
+**Which TREK is running.** To check the running version, for example against the latest GitHub release, call `GET /api/auth/app-config`. It needs no key and no login, and the `version` field holds the running version (such as `4.3.3`). It is not part of `/api/v1`, so it follows the app's own release rather than the promise below.
+
**Versioning.** `/api/v1` may gain fields; it will not lose them or change their types. A breaking change ships as `/api/v2` and both run side by side for a transition period. Write your client to ignore fields it does not know.
## What is not here yet