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 */} -
- TREK -
- -
- {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