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
+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,
};