diff --git a/client/src/App.tsx b/client/src/App.tsx index 1eaeedbcb..d71308729 100644 --- a/client/src/App.tsx +++ b/client/src/App.tsx @@ -19,7 +19,7 @@ import ErrorBoundary from './components/shared/ErrorBoundary' import { lazyWithRetry } from './utils/lazyWithRetry' import { useIsPhone } from './mobile/useIsPhone' import { TranslationProvider, useTranslation } from './i18n' -import { authApi } from './api/client' +import { authApi, isAuthPublicPath } from './api/client' import { tripRepo } from './repo/tripRepo' import { readStartDestination, tripStartPath, DEFAULT_START_PAGE, DEFAULT_START_TRIP_TAB, SETTINGS_WAIT_MS, START_DESTINATION_ROUTE } from './utils/startDestination' import { usePermissionsStore, PermissionLevel } from './store/permissionsStore' @@ -402,11 +402,15 @@ export default function App() { || location.pathname.startsWith('/register') || location.pathname.startsWith('/forgot-password') || location.pathname.startsWith('/reset-password') - // Anonymous visitor routes: no session, so authenticated-only widgets (system - // notices, background tasks, save-to-collection) have nothing to do here and - // would otherwise fire a doomed authenticated request on mount. - const isPublicVisitorPage = isSharedPage || location.pathname.startsWith('/public/journey/') - const hideAuthedWidgets = isAuthPage || isPublicVisitorPage + // No session on these, so authenticated-only widgets (system notices, + // background tasks, save-to-collection) have nothing to do and would only fire + // a doomed authenticated request on mount. + // + // Off isAuthPublicPath rather than a second hand-kept list: the two had already + // drifted, this one naming only /public/journey/ while the response + // interceptor's covers all of /public/. A route that is public to one and not + // to the other is exactly the seam that puts a 401 back. + const hideAuthedWidgets = isAuthPage || isAuthPublicPath(location.pathname) return ( diff --git a/client/src/components/Budget/CostsPanel.test.tsx b/client/src/components/Budget/CostsPanel.test.tsx index aed5a1d41..8483d58c9 100644 --- a/client/src/components/Budget/CostsPanel.test.tsx +++ b/client/src/components/Budget/CostsPanel.test.tsx @@ -295,6 +295,26 @@ describe('CostsPanel — settlements in the ledger', () => { expect(card).toHaveTextContent('120') // 120,00 € (locale separator), i.e. 90 + 30 }) + // #2225 follow-up — the ledger stopped charging payer-less expenses, but this + // tile kept counting them, so it contradicted the balances right beside it. + it('keeps a payer-less expense out of Your share, like the balances do', async () => { + seedStore(useSettingsStore, { settings: { ...useSettingsStore.getState().settings, default_currency: 'EUR' } }) + seedStore(useAuthStore, { user: buildUser({ id: 1, username: 'alice' }), isAuthenticated: true }) + const paid = { ...buildBudgetItem({ trip_id: 1, category: 'food', name: 'Dinner' }), total_price: 60, payers: [{ user_id: 1, amount: 60, username: 'alice' }], members: [{ user_id: 1, username: 'alice', paid: 1 }] } + const noPayer = { ...buildBudgetItem({ trip_id: 1, category: 'transport', name: 'Taxi' }), total_price: 40, payers: [], members: [{ user_id: 1, username: 'alice', paid: 0 }] } + server.use( + http.get('/api/trips/1/budget', () => HttpResponse.json({ items: [paid, noPayer] })), + http.get('/api/trips/1/budget/settlement', () => HttpResponse.json({ balances: [], flows: [], settlements: [] })), + ) + render() + + await screen.findByText('Taxi') + // 60 from the paid expense only — the 40 nobody has paid for is not a share. + const card = screen.getByText('Your share').closest('div[style*="border-radius: 22"]') + expect(card).toHaveTextContent('60') + expect(card).not.toHaveTextContent('100') + }) + it('records a recorded-total expense with nobody to split with (#1286)', async () => { seedStore(useAuthStore, { user: buildUser({ id: 1, username: 'alice' }), isAuthenticated: true }) let posted: Record | null = null diff --git a/client/src/components/Budget/CostsPanel.tsx b/client/src/components/Budget/CostsPanel.tsx index 942460c48..e2f548757 100644 --- a/client/src/components/Budget/CostsPanel.tsx +++ b/client/src/components/Budget/CostsPanel.tsx @@ -123,7 +123,14 @@ export default function CostsPanel({ tripId, tripMembers = [] }: CostsPanelProps // ── derived expense maths (everything converted to the base currency) ──── const baseTotal = (e: BudgetItem) => convert(e.total_price || 0, curOf(e)) const myPaidOf = (e: BudgetItem) => (e.payers || []).filter(p => p.user_id === me).reduce((a, p) => a + convert(p.amount, curOf(e)), 0) + // "Unfinished": a recorded total nobody has paid yet — counts toward the trip + // total but stays out of settlements until who-paid is filled in. A negative + // total (a refund, #2176) is just as unfinished until its recipient is named. + const isUnfinished = (e: BudgetItem) => baseTotal(e) !== 0 && (e.payers || []).filter(p => p.amount !== 0).length === 0 const myShareOf = (e: BudgetItem) => { + // Nobody paid, so nobody owes: the ledger skips these entirely (#2225), and + // counting them here left the tile contradicting the balances right beside it. + if (isUnfinished(e)) return 0 const myMember = (e.members || []).find(m => m.user_id === me) if (!myMember) return 0 if (myMember.amount !== null && myMember.amount !== undefined) { @@ -133,10 +140,6 @@ export default function CostsPanel({ tripId, tripMembers = [] }: CostsPanelProps const myShare = shares[me] || 0 return convert(myShare, curOf(e)) } - // "Unfinished": a recorded total nobody has paid yet — counts toward the trip - // total but stays out of settlements until who-paid is filled in. A negative - // total (a refund, #2176) is just as unfinished until its recipient is named. - const isUnfinished = (e: BudgetItem) => baseTotal(e) !== 0 && (e.payers || []).filter(p => p.amount !== 0).length === 0 const totals = useMemo(() => { const totalSpend = budgetItems.reduce((a, e) => a + baseTotal(e), 0) diff --git a/client/src/components/Planner/ReservationModal.tsx b/client/src/components/Planner/ReservationModal.tsx index a8cf40d50..92034200a 100644 --- a/client/src/components/Planner/ReservationModal.tsx +++ b/client/src/components/Planner/ReservationModal.tsx @@ -254,7 +254,12 @@ export function ReservationModal({ isOpen, onClose, onSave, reservation, days, p // Hotels link a place through the accommodation record; every other type links // the picked trip place/activity directly on the reservation (#1353). place_id: form.type === 'hotel' ? null : (form.place_id || null), - metadata: Object.keys(metadata).length > 0 ? metadata : null, + // An empty object, not null: null clears the column outright, and that + // took the mirrored booking price with it on every edit of a type that + // fills no metadata of its own — restaurant, event, tour, parking, other, + // a hotel without check-in times (#2233). An object still clears what the + // form dropped, and lets the server carry the price across. + metadata, // Omitted on an edit: the server replaces the endpoint set whenever the // key is present, and this form never edits endpoints, so sending an // empty list would drop a transit booking's stations (#2216). diff --git a/client/src/components/Planner/TransportModal.tsx b/client/src/components/Planner/TransportModal.tsx index 8573e8ba5..0263a08a9 100644 --- a/client/src/components/Planner/TransportModal.tsx +++ b/client/src/components/Planner/TransportModal.tsx @@ -543,7 +543,12 @@ export function TransportModal({ isOpen, onClose, onSave, reservation, days, sel location: null, confirmation_number: form.confirmation_number || null, notes: form.notes || null, - metadata: Object.keys(metadata).length > 0 ? metadata : null, + // An empty object, not null: null clears the column outright, and that + // took the mirrored booking price with it on every edit of a type that + // fills no metadata of its own — restaurant, event, tour, parking, other, + // a hotel without check-in times (#2233). An object still clears what the + // form dropped, and lets the server carry the price across. + metadata, endpoints, needs_review: false, } diff --git a/client/src/mobile/screens/trip/sheets/MReservationSheet.tsx b/client/src/mobile/screens/trip/sheets/MReservationSheet.tsx index ca219a584..3661e070f 100644 --- a/client/src/mobile/screens/trip/sheets/MReservationSheet.tsx +++ b/client/src/mobile/screens/trip/sheets/MReservationSheet.tsx @@ -259,7 +259,12 @@ export default function MReservationSheet({ planner, onOpenExpense }: MReservati assignment_id: (isHotel && !form.accommodation_id) ? null : (assignmentId || null), accommodation_id: isHotel ? (form.accommodation_id || null) : null, place_id: isHotel ? null : (form.place_id || null), - metadata: Object.keys(metadata).length > 0 ? metadata : null, + // An empty object, not null: null clears the column outright, and that + // took the mirrored booking price with it on every edit of a type that + // fills no metadata of its own — restaurant, event, tour, parking, other, + // a hotel without check-in times (#2233). An object still clears what the + // form dropped, and lets the server carry the price across. + metadata, // Omitted on an edit for the same reason as the desktop dialog: the // server swaps the whole endpoint set when the key is present, and this // sheet never edits endpoints (#2216). diff --git a/docker-compose.yml b/docker-compose.yml index 4b414c180..cc2c71781 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -49,6 +49,8 @@ services: # - MCP_RATE_LIMIT=300 # Max MCP API requests per user per minute (default: 300) # - MCP_MAX_SESSION_PER_USER=20 # Max concurrent MCP sessions per user (default: 20) # - KITINERARY_EXTRACTOR_PATH= # Optional. Full path to kitinerary-extractor binary. Auto-detected from PATH and /usr/lib/*/libexec/kf6/ when unset. +# - LLM_TIMEOUT_MS=900000 # Optional. Ceiling (ms) for one AI-parsing call before it is abandoned, applied to every provider and to the HTTP client underneath. Generous by default so heavier documents fit; lower it on a cloud provider to fail fast. Must be a whole number of milliseconds between 1 and 2147483647 — anything else stops the server at startup with the reason. (default: 900000) +# - TREK_PLUGINS_IGNORE_TREK_RANGE=true # Optional. Lets plugins whose declared TREK version range predates this build, and plugins that declare none, be installed and activated anyway. Every gate warns instead of refusing; the plugin-API version gate and the Discover compatibility verdict stay hard. Off unless set. (default: off) # - UNSPLASH_ACCESS_KEY= # Optional. Unsplash Access Key for trip-cover / place-image search. Without one, TREK uses Unsplash's unauthenticated endpoint, which some datacenter/VPS IPs are blocked from (#1449). Free key at unsplash.com/developers. Can also be set per-admin in Admin > Settings; the env var overrides that. volumes: - ./data:/app/data diff --git a/shared/src/i18n/ru/settings.ts b/shared/src/i18n/ru/settings.ts index eb56cb168..43c6982c0 100644 --- a/shared/src/i18n/ru/settings.ts +++ b/shared/src/i18n/ru/settings.ts @@ -496,7 +496,7 @@ const settings: TranslationStrings = { 'settings.offline.storage.tripsTitle': 'Поездки', 'settings.offline.storage.tripOn': 'Хранится офлайн', 'settings.offline.storage.tripOff': 'Не хранится', - 'settings.offline.storage.tripFinished': 'Завершёна. Сохраняется, только если вы её включите.', + 'settings.offline.storage.tripFinished': 'Завершена. Сохраняется, только если вы её включите.', 'settings.offline.notice.stored': 'На этом устройстве сохранено поездок: {count}', 'settings.offline.notice.nothing': 'Сохранять нечего. Включите поездки, которые хотите оставить.', 'settings.offline.notice.busy': 'Синхронизация уже идёт. Повторите попытку через мгновение.',