fix: resolve a batch of reported bugs (planner, budget, atlas, bookings, mobile)

- #1394 planner: two transports on one day no longer draw a phantom airport→airport
  road route between them (a run is only a drive when it holds a real place); mirrored
  in the map hook and the sidebar's leg list, with a regression test.
- #1392 planner: the per-day Route button now points the selection at the tapped day
  before toggling, so on mobile it computes that day's route instead of the previously
  selected one, and only the selected day's button reads as active.
- #1372 planner: the "open in Google Maps" route now includes the day's hotel bookends,
  matching the drawn map route.
- #1375 planner: a multi-day accommodation no longer thrashes the plan scroll — the
  auto-scroll lock keys on the selection identity, not the per-day row.
- #1377 planner: the reset-orientation compass is now shown on small screens too.
- #1382 budget: settlement nets in the trip's canonical currency and converts to the
  display currency once, so balances no longer drift with live FX and no phantom
  third-party micro-flows appear (identity, hence unchanged, when they're the same).
- #1366 atlas: countries reached only by a transport booking (no lodging/place) now
  count as visited, on both the dashboard and the Atlas page.
- #1383 bookings: a hotel linked to an accommodation shows only its day-range, not a
  duplicate stamped date row, and the range stays correct after an edit.
- #1353 bookings: any non-hotel reservation can now link an existing trip place/activity.
- #1390 i18n: fix the Polish word for "buddies" (Towarzysze → Współpodróżnicy).
- #1265 planner: drag-and-drop of places now works on touch devices via a polyfill.
This commit is contained in:
Maurice
2026-07-01 21:07:27 +02:00
committed by Maurice
parent 60cbd20327
commit a83ecc4ffb
35 changed files with 255 additions and 42 deletions
+1
View File
@@ -32,6 +32,7 @@
"@trek/shared": "*",
"axios": "^1.6.7",
"dexie": "^4.4.2",
"drag-drop-touch": "^1.3.1",
"heic-to": "^1.4.2",
"leaflet": "^1.9.4",
"lucide-react": "^0.344.0",
@@ -201,7 +201,7 @@ function useDayPlanSidebar(props: DayPlanSidebarProps) {
// Remember which assignment we last auto-scrolled into view so we don't
// keep yanking the user back whenever they scroll away while the same
// place stays selected.
const lastAutoScrolledIdRef = useRef<number | null>(null)
const lastAutoScrolledIdRef = useRef<string | number | null>(null)
useEffect(() => {
// Reset the scroll-lock whenever selection moves, so the next selected
// row triggers a fresh scroll-into-view on its ref.
@@ -392,9 +392,15 @@ function useDayPlanSidebar(props: DayPlanSidebarProps) {
const merged = mergedItemsMap[selectedDayId] || []
const runs: { id: number; lat: number; lng: number }[][] = []
let cur: { id: number; lat: number; lng: number }[] = []
// A run is only a real drive when it holds an actual place. Two back-to-back
// transports (e.g. two flights on one day) would otherwise pair the first's
// arrival with the second's departure into a phantom airport→airport leg — the
// flight, not a drive — and surface it as a bogus connector distance (#1394).
let curHasPlace = false
for (const it of merged) {
if (it.type === 'place' && it.data.place?.lat && it.data.place?.lng) {
cur.push({ id: it.data.id, lat: it.data.place.lat, lng: it.data.place.lng })
curHasPlace = true
} else if (it.type === 'transport') {
const r = it.data
const { from, to } = getTransportRouteEndpoints(r, selectedDayId)
@@ -402,8 +408,9 @@ function useDayPlanSidebar(props: DayPlanSidebarProps) {
// Located transport: route to its departure point, break the run (the
// flight/train itself isn't driven), and let its arrival start the next.
if (from) cur.push({ id: r.id, lat: from.lat, lng: from.lng })
if (cur.length >= 2) runs.push(cur)
if (cur.length >= 2 && curHasPlace) runs.push(cur)
cur = []
curHasPlace = false
if (to) cur.push({ id: r.id, lat: to.lat, lng: to.lng })
} else if (cur.length > 0) {
// No location: ignore for routing, but attribute the through-leg to the
@@ -412,7 +419,7 @@ function useDayPlanSidebar(props: DayPlanSidebarProps) {
}
}
}
if (cur.length >= 2) runs.push(cur)
if (cur.length >= 2 && curHasPlace) runs.push(cur)
// Hotel bookend legs: the drive from the day's accommodation to the first located
// waypoint of the day (morning) and from the last one back to it (evening). Only when
@@ -1058,6 +1065,12 @@ function useDayPlanSidebar(props: DayPlanSidebarProps) {
const DayPlanSidebar = React.memo(function DayPlanSidebar(props: DayPlanSidebarProps) {
const S = useDayPlanSidebar(props)
// A stable key for the current selection. A multi-day place renders one row per
// day (same place_id, different assignment ids); selecting it by place_id alone
// (e.g. clicking an accommodation) marks every one of those rows selected, so a
// per-assignment scroll-lock would let each day's row scroll the list in turn.
// Keying the lock on the selection identity makes only the first row scroll (#1375).
const selectionScrollKey = S.selectedAssignmentId != null ? `a${S.selectedAssignmentId}` : S.selectedPlaceId != null ? `p${S.selectedPlaceId}` : null
// Needed by the route-tools visibility gate in the render below (#1330); the hook
// keeps its own copy, so read it reactively here in the component scope too.
const optimizeFromAccommodation = useSettingsStore(s => s.settings.optimize_from_accommodation)
@@ -1609,14 +1622,14 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar(props: DayPlanSidebarP
// the transition "just became selected". Once we've
// scrolled for this assignment id, we won't scroll
// again until selection actually moves somewhere else.
if (el && isPlaceSelected && lastAutoScrolledIdRef.current !== assignment.id) {
if (el && isPlaceSelected && selectionScrollKey != null && lastAutoScrolledIdRef.current !== selectionScrollKey) {
const rect = el.getBoundingClientRect()
const nearTop = rect.top < 80
const nearBottom = rect.bottom > window.innerHeight - 80
if (nearTop || nearBottom) {
el.scrollIntoView({ behavior: 'smooth', block: 'center' })
}
lastAutoScrolledIdRef.current = assignment.id
lastAutoScrolledIdRef.current = selectionScrollKey
}
}}
onDragEnd={() => { setDraggingId(null); setDragOverDayId(null); setDropTargetKey(null); dragDataRef.current = null }}
@@ -2186,8 +2199,15 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar(props: DayPlanSidebarP
<div style={{ padding: '10px 16px 12px', borderTop: '1px solid var(--border-faint)', display: 'flex', flexDirection: 'column', gap: 7 }}>
<div style={{ display: 'flex', gap: 6, alignItems: 'stretch' }}>
<button
onClick={() => onToggleRoute?.()}
className={routeShown ? 'bg-accent text-accent-text' : 'bg-transparent text-content-secondary'}
onClick={() => {
// The route is computed for the globally selected day, so on
// mobile (where an expanded non-selected day also shows this
// footer) tapping Route must first point the selection at this
// day — otherwise it toggles the previously selected day (#1392).
if (isSelected) { onToggleRoute?.() }
else { onSelectDay(day.id, true); if (!routeShown) onToggleRoute?.() }
}}
className={routeShown && isSelected ? 'bg-accent text-accent-text' : 'bg-transparent text-content-secondary'}
style={{
flex: 1, display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 5,
padding: '6px 0', fontSize: 'calc(11px * var(--fs-scale-caption, 1))', fontWeight: 600, borderRadius: 8,
@@ -2201,7 +2221,16 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar(props: DayPlanSidebarP
{/* Open the day's stops as a route in Google Maps (planned order). #1255 */}
<button
onClick={() => {
const url = generateGoogleMapsUrl(getDayAssignments(day.id).map(a => a.place).filter(p => p?.lat != null && p?.lng != null) as { lat: number; lng: number }[])
// Bookend the Google Maps route with the day's accommodation the
// same way the drawn map route does (routeBookends is null when
// "optimize from accommodation" is off), so hotels aren't dropped
// from the exported route (#1372).
const stops = getDayAssignments(day.id).map(a => a.place).filter(p => p?.lat != null && p?.lng != null) as { lat: number; lng: number }[]
const morning = routeBookends?.morning?.place_lat != null && routeBookends?.morning?.place_lng != null
? { lat: routeBookends.morning.place_lat, lng: routeBookends.morning.place_lng } : null
const evening = routeBookends?.evening?.place_lat != null && routeBookends?.evening?.place_lng != null
? { lat: routeBookends.evening.place_lat, lng: routeBookends.evening.place_lng } : null
const url = generateGoogleMapsUrl([...(morning ? [morning] : []), ...stops, ...(evening ? [evening] : [])])
if (url) window.open(url, '_blank', 'noopener,noreferrer')
}}
aria-label={t('planner.openGoogleMaps')}
@@ -87,6 +87,7 @@ export function ReservationModal({ isOpen, onClose, onSave, reservation, days, p
title: '', type: 'other', status: 'pending',
reservation_time: '', reservation_end_time: '', end_date: '', location: '', confirmation_number: '',
notes: '', url: '', assignment_id: '' as string | number, accommodation_id: '' as string | number,
place_id: '' as string | number,
meta_check_in_time: '', meta_check_in_end_time: '', meta_check_out_time: '',
hotel_place_id: '' as string | number, hotel_start_day: '' as string | number, hotel_end_day: '' as string | number,
hotel_address: '',
@@ -139,6 +140,7 @@ export function ReservationModal({ isOpen, onClose, onSave, reservation, days, p
url: reservation.url || '',
assignment_id: reservation.assignment_id || '',
accommodation_id: reservation.accommodation_id || '',
place_id: reservation.place_id || '',
meta_check_in_time: meta.check_in_time || '',
meta_check_in_end_time: meta.check_in_end_time || '',
meta_check_out_time: meta.check_out_time || '',
@@ -168,6 +170,7 @@ export function ReservationModal({ isOpen, onClose, onSave, reservation, days, p
url: (prefill as { url?: string }).url || '',
assignment_id: defaultAssignmentId ?? '',
accommodation_id: '',
place_id: '',
meta_check_in_time: meta.check_in_time || '',
meta_check_in_end_time: meta.check_in_end_time || '',
meta_check_out_time: meta.check_out_time || '',
@@ -182,7 +185,7 @@ export function ReservationModal({ isOpen, onClose, onSave, reservation, days, p
setForm({
title: '', type: 'other', status: 'pending',
reservation_time: '', reservation_end_time: '', end_date: '', location: '', confirmation_number: '',
notes: '', url: '', assignment_id: defaultAssignmentId ?? '', accommodation_id: '',
notes: '', url: '', assignment_id: defaultAssignmentId ?? '', accommodation_id: '', place_id: '',
meta_check_in_time: '', meta_check_in_end_time: '', meta_check_out_time: '',
hotel_place_id: '', hotel_start_day: '', hotel_end_day: '', hotel_address: '',
})
@@ -242,6 +245,9 @@ export function ReservationModal({ isOpen, onClose, onSave, reservation, days, p
url: form.url,
assignment_id: (form.type === 'hotel' && !form.accommodation_id) ? null : (form.assignment_id || null),
accommodation_id: form.type === 'hotel' ? (form.accommodation_id || null) : null,
// 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,
endpoints: [],
needs_review: false,
@@ -466,6 +472,35 @@ export function ReservationModal({ isOpen, onClose, onSave, reservation, days, p
)}
{/* Location */}
{/* Link an existing trip place/activity to any non-hotel booking (#1353). Hotels
keep their own accommodation-based place picker below. */}
{form.type !== 'hotel' && (
<div>
<label className={labelClass}>{t('reservations.meta.linkPlace')}</label>
<CustomSelect
value={form.place_id}
onChange={value => {
const p = places.find(pl => pl.id === value)
setForm(prev => {
const next = { ...prev, place_id: value }
if (value && p) {
if (!prev.title) next.title = p.name
if (!prev.location && p.address) next.location = p.address
}
return next
})
}}
placeholder={t('reservations.meta.pickPlace')}
options={[
{ value: '', label: '—' },
...places.map(p => ({ value: p.id, label: p.name })),
]}
searchable
size="sm"
/>
</div>
)}
{form.type !== 'hotel' && (
<div>
<label className={labelClass}>{t('reservations.locationAddress')}</label>
@@ -112,11 +112,14 @@ function ReservationCard({ r, tripId, onEdit, onDelete, files = [], onNavigateTo
const TRANSPORT_TYPES_SET = new Set(['flight', 'train', 'bus', 'car', 'taxi', 'bicycle', 'cruise', 'ferry', 'transport_other'])
const isTransportType = TRANSPORT_TYPES_SET.has(r.type)
const isHotel = r.type === 'hotel'
const startDay = r.day_id ? days.find(d => d.id === r.day_id)
: (isHotel && r.accommodation_start_day_id) ? days.find(d => d.id === r.accommodation_start_day_id)
// For a hotel linked to an accommodation, the accommodation's own start/end days are
// the source of truth for the stay range: a stale day_id left behind by a range edit
// would otherwise mislabel the card, so prefer the accommodation ids here (#1383).
const startDay = (isHotel && r.accommodation_start_day_id) ? days.find(d => d.id === r.accommodation_start_day_id)
: r.day_id ? days.find(d => d.id === r.day_id)
: undefined
const endDay = r.end_day_id ? days.find(d => d.id === r.end_day_id)
: (isHotel && r.accommodation_end_day_id) ? days.find(d => d.id === r.accommodation_end_day_id)
const endDay = (isHotel && r.accommodation_end_day_id) ? days.find(d => d.id === r.accommodation_end_day_id)
: r.end_day_id ? days.find(d => d.id === r.end_day_id)
: undefined
const DayLabel = ({ day }: { day: typeof startDay }) => {
if (!day) return null
@@ -234,8 +237,10 @@ function ReservationCard({ r, tripId, onEdit, onDelete, files = [], onNavigateTo
</div>
</div>
)}
{/* Date / Time row */}
{(hasDate || hasTime) && (
{/* Date / Time row — hidden for a hotel linked to an accommodation: its stay
already shows as the day-range label above, and a reservation_time stamped
on the auto-created reservation would otherwise duplicate it (#1383). */}
{(hasDate || hasTime) && !(isHotel && (r.accommodation_start_day_id || r.accommodation_end_day_id)) && (
<div style={{ display: 'grid', gap: 10, gridTemplateColumns: hasDate && hasTime ? '1fr 1fr' : '1fr' }}>
{hasDate && (
<div>
+9 -2
View File
@@ -89,19 +89,26 @@ export function useRouteCalculation(tripStore: TripStoreState, selectedDayId: nu
// arrival point starts the next run.
// - A transport WITHOUT a location is ignored entirely — the places around it
// connect directly, as if the booking weren't there.
// A run is only a real drive when it contains at least one actual place. Two
// back-to-back transports (e.g. two flights on one day) would otherwise pair the
// first's arrival point with the second's departure point into a phantom
// [airport → airport] road route — that is the flight itself, not a drive (#1394).
const runs: { lat: number; lng: number }[][] = []
let currentRun: { lat: number; lng: number }[] = []
let runHasPlace = false
for (const entry of entries) {
if (entry.kind === 'place') {
currentRun.push({ lat: entry.lat, lng: entry.lng })
runHasPlace = true
} else if (entry.from || entry.to) {
if (entry.from) currentRun.push(entry.from)
if (currentRun.length >= 2) runs.push(currentRun)
if (currentRun.length >= 2 && runHasPlace) runs.push(currentRun)
currentRun = []
runHasPlace = false
if (entry.to) currentRun.push(entry.to)
}
}
if (currentRun.length >= 2) runs.push(currentRun)
if (currentRun.length >= 2 && runHasPlace) runs.push(currentRun)
// Bookend the route with the day's accommodation: a hotel → first-stop run and
// a last-stop → hotel run, so the drawn line matches the sidebar's hotel legs.
+5
View File
@@ -14,6 +14,11 @@ import '@fontsource/geist-sans/400.css'
import '@fontsource/geist-sans/500.css'
import '@fontsource/geist-sans/600.css'
import './index.css'
// Native HTML5 drag-and-drop never fires on touch input, so the planner's place /
// day reordering was dead on Android and iOS. This polyfill synthesises the standard
// drag events from touch gestures over draggable elements — it only hooks touch, so
// desktop mouse dragging is untouched (#1265).
import 'drag-drop-touch'
import { startConnectivityProbe } from './sync/connectivity'
import { requestPersistentStorage } from './sync/persistentStorage'
+8
View File
@@ -338,6 +338,14 @@ export default function TripPlannerPage(): React.ReactElement | null {
</div>
)}
{/* Mobile: the compass/reset-orientation control lives centre-top on its own
(the desktop cluster above is hidden below md), between the edge Plan/Places tabs. */}
{glMap && (
<div className="flex md:hidden" style={{ position: 'absolute', top: 14, left: '50%', transform: 'translateX(-50%)', zIndex: 25, pointerEvents: 'none' }}>
<MapCompassPill map={glMap} />
</div>
)}
<div className="hidden md:block" style={{ position: 'absolute', left: 10, top: 10, bottom: 10, zIndex: 20 }}>
<button onClick={() => setLeftCollapsed(c => !c)}
style={{
@@ -414,4 +414,30 @@ describe('useRouteCalculation', () => {
[[p1.lat, p1.lng], [p2.lat, p2.lng], [p3.lat, p3.lng]],
]);
});
it('FE-HOOK-ROUTE-018: two flights on one day are not road-routed airport→airport (#1394)', async () => {
// Two single-day flights, no place between them. The arrival of the first and the
// departure of the second must NOT be joined into a phantom driving run — that leg
// is the flight itself, not a drive.
const store = buildMockStore({ '5': [] });
useTripStore.setState({
reservations: [
{ id: 1, type: 'flight', day_id: 5, end_day_id: 5, day_positions: { 5: 0 },
endpoints: [{ role: 'from', lat: 52.5, lng: 13.4 }, { role: 'to', lat: 42.4, lng: 18.7 }] },
{ id: 2, type: 'flight', day_id: 5, end_day_id: 5, day_positions: { 5: 1 },
endpoints: [{ role: 'from', lat: 50.1, lng: 14.3 }, { role: 'to', lat: 42.4, lng: 18.9 }] },
],
days: [{ id: 5, day_number: 1 }],
} as any);
const { result } = renderHook(() =>
useRouteCalculation(store as TripStoreState, 5)
);
await act(async () => {});
// No real place anywhere on the day → nothing is a drive → no route is drawn.
// Before the fix this produced a bogus [flight1.arrival → flight2.departure] leg.
expect(result.current.route).toBeNull();
expect(result.current.routeSegments).toEqual([]);
});
});
+7
View File
@@ -34,6 +34,7 @@
"@trek/shared": "*",
"axios": "^1.6.7",
"dexie": "^4.4.2",
"drag-drop-touch": "^1.3.1",
"heic-to": "^1.4.2",
"leaflet": "^1.9.4",
"lucide-react": "^0.344.0",
@@ -9909,6 +9910,12 @@
"url": "https://dotenvx.com"
}
},
"node_modules/drag-drop-touch": {
"version": "1.3.1",
"resolved": "https://registry.npmjs.org/drag-drop-touch/-/drag-drop-touch-1.3.1.tgz",
"integrity": "sha512-Q0/ZgsnW7VUjn+YqSnp1rvxjjPnZX5YLyVaw28einood+eTMcLzgOgHk8nyqIF9O18J68l+2htlEnbw5GsyTvQ==",
"license": "MIT"
},
"node_modules/dts-resolver": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/dts-resolver/-/dts-resolver-3.0.0.tgz",
+16
View File
@@ -444,6 +444,22 @@ export async function getStats(userId: number) {
}
}
// Merge countries reached only by a transport booking. Those store geocoded from/to
// coordinates in reservation_endpoints but create no place row, so they never show up
// via resolvePlaceCountries above and would otherwise be missed (#1366).
const endpoints = db.prepare(`
SELECT DISTINCT e.lat, e.lng
FROM reservation_endpoints e
JOIN reservations r ON e.reservation_id = r.id
WHERE r.trip_id IN (${tripIds.map(() => '?').join(',')})
`).all(...tripIds) as { lat: number; lng: number }[];
for (const e of endpoints) {
const code = getCountryFromCoords(e.lat, e.lng);
if (code && !countries.find(c => c.code === code)) {
countries.push({ code, placeCount: 0, tripCount: 0, firstVisit: null, lastVisit: null });
}
}
const mostVisited = countries.length > 0 ? countries.reduce((a, b) => a.placeCount > b.placeCount ? a : b) : null;
const continents: Record<string, number> = {};
+18
View File
@@ -17,6 +17,7 @@ import { revokeUserSessions } from '../mcp';
import { startTripReminders } from '../scheduler';
import { deleteUserCompletely } from './userCleanupService';
import { getFlightDistanceKm } from './distanceService';
import { getCountryFromCoords } from './atlasService';
import { verifyJwtAndLoadUser } from '../middleware/auth';
import { User } from '../types';
import { DEMO_EMAIL_PRIMARY, isDemoEmail } from './demo';
@@ -982,6 +983,23 @@ export function getTravelStats(userId: number) {
`).all(userId, userId) as { country_code: string }[];
placeRegionCodes.forEach(r => { if (r.country_code) countryCodes.add(r.country_code.toUpperCase()); });
// Transport bookings don't create a place row, so their geocoded endpoints never
// reached place_regions — a country reached only by a flight/train (no lodging or
// planned place there) was never counted as visited (#1366). Resolve each endpoint
// coordinate to a country and fold it in too.
const endpoints = db.prepare(`
SELECT DISTINCT e.lat, e.lng
FROM reservation_endpoints e
JOIN reservations r ON e.reservation_id = r.id
JOIN trips t ON r.trip_id = t.id
LEFT JOIN trip_members tm ON t.id = tm.trip_id
WHERE (t.user_id = ? OR tm.user_id = ?)
`).all(userId, userId) as { lat: number; lng: number }[];
for (const e of endpoints) {
const code = getCountryFromCoords(e.lat, e.lng);
if (code) countryCodes.add(code.toUpperCase());
}
return {
countries: [...countryCodes],
cities: [...cities],
+34 -22
View File
@@ -374,22 +374,34 @@ export function calculateSettlement(
const base = (opts.base || opts.tripCurrency || 'EUR').toUpperCase();
const tripCurrency = (opts.tripCurrency || base).toUpperCase();
const rates = opts.rates ?? null;
// Amount in some currency → base. Pre-rework rows store currency = NULL, which
// means "the trip's own currency". rates[X] = units of X per 1 base.
const toBase = (amount: number, itemCurrency: string | null | undefined, itemRate?: number | null): number => {
// Net the whole settlement in the trip's canonical currency and convert the final
// totals to the display currency once, instead of netting in the (moving) display
// currency. Otherwise per-expense rounding shifts as live FX drifts and the greedy
// debt-simplifier reshuffles it into phantom third-party micro-flows (#1382). When
// the display currency IS the trip currency (the common case) every conversion below
// is the identity, so behaviour is unchanged.
// rates[X] = units of X per 1 base; the frozen exchange_rate is units of item-currency
// per 1 trip-currency. Pre-rework rows store currency = NULL = "the trip's own currency".
const toTrip = (amount: number, itemCurrency: string | null | undefined, itemRate?: number | null): number => {
const cur = (itemCurrency || tripCurrency).toUpperCase();
if (cur === base) return amount;
// Prefer the FX rate frozen at entry time (#1335): a settled expense keeps the rate it
// was booked at, so a later live-rate drift doesn't re-open it with a few-cent residual.
// The stored rate is units of item-currency per 1 trip-currency, so it only applies when
// converting to the trip's own currency; otherwise (and for legacy rows) use live rates.
if (base === tripCurrency && itemRate != null && itemRate > 0 && itemRate !== 1) {
return amount / itemRate;
}
if (cur === tripCurrency) return amount;
// Prefer the FX rate frozen at entry time (#1335): a settled expense keeps the rate
// it was booked at, so a later live-rate drift doesn't re-open it with a residual.
if (itemRate != null && itemRate > 0 && itemRate !== 1) return amount / itemRate;
// Legacy rows without a frozen rate: convert via base with live rates.
if (!rates) return amount;
const r = rates[cur];
return r && r > 0 ? amount / r : amount;
const rCur = rates[cur];
const rTrip = rates[tripCurrency];
if (rCur && rCur > 0 && rTrip && rTrip > 0) return (amount / rCur) * rTrip;
return amount;
};
// trip-currency → display currency, applied once to the final netted totals.
const toDisplay = (v: number): number =>
base === tripCurrency ? v : (rates && rates[tripCurrency] > 0 ? v / rates[tripCurrency] : v);
// A recorded settle-up amount is entered in whatever display currency the payer was
// viewing (the table has no currency column), so bring it into trip currency to net.
const settleToTrip = (v: number): number =>
base === tripCurrency ? v : (rates && rates[tripCurrency] > 0 ? v * rates[tripCurrency] : v);
const items = db.prepare('SELECT * FROM budget_items WHERE trip_id = ?').all(tripId) as BudgetItem[];
const allMembers = db.prepare(`
@@ -419,17 +431,17 @@ export function calculateSettlement(
const payers = allPayers.filter(p => p.budget_item_id === item.id);
if (members.length === 0) continue; // planning-only entry → doesn't affect balances
// Payers are credited what they actually paid (converted to base with the
// item's stored exchange rate)…
for (const p of payers) ensure(p.user_id, p).balance += toBase(p.amount > 0 ? p.amount : 0, item.currency, item.exchange_rate);
// Payers are credited what they actually paid (converted to trip currency with
// the item's stored exchange rate)…
for (const p of payers) ensure(p.user_id, p).balance += toTrip(p.amount > 0 ? p.amount : 0, item.currency, item.exchange_rate);
// …and each split participant owes their share — a custom per-member amount
// when one is set, otherwise an equal share of the expense total.
const hasCustomSplit = members.some(m => m.amount !== null && m.amount !== undefined);
const equalShares = !hasCustomSplit ? splitEqualShares(item.total_price, members, item.id) : {};
for (const m of members) {
const memberShare = hasCustomSplit && m.amount !== null && m.amount !== undefined
? toBase(m.amount, item.currency, item.exchange_rate)
: toBase(equalShares[m.user_id] || 0, item.currency, item.exchange_rate);
? toTrip(m.amount, item.currency, item.exchange_rate)
: toTrip(equalShares[m.user_id] || 0, item.currency, item.exchange_rate);
ensure(m.user_id, m).balance -= memberShare;
}
}
@@ -445,8 +457,8 @@ export function calculateSettlement(
return balances[id];
};
for (const s of settlements) {
ensureSettled(s.from_user_id, s.from_username, s.from_avatar_url).balance += s.amount;
ensureSettled(s.to_user_id, s.to_username, s.to_avatar_url).balance -= s.amount;
ensureSettled(s.from_user_id, s.from_username, s.from_avatar_url).balance += settleToTrip(s.amount);
ensureSettled(s.to_user_id, s.to_username, s.to_avatar_url).balance -= settleToTrip(s.amount);
}
// Calculate optimized payment flows (greedy algorithm)
@@ -467,7 +479,7 @@ export function calculateSettlement(
flows.push({
from: { user_id: debtors[di].user_id, username: debtors[di].username, avatar_url: debtors[di].avatar_url },
to: { user_id: creditors[ci].user_id, username: creditors[ci].username, avatar_url: creditors[ci].avatar_url },
amount: Math.round(transfer * 100) / 100,
amount: Math.round(toDisplay(transfer) * 100) / 100,
});
}
debtors[di].amount -= transfer;
@@ -477,7 +489,7 @@ export function calculateSettlement(
}
return {
balances: Object.values(balances).map(b => ({ ...b, balance: Math.round(b.balance * 100) / 100 })),
balances: Object.values(balances).map(b => ({ ...b, balance: Math.round(toDisplay(b.balance) * 100) / 100 })),
flows,
settlements,
};
+2
View File
@@ -44,6 +44,8 @@ const reservations: TranslationStrings = {
'reservations.meta.pickAccommodation': 'ربط بالإقامة',
'reservations.meta.noAccommodation': 'لا يوجد',
'reservations.meta.hotelPlace': 'الإقامة',
'reservations.meta.linkPlace': 'مكان / نشاط',
'reservations.meta.pickPlace': 'اختر مكانًا / نشاطًا',
'reservations.meta.pickHotel': 'اختر الإقامة',
'reservations.meta.fromDay': 'من',
'reservations.meta.toDay': 'إلى',
+2
View File
@@ -44,6 +44,8 @@ const reservations: TranslationStrings = {
'reservations.meta.pickAccommodation': 'Vincular à hospedagem',
'reservations.meta.noAccommodation': 'Nenhuma',
'reservations.meta.hotelPlace': 'Hospedagem',
'reservations.meta.linkPlace': 'Local / Atividade',
'reservations.meta.pickPlace': 'Selecionar local / atividade',
'reservations.meta.pickHotel': 'Selecionar hospedagem',
'reservations.meta.fromDay': 'De',
'reservations.meta.toDay': 'Até',
+2
View File
@@ -44,6 +44,8 @@ const reservations: TranslationStrings = {
'reservations.meta.pickAccommodation': 'Propojit s ubytováním',
'reservations.meta.noAccommodation': 'Nic',
'reservations.meta.hotelPlace': 'Ubytování',
'reservations.meta.linkPlace': 'Místo / Aktivita',
'reservations.meta.pickPlace': 'Vybrat místo / aktivitu',
'reservations.meta.pickHotel': 'Vybrat ubytování',
'reservations.meta.fromDay': 'Od dne',
'reservations.meta.toDay': 'Do dne',
+2
View File
@@ -47,6 +47,8 @@ const reservations: TranslationStrings = {
'reservations.meta.pickAccommodation': 'Mit Unterkunft verknüpfen',
'reservations.meta.noAccommodation': 'Keine',
'reservations.meta.hotelPlace': 'Unterkunft',
'reservations.meta.linkPlace': 'Ort / Aktivität',
'reservations.meta.pickPlace': 'Ort / Aktivität wählen',
'reservations.meta.pickHotel': 'Unterkunft auswählen',
'reservations.meta.fromDay': 'Von',
'reservations.meta.toDay': 'Bis',
+2
View File
@@ -46,6 +46,8 @@ const reservations: TranslationStrings = {
'reservations.meta.pickAccommodation': 'Link to accommodation',
'reservations.meta.noAccommodation': 'None',
'reservations.meta.hotelPlace': 'Accommodation',
'reservations.meta.linkPlace': 'Place / Activity',
'reservations.meta.pickPlace': 'Select place / activity',
'reservations.meta.pickHotel': 'Select accommodation',
'reservations.meta.fromDay': 'From',
'reservations.meta.toDay': 'To',
+2
View File
@@ -117,6 +117,8 @@ const reservations: TranslationStrings = {
'reservations.meta.pickAccommodation': 'Vincular con alojamiento',
'reservations.meta.noAccommodation': 'Ninguno',
'reservations.meta.hotelPlace': 'Alojamiento',
'reservations.meta.linkPlace': 'Lugar / Actividad',
'reservations.meta.pickPlace': 'Seleccionar lugar / actividad',
'reservations.meta.pickHotel': 'Seleccionar alojamiento',
'reservations.meta.fromDay': 'Desde',
'reservations.meta.toDay': 'Hasta',
+2
View File
@@ -46,6 +46,8 @@ const reservations: TranslationStrings = {
'reservations.meta.pickAccommodation': 'Lier à un hébergement',
'reservations.meta.noAccommodation': 'Aucun',
'reservations.meta.hotelPlace': 'Hébergement',
'reservations.meta.linkPlace': 'Lieu / Activité',
'reservations.meta.pickPlace': 'Sélectionner un lieu / une activité',
'reservations.meta.pickHotel': 'Sélectionner un hébergement',
'reservations.meta.fromDay': 'Du',
'reservations.meta.toDay': 'Au',
+2
View File
@@ -46,6 +46,8 @@ const reservations: TranslationStrings = {
'reservations.meta.pickAccommodation': 'Σύνδεση με κατάλυμα',
'reservations.meta.noAccommodation': 'Κανένα',
'reservations.meta.hotelPlace': 'Κατάλυμα',
'reservations.meta.linkPlace': 'Τοποθεσία / Δραστηριότητα',
'reservations.meta.pickPlace': 'Επιλογή τοποθεσίας / δραστηριότητας',
'reservations.meta.pickHotel': 'Επιλογή καταλύματος',
'reservations.meta.fromDay': 'Από',
'reservations.meta.toDay': 'Προς',
+2
View File
@@ -46,6 +46,8 @@ const reservations: TranslationStrings = {
'reservations.meta.pickAccommodation': 'Szállás hozzárendelése',
'reservations.meta.noAccommodation': 'Nincs',
'reservations.meta.hotelPlace': 'Szálloda',
'reservations.meta.linkPlace': 'Hely / Tevékenység',
'reservations.meta.pickPlace': 'Hely / tevékenység kiválasztása',
'reservations.meta.pickHotel': 'Szálloda kiválasztása',
'reservations.meta.fromDay': 'Ettől',
'reservations.meta.toDay': 'Eddig',
+2
View File
@@ -45,6 +45,8 @@ const reservations: TranslationStrings = {
'reservations.meta.pickAccommodation': 'Hubungkan ke akomodasi',
'reservations.meta.noAccommodation': 'Tidak ada',
'reservations.meta.hotelPlace': 'Akomodasi',
'reservations.meta.linkPlace': 'Tempat / Aktivitas',
'reservations.meta.pickPlace': 'Pilih tempat / aktivitas',
'reservations.meta.pickHotel': 'Pilih akomodasi',
'reservations.meta.fromDay': 'Dari',
'reservations.meta.toDay': 'Sampai',
+2
View File
@@ -45,6 +45,8 @@ const reservations: TranslationStrings = {
'reservations.meta.pickAccommodation': 'Collega a un alloggio',
'reservations.meta.noAccommodation': 'Nessuno',
'reservations.meta.hotelPlace': 'Alloggio',
'reservations.meta.linkPlace': 'Luogo / Attività',
'reservations.meta.pickPlace': 'Seleziona luogo / attività',
'reservations.meta.pickHotel': 'Seleziona alloggio',
'reservations.meta.fromDay': 'Da',
'reservations.meta.toDay': 'A',
+2
View File
@@ -44,6 +44,8 @@ const reservations: TranslationStrings = {
'reservations.meta.pickAccommodation': '宿泊先にリンク',
'reservations.meta.noAccommodation': 'なし',
'reservations.meta.hotelPlace': '宿泊先',
'reservations.meta.linkPlace': '場所 / アクティビティ',
'reservations.meta.pickPlace': '場所 / アクティビティを選択',
'reservations.meta.pickHotel': '宿泊先を選択',
'reservations.meta.fromDay': '開始',
'reservations.meta.toDay': '終了',
+2
View File
@@ -44,6 +44,8 @@ const reservations: TranslationStrings = {
'reservations.meta.pickAccommodation': '숙박 연결',
'reservations.meta.noAccommodation': '없음',
'reservations.meta.hotelPlace': '숙박',
'reservations.meta.linkPlace': '장소 / 활동',
'reservations.meta.pickPlace': '장소 / 활동 선택',
'reservations.meta.pickHotel': '숙박 선택',
'reservations.meta.fromDay': '부터',
'reservations.meta.toDay': '까지',
+2
View File
@@ -45,6 +45,8 @@ const reservations: TranslationStrings = {
'reservations.meta.pickAccommodation': 'Koppel aan accommodatie',
'reservations.meta.noAccommodation': 'Geen',
'reservations.meta.hotelPlace': 'Accommodatie',
'reservations.meta.linkPlace': 'Plaats / Activiteit',
'reservations.meta.pickPlace': 'Kies plaats / activiteit',
'reservations.meta.pickHotel': 'Selecteer accommodatie',
'reservations.meta.fromDay': 'Van',
'reservations.meta.toDay': 'Tot',
+3 -3
View File
@@ -80,7 +80,7 @@ const dashboard: TranslationStrings = {
'dashboard.useUnsplashPhoto': 'Użyj zdjęcia z Unsplash autorstwa {photographer}',
'dashboard.titleRequired': 'Nazwa podróży jest wymagana',
'dashboard.endDateError': 'Data zakończenia musi być po dacie rozpoczęcia',
'dashboard.members': 'Towarzysze',
'dashboard.members': 'Współpodróżnicy',
'dashboard.copyTrip': 'Kopiuj',
'dashboard.copySuffix': 'kopia',
'dashboard.toast.copied': 'Podróż skopiowana!',
@@ -92,7 +92,7 @@ const dashboard: TranslationStrings = {
'dashboard.mobile.tripProgress': 'Postęp podróży',
'dashboard.mobile.daysLeft': 'Pozostało {count} dni',
'dashboard.mobile.places': 'Miejsca',
'dashboard.mobile.buddies': 'Towarzysze',
'dashboard.mobile.buddies': 'Współpodróżnicy',
'dashboard.mobile.newTrip': 'Nowa podróż',
'dashboard.mobile.currency': 'Waluta',
'dashboard.mobile.timezone': 'Strefa czasowa',
@@ -140,7 +140,7 @@ const dashboard: TranslationStrings = {
'dashboard.atlas.kmUnit': 'km',
'dashboard.atlas.aroundEquator': '≈ {count}× dookoła równika',
'dashboard.card.idea': 'Pomysł',
'dashboard.card.buddyOne': 'Towarzysz',
'dashboard.card.buddyOne': 'Współpodróżnik',
'dashboard.fx.from': 'Z',
'dashboard.fx.to': 'Na',
'dashboard.fx.unavailable': 'Kurs niedostępny',
+2
View File
@@ -42,6 +42,8 @@ const reservations: TranslationStrings = {
'reservations.meta.pickAccommodation': 'Link do zakwaterowania',
'reservations.meta.noAccommodation': 'Brak',
'reservations.meta.hotelPlace': 'Zakwaterowanie',
'reservations.meta.linkPlace': 'Miejsce / Aktywność',
'reservations.meta.pickPlace': 'Wybierz miejsce / aktywność',
'reservations.meta.pickHotel': 'Wybierz zakwaterowanie',
'reservations.meta.fromDay': 'Od',
'reservations.meta.toDay': 'Do',
+2
View File
@@ -44,6 +44,8 @@ const reservations: TranslationStrings = {
'reservations.meta.pickAccommodation': 'Привязать к жилью',
'reservations.meta.noAccommodation': 'Нет',
'reservations.meta.hotelPlace': 'Жильё',
'reservations.meta.linkPlace': 'Место / Активность',
'reservations.meta.pickPlace': 'Выбрать место / активность',
'reservations.meta.pickHotel': 'Выбрать жильё',
'reservations.meta.fromDay': 'С',
'reservations.meta.toDay': 'По',
+2
View File
@@ -44,6 +44,8 @@ const reservations: TranslationStrings = {
'reservations.meta.pickAccommodation': 'Länk till boende',
'reservations.meta.noAccommodation': 'Inget',
'reservations.meta.hotelPlace': 'Boende',
'reservations.meta.linkPlace': 'Plats / Aktivitet',
'reservations.meta.pickPlace': 'Välj plats / aktivitet',
'reservations.meta.pickHotel': 'Välj boende',
'reservations.meta.fromDay': 'Från',
'reservations.meta.toDay': 'Till',
+2
View File
@@ -45,6 +45,8 @@ const reservations: TranslationStrings = {
'reservations.meta.pickAccommodation': 'Konaklama bağlantısı',
'reservations.meta.noAccommodation': 'Hiçbiri',
'reservations.meta.hotelPlace': 'Konaklama',
'reservations.meta.linkPlace': 'Yer / Etkinlik',
'reservations.meta.pickPlace': 'Yer / etkinlik seç',
'reservations.meta.pickHotel': 'Konaklama seçin',
'reservations.meta.fromDay': 'İtibaren',
'reservations.meta.toDay': 'İle',
+2
View File
@@ -44,6 +44,8 @@ const reservations: TranslationStrings = {
'reservations.meta.pickAccommodation': 'Прив’язати до житла',
'reservations.meta.noAccommodation': 'Ні',
'reservations.meta.hotelPlace': 'Житло',
'reservations.meta.linkPlace': 'Місце / Активність',
'reservations.meta.pickPlace': 'Вибрати місце / активність',
'reservations.meta.pickHotel': 'Оберіть житло',
'reservations.meta.fromDay': 'З',
'reservations.meta.toDay': 'По',
+2
View File
@@ -44,6 +44,8 @@ const reservations: TranslationStrings = {
'reservations.meta.pickAccommodation': 'Liên kết chỗ ở',
'reservations.meta.noAccommodation': 'Không có',
'reservations.meta.hotelPlace': 'Chỗ ở',
'reservations.meta.linkPlace': 'Địa điểm / Hoạt động',
'reservations.meta.pickPlace': 'Chọn địa điểm / hoạt động',
'reservations.meta.pickHotel': 'Chọn chỗ ở',
'reservations.meta.fromDay': 'Từ',
'reservations.meta.toDay': 'ĐẾN',
+2
View File
@@ -44,6 +44,8 @@ const reservations: TranslationStrings = {
'reservations.meta.pickAccommodation': '關聯住宿',
'reservations.meta.noAccommodation': '無',
'reservations.meta.hotelPlace': '住宿',
'reservations.meta.linkPlace': '地點 / 活動',
'reservations.meta.pickPlace': '選擇地點 / 活動',
'reservations.meta.pickHotel': '選擇住宿',
'reservations.meta.fromDay': '從',
'reservations.meta.toDay': '到',
+2
View File
@@ -44,6 +44,8 @@ const reservations: TranslationStrings = {
'reservations.meta.pickAccommodation': '关联住宿',
'reservations.meta.noAccommodation': '无',
'reservations.meta.hotelPlace': '住宿',
'reservations.meta.linkPlace': '地点 / 活动',
'reservations.meta.pickPlace': '选择地点 / 活动',
'reservations.meta.pickHotel': '选择住宿',
'reservations.meta.fromDay': '从',
'reservations.meta.toDay': '到',