From 247433fb2a9aa8491d4eeb2b98cdc4d3c1f1cccc Mon Sep 17 00:00:00 2001 From: Maurice <61554723+mauriceboe@users.noreply.github.com> Date: Fri, 5 Jun 2026 01:38:25 +0200 Subject: [PATCH] =?UTF-8?q?feat(costs):=20rework=20Budget=20into=20Costs?= =?UTF-8?q?=20=E2=80=94=20Splitwise-style,=20multi-currency,=20mobile=20(#?= =?UTF-8?q?1106)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(journey): authorize reads of the journey share link GET /api/journeys/:id/share-link now requires journey access (canAccessJourney), matching the create/delete share-link routes and the get_journey_share_link MCP tool. Returns no link when the caller lacks access to the journey. * feat(costs): rework Budget into Costs — Splitwise-style, multi-currency, mobile Renames the Budget addon to "Costs" (UI only) and reworks it into a Tricount/ Splitwise-style cost tracker: multiple payers per expense, equal split across chosen members, settle-up with persisted history + undo, 12 fixed categories, per-expense currency with live FX conversion to a user-set display currency (Settings -> Display), and locale-correct money formatting. Adds a desktop and a dedicated mobile layout. A migration backfills existing budget items (single payer, split members, currency). Closes #551 (per-expense currency). Also switches the app font to self-hosted Poppins (Geist for secondary subtext), replacing the Google Fonts CDN dependency. * fix(costs): neutral dashboard dark palette + liquid glass, full page width, entry-count badge - Dark mode used a warm oklch palette that read brownish; switch to the neutral zinc tokens used by the dashboard (#121215 bg, #f4f4f5 ink) and add a subtle backdrop-blur glass on cards. - Costs now uses the full available page width on desktop instead of a 1280px cap. - Render the expense count next to the Expenses title as a badge. - Adapt budget/journey unit tests to the new payer-based settlement model and the Costs rename (category default 'other', Costs tab/CostsPanel). * fix(costs): drop the entry-count badge, always show row edit/delete actions Removes the count badge next to the Expenses title and makes the per-row edit/delete actions permanently visible (no longer hover-only) on desktop too. * feat(costs): currency-native money formatting, custom select/date, rename addon to Costs - Format every amount in its own currency convention (symbol position, grouping and decimal separators) regardless of app language, via a currency->locale map (EUR -> '12,00 €', USD -> '$12.00', JPY -> '¥12', ...). Previously Intl used the app locale, so EUR showed the symbol in front under an English UI. - Use TREK's CustomSelect (searchable, with symbols) and CustomDatePicker in the add/edit expense modal instead of the native . - Rename the 'Budget Planner' add-on to 'Costs' in the admin list (display only; id/tables/permissions/MCP stay 'budget') via seed + a migration for existing DBs. * feat(auth): configurable session duration via SESSION_DURATION Adds a SESSION_DURATION env var (ms-style strings: 1h, 7d, 30d, ...) controlling how long a session stays valid before re-login. It drives both the trek_session JWT exp claim and the cookie maxAge from one source, so they never drift. Invalid values warn at startup and fall back to the default (24h — unchanged). The MFA challenge token and MCP OAuth tokens keep their own TTL. Implements the request from discussion #946. Documented in the env-var wiki page, .env.example and docker-compose.yml. --- client/index.html | 1 - client/package.json | 2 + client/src/api/client.ts | 5 +- client/src/components/Budget/CostsPanel.tsx | 814 ++++++++++++++++++ .../src/components/Budget/costsCategories.tsx | 39 + .../components/Collab/CollabChatMessages.tsx | 4 +- .../Collab/CollabNotes.constants.ts | 2 +- client/src/components/Collab/CollabPolls.tsx | 2 +- client/src/components/Files/FileManager.tsx | 2 +- client/src/components/Journey/JourneyMap.tsx | 2 +- .../src/components/Journey/JourneyMapGL.tsx | 4 +- client/src/components/Layout/BottomNav.tsx | 5 + client/src/components/Layout/DemoBanner.tsx | 2 +- client/src/components/Map/MapView.tsx | 4 +- client/src/components/Map/MapViewGL.tsx | 2 +- .../src/components/Map/ReservationOverlay.tsx | 4 +- .../src/components/Map/reservationsMapbox.ts | 4 +- .../components/Packing/usePackingListPanel.ts | 2 +- .../components/Planner/BookingImportModal.tsx | 2 +- .../src/components/Planner/DayDetailPanel.tsx | 2 +- .../src/components/Planner/DayPlanSidebar.tsx | 2 +- .../components/Planner/FileImportModal.tsx | 2 +- .../src/components/Planner/PlaceInspector.tsx | 2 +- .../src/components/Planner/PlacesSidebar.tsx | 2 +- .../components/Planner/ReservationsPanel.tsx | 2 +- .../Settings/DisplaySettingsTab.tsx | 17 + .../src/components/Trips/TripMembersModal.tsx | 2 +- .../src/components/Weather/WeatherWidget.tsx | 2 +- client/src/components/shared/ContextMenu.tsx | 2 +- client/src/components/shared/Toast.tsx | 2 +- client/src/components/shared/Tooltip.tsx | 2 +- client/src/hooks/useExchangeRates.ts | 60 ++ client/src/index.css | 11 +- client/src/main.tsx | 11 + client/src/pages/DashboardPage.tsx | 2 +- client/src/pages/LoginPage.tsx | 2 +- client/src/pages/SharedTripPage.tsx | 2 +- client/src/pages/TripPlannerPage.test.tsx | 14 +- client/src/pages/TripPlannerPage.tsx | 4 +- .../src/pages/tripPlanner/useTripPlanner.ts | 2 +- client/src/styles/dashboard.css | 2 +- client/src/utils/formatters.ts | 52 ++ docker-compose.yml | 1 + package-lock.json | 21 + server/.env.example | 1 + server/src/config.ts | 29 + server/src/db/migrations.ts | 62 ++ server/src/db/seeds.ts | 2 +- server/src/nest/budget/budget.controller.ts | 73 +- server/src/nest/budget/budget.service.ts | 23 +- server/src/nest/journey/journey.controller.ts | 2 +- server/src/nest/journey/journey.service.ts | 8 +- server/src/services/authService.ts | 4 +- server/src/services/budgetService.ts | 224 ++++- server/src/services/cookie.ts | 3 +- server/src/services/exchangeRateService.ts | 68 ++ server/src/services/oidcService.ts | 4 +- server/src/types.ts | 14 + server/tests/helpers/test-db.ts | 5 +- server/tests/integration/admin.test.ts | 3 + server/tests/integration/assignments.test.ts | 3 + server/tests/integration/atlas.test.ts | 3 + server/tests/integration/auth.test.ts | 3 + server/tests/integration/backup.test.ts | 3 + server/tests/integration/bootstrap.test.ts | 3 + server/tests/integration/budget.test.ts | 9 +- server/tests/integration/categories.test.ts | 3 + server/tests/integration/collab.test.ts | 3 + server/tests/integration/dayNotes.test.ts | 3 + server/tests/integration/days.test.ts | 3 + server/tests/integration/files.test.ts | 3 + server/tests/integration/immich.test.ts | 3 + server/tests/integration/journey.test.ts | 3 + server/tests/integration/maps.test.ts | 3 + server/tests/integration/mcp.test.ts | 3 + .../tests/integration/memories-immich.test.ts | 3 + .../integration/memories-synology.test.ts | 3 + .../integration/memories-unified.test.ts | 3 + server/tests/integration/misc.test.ts | 3 + .../tests/integration/notifications.test.ts | 3 + server/tests/integration/oauth.test.ts | 3 + server/tests/integration/oidc.test.ts | 3 + server/tests/integration/packing.test.ts | 3 + server/tests/integration/places.test.ts | 3 + server/tests/integration/profile.test.ts | 3 + server/tests/integration/reservations.test.ts | 3 + server/tests/integration/security.test.ts | 3 + server/tests/integration/settings.test.ts | 3 + server/tests/integration/share.test.ts | 3 + .../tests/integration/systemNotices.test.ts | 3 + server/tests/integration/tags.test.ts | 3 + server/tests/integration/todo.test.ts | 3 + server/tests/integration/trips.test.ts | 3 + server/tests/integration/vacay.test.ts | 3 + server/tests/unit/mcp/tools-budget.test.ts | 2 +- .../tests/unit/services/budgetService.test.ts | 89 +- server/tests/websocket/connection.test.ts | 3 + shared/src/budget/budget.schema.ts | 97 ++- shared/src/i18n/ar/budget.ts | 73 ++ shared/src/i18n/ar/settings.ts | 3 + shared/src/i18n/ar/trip.ts | 2 +- shared/src/i18n/br/budget.ts | 73 ++ shared/src/i18n/br/settings.ts | 3 + shared/src/i18n/br/trip.ts | 2 +- shared/src/i18n/cs/budget.ts | 73 ++ shared/src/i18n/cs/settings.ts | 3 + shared/src/i18n/cs/trip.ts | 2 +- shared/src/i18n/de/budget.ts | 73 ++ shared/src/i18n/de/settings.ts | 3 + shared/src/i18n/de/trip.ts | 2 +- shared/src/i18n/en/budget.ts | 73 ++ shared/src/i18n/en/settings.ts | 3 + shared/src/i18n/en/trip.ts | 2 +- shared/src/i18n/es/budget.ts | 73 ++ shared/src/i18n/es/settings.ts | 3 + shared/src/i18n/es/trip.ts | 2 +- shared/src/i18n/fr/budget.ts | 73 ++ shared/src/i18n/fr/settings.ts | 3 + shared/src/i18n/fr/trip.ts | 2 +- shared/src/i18n/gr/budget.ts | 73 ++ shared/src/i18n/gr/settings.ts | 3 + shared/src/i18n/gr/trip.ts | 2 +- shared/src/i18n/hu/budget.ts | 73 ++ shared/src/i18n/hu/settings.ts | 3 + shared/src/i18n/hu/trip.ts | 2 +- shared/src/i18n/id/budget.ts | 73 ++ shared/src/i18n/id/settings.ts | 3 + shared/src/i18n/id/trip.ts | 2 +- shared/src/i18n/it/budget.ts | 73 ++ shared/src/i18n/it/settings.ts | 3 + shared/src/i18n/it/trip.ts | 2 +- shared/src/i18n/ja/budget.ts | 73 ++ shared/src/i18n/ja/settings.ts | 3 + shared/src/i18n/ja/trip.ts | 2 +- shared/src/i18n/ko/budget.ts | 73 ++ shared/src/i18n/ko/settings.ts | 3 + shared/src/i18n/ko/trip.ts | 2 +- shared/src/i18n/nl/budget.ts | 73 ++ shared/src/i18n/nl/settings.ts | 3 + shared/src/i18n/nl/trip.ts | 2 +- shared/src/i18n/pl/budget.ts | 73 ++ shared/src/i18n/pl/settings.ts | 3 + shared/src/i18n/pl/trip.ts | 2 +- shared/src/i18n/ru/budget.ts | 73 ++ shared/src/i18n/ru/settings.ts | 3 + shared/src/i18n/ru/trip.ts | 2 +- shared/src/i18n/tr/budget.ts | 73 ++ shared/src/i18n/tr/settings.ts | 3 + shared/src/i18n/tr/trip.ts | 2 +- shared/src/i18n/uk/budget.ts | 73 ++ shared/src/i18n/uk/settings.ts | 3 + shared/src/i18n/uk/trip.ts | 2 +- shared/src/i18n/zh-TW/budget.ts | 73 ++ shared/src/i18n/zh-TW/settings.ts | 3 + shared/src/i18n/zh-TW/trip.ts | 2 +- shared/src/i18n/zh/budget.ts | 73 ++ shared/src/i18n/zh/settings.ts | 3 + shared/src/i18n/zh/trip.ts | 2 +- wiki/Environment-Variables.md | 1 + 159 files changed, 3354 insertions(+), 156 deletions(-) create mode 100644 client/src/components/Budget/CostsPanel.tsx create mode 100644 client/src/components/Budget/costsCategories.tsx create mode 100644 client/src/hooks/useExchangeRates.ts create mode 100644 server/src/services/exchangeRateService.ts diff --git a/client/index.html b/client/index.html index 6d769c09..0e50cf50 100644 --- a/client/index.html +++ b/client/index.html @@ -19,7 +19,6 @@ - apiClient.delete(`/trips/${tripId}/budget/${id}`).then(r => r.data), setMembers: (tripId: number | string, id: number, userIds: number[]) => apiClient.put(`/trips/${tripId}/budget/${id}/members`, { user_ids: userIds } satisfies BudgetUpdateMembersRequest).then(r => r.data), togglePaid: (tripId: number | string, id: number, userId: number, paid: boolean) => apiClient.put(`/trips/${tripId}/budget/${id}/members/${userId}/paid`, { paid } satisfies BudgetToggleMemberPaidRequest).then(r => r.data), + setPayers: (tripId: number | string, id: number, payers: { user_id: number; amount: number }[]) => apiClient.put(`/trips/${tripId}/budget/${id}/payers`, { payers }).then(r => r.data), perPersonSummary: (tripId: number | string) => apiClient.get(`/trips/${tripId}/budget/summary/per-person`).then(r => r.data), - settlement: (tripId: number | string) => apiClient.get(`/trips/${tripId}/budget/settlement`).then(r => r.data), + settlement: (tripId: number | string, base?: string) => apiClient.get(`/trips/${tripId}/budget/settlement`, base ? { params: { base } } : undefined).then(r => r.data), + createSettlement: (tripId: number | string, data: { from_user_id: number; to_user_id: number; amount: number }) => apiClient.post(`/trips/${tripId}/budget/settlements`, data).then(r => r.data), + deleteSettlement: (tripId: number | string, settlementId: number) => apiClient.delete(`/trips/${tripId}/budget/settlements/${settlementId}`).then(r => r.data), reorderItems: (tripId: number | string, orderedIds: number[]) => apiClient.put(`/trips/${tripId}/budget/reorder/items`, { orderedIds }).then(r => r.data), reorderCategories: (tripId: number | string, orderedCategories: string[]) => apiClient.put(`/trips/${tripId}/budget/reorder/categories`, { orderedCategories } satisfies BudgetReorderCategoriesRequest).then(r => r.data), } diff --git a/client/src/components/Budget/CostsPanel.tsx b/client/src/components/Budget/CostsPanel.tsx new file mode 100644 index 00000000..8663bedb --- /dev/null +++ b/client/src/components/Budget/CostsPanel.tsx @@ -0,0 +1,814 @@ +import { useState, useEffect, useMemo, useCallback } from 'react' +import { useSearchParams } from 'react-router-dom' +import { ArrowDown, ArrowUp, BarChart3, Plus, Search, ArrowRight, Check, RotateCcw, History, Pencil, Trash2 } from 'lucide-react' +import { useTripStore } from '../../store/tripStore' +import { useAuthStore } from '../../store/authStore' +import { useSettingsStore } from '../../store/settingsStore' +import { useCanDo } from '../../store/permissionsStore' +import { useToast } from '../shared/Toast' +import { useTranslation } from '../../i18n' +import { budgetApi } from '../../api/client' +import { useExchangeRates } from '../../hooks/useExchangeRates' +import { useIsMobile } from '../../hooks/useIsMobile' +import { formatMoney, currencyDecimals, currencyLocale } from '../../utils/formatters' +import Modal from '../shared/Modal' +import CustomSelect from '../shared/CustomSelect' +import { CustomDatePicker } from '../shared/CustomDateTimePicker' +import { SYMBOLS, CURRENCIES, SPLIT_COLORS } from './BudgetPanel.constants' +import { COST_CATEGORY_LIST, catMeta } from './costsCategories' +import type { BudgetItem } from '../../types' +import type { TripMember } from './BudgetPanelMemberChips' + +interface CostsPanelProps { + tripId: number + tripMembers?: TripMember[] +} + +interface Settlement { + id: number + from_user_id: number + to_user_id: number + amount: number + created_at?: string + from_username?: string + to_username?: string +} +interface SettlementData { + balances: { user_id: number; username: string; avatar_url: string | null; balance: number }[] + flows: { from: { user_id: number; username: string }; to: { user_id: number; username: string }; amount: number }[] + settlements: Settlement[] +} + +const round2 = (n: number) => Math.round(n * 100) / 100 +const FIELD_H = 40 // shared height for the amount / currency / day row in the modal + +export default function CostsPanel({ tripId, tripMembers = [] }: CostsPanelProps) { + const { trip, budgetItems, deleteBudgetItem, loadBudgetItems } = useTripStore() + const me = useAuthStore(s => s.user?.id ?? -1) + const can = useCanDo() + const canEdit = can('budget_edit', trip) + const toast = useToast() + const { t, locale } = useTranslation() + const isMobile = useIsMobile() + + // Display/base currency = the user's preferred currency (Settings), falling back + // to the trip's own currency. Everything in Costs is converted to and shown in it. + const displayCurrency = useSettingsStore(s => s.settings.default_currency) + const base = (displayCurrency || trip?.currency || 'EUR').toUpperCase() + // Pre-rework rows stored currency = NULL, meaning "the trip's own currency". + const tripCurrency = (trip?.currency || base).toUpperCase() + const { convert } = useExchangeRates(base) + const curOf = useCallback((e: BudgetItem) => (e.currency || tripCurrency), [tripCurrency]) + const [settlement, setSettlement] = useState(null) + const [filter, setFilter] = useState<'all' | 'mine' | 'owed'>('all') + const [search, setSearch] = useState('') + const [histOpen, setHistOpen] = useState(false) + const [modalOpen, setModalOpen] = useState(false) + const [editing, setEditing] = useState(null) + + const people = tripMembers + const personById = useCallback((id: number) => people.find(p => p.id === id), [people]) + const personName = useCallback((id: number) => id === me ? t('costs.you') : (personById(id)?.username || '?'), [me, personById, t]) + const colorFor = useCallback((id: number) => { + const idx = people.findIndex(p => p.id === id) + return SPLIT_COLORS[(idx >= 0 ? idx : 0) % SPLIT_COLORS.length].gradient + }, [people]) + const initial = useCallback((id: number) => id === me ? t('costs.youShort') : (personById(id)?.username || '?').charAt(0).toUpperCase(), [me, personById, t]) + + const fmt = useCallback((v: number, c = base) => formatMoney(v, c, locale), [base, locale]) + const fmt0 = useCallback((v: number, c = base) => formatMoney(v, c, locale, { decimals: 0 }), [base, locale]) + + const loadSettlement = useCallback(() => { + budgetApi.settlement(tripId, base).then(setSettlement).catch(() => {}) + }, [tripId, base]) + + useEffect(() => { loadBudgetItems(tripId); loadSettlement() }, [tripId]) + useEffect(() => { loadSettlement() }, [budgetItems.length, base]) + + // The bottom-nav "+" on the Costs tab opens the add-expense modal via ?create=expense. + const [searchParams, setSearchParams] = useSearchParams() + useEffect(() => { + if (searchParams.get('create') === 'expense') { + setEditing(null); setModalOpen(true) + setSearchParams(p => { p.delete('create'); return p }, { replace: true }) + } + }, [searchParams]) + + // ── 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) + const myShareOf = (e: BudgetItem) => { + const n = (e.members || []).length + if (!n || !(e.members || []).some(m => m.user_id === me)) return 0 + return baseTotal(e) / n + } + + const totals = useMemo(() => { + const totalSpend = budgetItems.reduce((a, e) => a + baseTotal(e), 0) + const myPaid = budgetItems.reduce((a, e) => a + myPaidOf(e), 0) + const myShare = budgetItems.reduce((a, e) => a + myShareOf(e), 0) + const owe = (settlement?.flows || []).filter(f => f.from.user_id === me).reduce((a, f) => a + f.amount, 0) + const owed = (settlement?.flows || []).filter(f => f.to.user_id === me).reduce((a, f) => a + f.amount, 0) + return { totalSpend, myPaid, myShare, owe, owed } + }, [budgetItems, settlement, me]) + + // ── filtering + day grouping ──────────────────────────────────────────── + const filtered = useMemo(() => { + let list = budgetItems.slice() + if (filter === 'mine') list = list.filter(e => myPaidOf(e) > 0) + if (filter === 'owed') list = list.filter(e => round2(myPaidOf(e) - myShareOf(e)) > 0) + const q = search.trim().toLowerCase() + if (q) list = list.filter(e => e.name.toLowerCase().includes(q)) + return list + }, [budgetItems, filter, search, me]) + + const dayGroups = useMemo(() => { + const groups: { day: string; items: BudgetItem[] }[] = [] + const labelOf = (e: BudgetItem) => { + if (!e.expense_date) return t('costs.noDate') + try { return new Date(e.expense_date + 'T00:00:00Z').toLocaleDateString(locale, { weekday: 'short', day: 'numeric', month: 'short', timeZone: 'UTC' }) } catch { return e.expense_date } + } + const sorted = filtered.slice().sort((a, b) => (b.expense_date || '').localeCompare(a.expense_date || '')) + for (const e of sorted) { + const day = labelOf(e) + let g = groups.find(x => x.day === day) + if (!g) { g = { day, items: [] }; groups.push(g) } + g.items.push(e) + } + return groups + }, [filtered, locale, t]) + + // ── settle actions ────────────────────────────────────────────────────── + const settleFlow = async (fromId: number, toId: number, amount: number) => { + try { + await budgetApi.createSettlement(tripId, { from_user_id: fromId, to_user_id: toId, amount }) + loadSettlement() + } catch { toast.error(t('common.unknownError')) } + } + const undoSettlement = async (id: number) => { + try { await budgetApi.deleteSettlement(tripId, id); loadSettlement() } catch { toast.error(t('common.unknownError')) } + } + const settleAll = async () => { + const flows = settlement?.flows || [] + if (!flows.length) return + try { + for (const f of flows) await budgetApi.createSettlement(tripId, { from_user_id: f.from.user_id, to_user_id: f.to.user_id, amount: f.amount }) + loadSettlement() + } catch { toast.error(t('common.unknownError')) } + } + + const dateMeta = useMemo(() => { + if (!trip?.start_date || !trip?.end_date) return null + try { + const s = new Date(trip.start_date + 'T00:00:00Z'), e = new Date(trip.end_date + 'T00:00:00Z') + const days = Math.round((e.getTime() - s.getTime()) / 86400000) + 1 + const opt = { day: 'numeric', month: 'short', timeZone: 'UTC' } as const + return { range: `${s.toLocaleDateString(locale, opt)} – ${e.toLocaleDateString(locale, opt)}`, days } + } catch { return null } + }, [trip?.start_date, trip?.end_date, locale]) + + const handleDelete = async (id: number) => { + try { await deleteBudgetItem(tripId, id); loadSettlement() } catch { toast.error(t('common.unknownError')) } + } + + // ── small presentational helpers ──────────────────────────────────────── + const Avatar = ({ id, size = 24 }: { id: number; size?: number }) => { + const url = personById(id)?.avatar_url + if (url) return + return {initial(id)} + } + + const cardCls = 'bg-surface-card border border-edge' + const labelCls = 'text-[11px] font-semibold uppercase tracking-[0.12em] text-content-faint' + + // Big money number with the design's muted symbol/decimals, locale-correct via Intl. + const bigMoney = (amount: number, smallSize: number, mutedColor: string) => { + let parts: Intl.NumberFormatPart[] | null = null + try { + const d = currencyDecimals(base) + parts = new Intl.NumberFormat(currencyLocale(base), { style: 'currency', currency: base, minimumFractionDigits: d, maximumFractionDigits: d }).formatToParts(amount || 0) + } catch { return <>{formatMoney(amount, base, locale)} } + const isBig = (p: Intl.NumberFormatPart) => p.type === 'integer' || p.type === 'group' || p.type === 'minusSign' + return <>{parts.map((p, i) => {p.value})} + } + + return ( +
+ {isMobile ? : ( +
+ {/* ── Header bar ── */} +
+
+ {dateMeta && ( + + {dateMeta.range} · {t('costs.daysCount', { count: dateMeta.days })} + + )} + + + {people.slice(0, 4).map((p, i) => { + const common = { width: 22, height: 22, borderRadius: '50%', border: '2px solid var(--bg-card)', marginLeft: i ? -8 : 0, flexShrink: 0 } as const + return p.avatar_url + ? + : {(p.id === me ? t('costs.youShort') : p.username.charAt(0)).toUpperCase()} + })} + + {t('costs.travelers', { count: people.length })} + +
+ {canEdit && ( +
+ + +
+ )} +
+ + {/* ── Summary cards ── */} +
+ } tone="owe" + foot={totals.owe > 0.01 + ? f.from.user_id === me).map(f => f.to.user_id)} lead={t('costs.to')} Avatar={Avatar} name={personName} /> + : {t('costs.allSettled')}} /> + } tone="owed" + foot={totals.owed > 0.01 + ? f.to.user_id === me).map(f => f.from.user_id)} lead={t('costs.from')} Avatar={Avatar} name={personName} /> + : {t('costs.nothingOwed')}} /> + } tone="total" + foot={{t('costs.yourShare')} · {fmt0(totals.myShare)}{t('costs.youPaid')} · {fmt0(totals.myPaid)}} /> +
+ + {/* ── Main grid ── */} +
+ {/* expenses */} +
+
+

+ {t('costs.expenses')} +

+
+
+ + setSearch(e.target.value)} placeholder={t('costs.searchPlaceholder')} + className="text-content" style={{ border: 0, background: 'none', outline: 'none', fontSize: 13, width: 150, fontFamily: 'inherit' }} /> +
+
+ {(['all', 'mine', 'owed'] as const).map(f => ( + + ))} +
+
+
+ + {dayGroups.length === 0 ? ( +
+ {search ? t('costs.noMatch') : t('costs.emptyText')} +
+ ) : dayGroups.map(g => { + const dtot = g.items.reduce((a, e) => a + baseTotal(e), 0) + return ( +
+
+ {g.day}{t('costs.spent', { amount: fmt(dtot) })} +
+
+ {g.items.map(e => )} +
+
+ ) + })} +
+ + {/* sidebar */} +
+ {/* settle up */} +
+
+
{t('costs.settleUp')} · {(settlement?.flows || []).length}
+ +
+ +
+ + {/* balances */} +
+
{t('costs.balances')}
+ +
+ + {/* by category */} +
+
{t('costs.byCategory')}
+ +
+
+
+
)} + + {modalOpen && ( + setModalOpen(false)} + onSaved={() => { setModalOpen(false); loadBudgetItems(tripId); loadSettlement() }} /> + )} + + setHistOpen(false)} title={t('costs.settleHistory')} size="md"> + + + + +
+ ) + + // ── shared settle-flow list ────────────────────────────────────────────── + function SettleFlows() { + const flows = settlement?.flows || [] + if (flows.length === 0) return ( +
+
+
{t('costs.everyoneSquare')}
+
{t('costs.nothingOutstanding')}
+
+ ) + return ( +
+ {flows.map((f, i) => ( +
+
+ +
+
+ {fmt(f.amount)} + {canEdit && } +
+
+ ))} +
+ ) + } + + // ── mobile layout (Budget1Mobile.html): single flat column, total card on top ── + function MobileBody() { + return ( +
+ {/* Total card */} +
+
{t('costs.totalSpend')}
+
{bigMoney(totals.totalSpend, 24, 'rgba(255,255,255,0.6)')}
+
+ {t('costs.yourShare')} · {fmt0(totals.myShare)} + {t('costs.youPaid')} · {fmt0(totals.myPaid)} +
+ {canEdit && ( + + )} +
+ + {/* Owe / Owed */} +
+
+
+
{t('costs.youOwe')}
+
{t('costs.youOweSub')}
+
{bigMoney(totals.owe, 16, 'var(--c-ink3)')}
+
+
+
+
{t('costs.youreOwed')}
+
{t('costs.youreOwedSub')}
+
{bigMoney(totals.owed, 16, 'var(--c-ink3)')}
+
+
+ + {/* Settle up */} +
+
+
{t('costs.settleUp')} {(settlement?.flows || []).length}
+ +
+ +
+ + {/* Expenses */} +
+
{t('costs.expenses')}
+
+ + setSearch(e.target.value)} placeholder={t('costs.searchPlaceholder')} className="text-content" style={{ border: 0, background: 'none', outline: 'none', fontSize: 14, width: '100%', fontFamily: 'inherit' }} /> +
+
+ {(['all', 'mine', 'owed'] as const).map(f => ( + + ))} +
+ {dayGroups.length === 0 + ?
{search ? t('costs.noMatch') : t('costs.emptyText')}
+ : dayGroups.map(g => { + const dtot = g.items.reduce((a, e) => a + baseTotal(e), 0) + return ( +
+
{g.day}{t('costs.spent', { amount: fmt(dtot) })}
+
{g.items.map(e => )}
+
+ ) + })} +
+ + {/* Balances */} +
+
{t('costs.balances')}
+ +
+ + {/* By category */} +
+
{t('costs.byCategory')}
+ +
+
+ ) + } + + // ── inline subcomponents (close over helpers) ──────────────────────────── + function ExpenseRow({ e }: { e: BudgetItem }) { + const c = catMeta(e.category) + const Icon = c.Icon + const cur = curOf(e) + const payers = (e.payers || []).filter(p => p.amount > 0) + const net = round2(myPaidOf(e) - myShareOf(e)) + return ( +
+ +
+
{e.name}
+ {payers.length > 0 && ( +
+ {payers.map(p => ( + + + {fmt(convert(p.amount, cur))} + + ))} +
+ )} + {!isMobile && ( +
+ {t(c.labelKey)}{cur !== base ? ` · ${fmt(e.total_price, cur)} → ${fmt(baseTotal(e))}` : ''} +
+ )} +
+
+
+
{fmt(baseTotal(e))}
+ {(e.members || []).length > 0 && Math.abs(net) > 0.01 && ( +
0 ? '#16a34a' : '#dc2626' }}> + {net > 0 ? t('costs.youLent', { amount: fmt(net) }) : t('costs.youBorrowed', { amount: fmt(-net) })} +
+ )} +
+ {canEdit && ( +
+ + +
+ )} +
+
+ ) + } + + function BalancesList({ balances }: { balances: SettlementData['balances'] }) { + const rows = people.map(p => balances.find(b => b.user_id === p.id) || { user_id: p.id, username: p.username, avatar_url: null, balance: 0 }) + const max = Math.max(1, ...rows.map(r => Math.abs(r.balance))) + return ( +
+ {rows.map(r => { + const pct = Math.min(100, Math.abs(r.balance) / max * 100) + const pos = r.balance > 0.01, neg = r.balance < -0.01 + return ( +
+ +
+
{personName(r.user_id)}
+
+ + {pos && } + {neg && } +
+
+
+ {pos ? '+' + fmt(r.balance) : neg ? '−' + fmt(-r.balance) : fmt(0)} +
+
+ ) + })} +
+ ) + } + + function CategoryBreakdown() { + const tot: Record = {} + let grand = 0 + for (const e of budgetItems) { const k = catMeta(e.category).key; tot[k] = (tot[k] || 0) + baseTotal(e); grand += baseTotal(e) } + const rows = COST_CATEGORY_LIST.filter(c => (tot[c.key] || 0) > 0).sort((a, b) => (tot[b.key] || 0) - (tot[a.key] || 0)) + if (rows.length === 0) return
{t('costs.noCategories')}
+ return ( +
+ {rows.map(c => { + const v = tot[c.key]; const pct = grand ? v / grand * 100 : 0 + return ( +
+ + {t(c.labelKey)} + {fmt0(v)} +
+ +
+
+ ) + })} +
+ ) + } +} + +// ── pure subcomponents ───────────────────────────────────────────────────── +function SummaryCard({ label, sub, amount, currency, locale, icon, foot, tone }: { label: string; sub: string; amount: number; currency: string; locale: string; icon: React.ReactNode; foot: React.ReactNode; tone: 'owe' | 'owed' | 'total' }) { + const total = tone === 'total' + const accent = tone === 'owe' ? '#dc2626' : tone === 'owed' ? '#16a34a' : undefined + const muted = total ? 'rgba(255,255,255,0.55)' : 'var(--text-faint)' + // formatToParts keeps the design's "big integer + muted symbol/decimals" styling + // while letting Intl place the symbol and pick separators per locale + currency. + let parts: Intl.NumberFormatPart[] | null = null + try { + const d = currencyDecimals(currency) + parts = new Intl.NumberFormat(currencyLocale(currency), { style: 'currency', currency: (currency || 'EUR').toUpperCase(), minimumFractionDigits: d, maximumFractionDigits: d }).formatToParts(amount || 0) + } catch { parts = null } + const big = (p: Intl.NumberFormatPart) => p.type === 'integer' || p.type === 'group' || p.type === 'minusSign' + return ( +
+
+ {icon} +
+
{label}
+
{sub}
+
+
+
+ {parts + ? parts.map((p, i) => {p.value}) + : {formatMoney(amount, currency, locale)}} +
+
{foot}
+
+ ) +} + +function FlowPills({ ids, lead, Avatar, name }: { ids: number[]; lead: string; Avatar: (p: { id: number; size?: number }) => React.JSX.Element; name: (id: number) => string }) { + const uniq = Array.from(new Set(ids)) + return ( + + {lead} + {uniq.map(id => ( + + {name(id)} + + ))} + + ) +} + +function SettleHistory({ settlements, fmt, Avatar, name, onUndo, canEdit }: { + settlements: Settlement[]; fmt: (v: number) => string; Avatar: (p: { id: number; size?: number }) => React.JSX.Element; name: (id: number) => string; onUndo: (id: number) => void; canEdit: boolean +}) { + const { t } = useTranslation() + if (settlements.length === 0) return
{t('costs.noSettlements')}
+ const total = settlements.reduce((a, s) => a + s.amount, 0) + return ( +
+
+ {t('costs.paymentsSettled', { count: settlements.length })}{fmt(total)} +
+
+ {settlements.map(s => ( +
+
+ +
+
+ {fmt(s.amount)} + {canEdit && } +
+
+ ))} +
+
+ ) +} + +// ── Add / edit expense modal ─────────────────────────────────────────────── +function ExpenseModal({ tripId, base, people, me, editing, onClose, onSaved }: { + tripId: number; base: string; people: TripMember[]; me: number; editing: BudgetItem | null; onClose: () => void; onSaved: () => void +}) { + const { t, locale } = useTranslation() + const toast = useToast() + const { addBudgetItem, updateBudgetItem } = useTripStore() + const { convert } = useExchangeRates(base) + const sym = (c: string) => SYMBOLS[c] || (c + ' ') + + const [name, setName] = useState(editing?.name || '') + const [cat, setCat] = useState(editing ? catMeta(editing.category).key : 'food') + const [currency, setCurrency] = useState((editing?.currency || base).toUpperCase()) + const [day, setDay] = useState(editing?.expense_date || new Date().toISOString().slice(0, 10)) + const [payers, setPayers] = useState>(() => { + const m: Record = {} + for (const p of editing?.payers || []) m[p.user_id] = String(p.amount) + return m + }) + const [split, setSplit] = useState>(() => + editing ? new Set((editing.members || []).map(m => m.user_id)) : new Set(people.map(p => p.id))) + const [saving, setSaving] = useState(false) + + const payersTotal = Object.values(payers).reduce((a, v) => a + (parseFloat(v) || 0), 0) + const each = split.size > 0 ? payersTotal / split.size : 0 + const valid = name.trim().length > 0 && split.size > 0 && payersTotal > 0 + + const save = async () => { + if (!valid) return + setSaving(true) + const payerList = Object.entries(payers).map(([uid, v]) => ({ user_id: Number(uid), amount: parseFloat(v) || 0 })).filter(p => p.amount > 0) + const data = { + name: name.trim(), category: cat, + // Store the actual currency the amounts were entered in; conversion to the + // viewer's display currency happens live (real rates), no manual rate. + currency, + payers: payerList, member_ids: [...split], + expense_date: day || null, + } + try { + if (editing) await updateBudgetItem(tripId, editing.id, data) + else await addBudgetItem(tripId, data) + onSaved() + } catch { toast.error(t('common.unknownError')) } finally { setSaving(false) } + } + + const inputCls = 'w-full bg-surface-input border border-edge text-content' + const labelCls = 'block text-[11px] font-semibold uppercase tracking-[0.08em] text-content-faint mb-[6px]' + + return ( + + + + + }> +
+
+ + setName(e.target.value)} placeholder={t('costs.namePlaceholder')} className={inputCls} style={{ borderRadius: 10, padding: '11px 13px', fontSize: 14, outline: 'none' }} /> +
+ +
+ +
+ {sym(currency)} + {payersTotal.toFixed(2)} +
+
+
+
+ + setCurrency(String(v))} searchable + options={CURRENCIES.map(c => ({ value: c, label: SYMBOLS[c] ? `${c} ${SYMBOLS[c]}` : c }))} + style={{ width: '100%' }} /> +
+
+ + +
+
+ + {currency !== base && payersTotal > 0 && ( +
+ {formatMoney(payersTotal, currency, locale)} + + {formatMoney(convert(payersTotal, currency), base, locale)} + · {t('costs.liveRate')} +
+ )} + +
+ +
+ {COST_CATEGORY_LIST.map(c => { + const Icon = c.Icon; const on = cat === c.key + return ( + + ) + })} +
+
+ +
+ +
+ {people.map(p => ( +
+ {p.id === me ? t('costs.you') : p.username} +
+ {sym(currency)} + setPayers(prev => ({ ...prev, [p.id]: e.target.value }))} + className="text-content" style={{ width: '100%', border: 0, background: 'none', outline: 'none', fontSize: 14, fontWeight: 600, padding: '8px 0', textAlign: 'right' }} /> +
+
+ ))} +
+
+ +
+ +
+ {people.map(p => { + const on = split.has(p.id) + return ( + + ) + })} +
+
+ {split.size === 0 ? t('costs.pickSomeone') : t('costs.splitSummary', { count: split.size, amount: sym(currency) + each.toFixed(2) })} +
+
+
+
+ ) +} diff --git a/client/src/components/Budget/costsCategories.tsx b/client/src/components/Budget/costsCategories.tsx new file mode 100644 index 00000000..1a6a619f --- /dev/null +++ b/client/src/components/Budget/costsCategories.tsx @@ -0,0 +1,39 @@ +import { Hotel, Utensils, ShoppingCart, Bus, Plane, Ticket, Camera, ShoppingBag, FileText, HeartPulse, Coins, MoreHorizontal } from 'lucide-react' +import type { LucideIcon } from 'lucide-react' +import { COST_CATEGORIES, type CostCategory } from '@trek/shared' + +/** + * The fixed Costs categories. Users can't add their own — every expense maps to + * one of these. Category colour is the one place an accent is allowed (it + * visualises the category); everything else stays black/white. The label comes + * from i18n (`costs.cat.*`). + */ +export interface CostCategoryMeta { + key: CostCategory + labelKey: string + Icon: LucideIcon + color: string +} + +export const COST_CAT_META: Record = { + accommodation: { key: 'accommodation', labelKey: 'costs.cat.accommodation', Icon: Hotel, color: '#16a34a' }, + food: { key: 'food', labelKey: 'costs.cat.food', Icon: Utensils, color: '#ea580c' }, + groceries: { key: 'groceries', labelKey: 'costs.cat.groceries', Icon: ShoppingCart, color: '#65a30d' }, + transport: { key: 'transport', labelKey: 'costs.cat.transport', Icon: Bus, color: '#2563eb' }, + flights: { key: 'flights', labelKey: 'costs.cat.flights', Icon: Plane, color: '#0ea5e9' }, + activities: { key: 'activities', labelKey: 'costs.cat.activities', Icon: Ticket, color: '#9333ea' }, + sightseeing: { key: 'sightseeing', labelKey: 'costs.cat.sightseeing', Icon: Camera, color: '#db2777' }, + shopping: { key: 'shopping', labelKey: 'costs.cat.shopping', Icon: ShoppingBag, color: '#e11d48' }, + fees: { key: 'fees', labelKey: 'costs.cat.fees', Icon: FileText, color: '#475569' }, + health: { key: 'health', labelKey: 'costs.cat.health', Icon: HeartPulse, color: '#dc2626' }, + tips: { key: 'tips', labelKey: 'costs.cat.tips', Icon: Coins, color: '#d97706' }, + other: { key: 'other', labelKey: 'costs.cat.other', Icon: MoreHorizontal, color: '#6b7280' }, +} + +export const COST_CATEGORY_LIST: CostCategoryMeta[] = COST_CATEGORIES.map(k => COST_CAT_META[k]) + +/** Map any stored category (incl. legacy free-text values) to a known meta. */ +export function catMeta(cat: string | null | undefined): CostCategoryMeta { + if (cat && cat in COST_CAT_META) return COST_CAT_META[cat as CostCategory] + return COST_CAT_META.other +} diff --git a/client/src/components/Collab/CollabChatMessages.tsx b/client/src/components/Collab/CollabChatMessages.tsx index 75bec19d..37a4661d 100644 --- a/client/src/components/Collab/CollabChatMessages.tsx +++ b/client/src/components/Collab/CollabChatMessages.tsx @@ -12,10 +12,10 @@ export function ChatMessages(props: any) { <> {/* Messages */} {messages.length === 0 ? ( -
+
{t('collab.chat.empty')} - {t('collab.chat.emptyDesc') || ''} + {t('collab.chat.emptyDesc') || ''}
) : (
+
{/* Lightbox */} {lightboxIndex !== null && setLightboxIndex(null)} />} diff --git a/client/src/components/Journey/JourneyMap.tsx b/client/src/components/Journey/JourneyMap.tsx index f11dd8ea..26cd2829 100644 --- a/client/src/components/Journey/JourneyMap.tsx +++ b/client/src/components/Journey/JourneyMap.tsx @@ -77,7 +77,7 @@ function markerSvg(dayColor: string, dayLabel: number, highlighted: boolean): st - ${label} + ${label}
` } diff --git a/client/src/components/Journey/JourneyMapGL.tsx b/client/src/components/Journey/JourneyMapGL.tsx index 7f2dd6a9..81b8e304 100644 --- a/client/src/components/Journey/JourneyMapGL.tsx +++ b/client/src/components/Journey/JourneyMapGL.tsx @@ -104,7 +104,7 @@ function ensureJourneyPopupStyle() { -webkit-backdrop-filter: blur(16px) saturate(180%); border: 1px solid rgba(0, 0, 0, 0.06); box-shadow: 0 10px 32px rgba(0, 0, 0, 0.18), 0 2px 6px rgba(0, 0, 0, 0.06); - font-family: -apple-system, system-ui, sans-serif; + font-family:var(--font-system); min-width: 160px; max-width: 280px; } @@ -185,7 +185,7 @@ function markerHtml(dayColor: string, dayLabel: number, highlighted: boolean): H inner.innerHTML = ` - ${label} + ${label} ` wrap.appendChild(inner) return wrap diff --git a/client/src/components/Layout/BottomNav.tsx b/client/src/components/Layout/BottomNav.tsx index 5f763712..04b74c6a 100644 --- a/client/src/components/Layout/BottomNav.tsx +++ b/client/src/components/Layout/BottomNav.tsx @@ -25,6 +25,11 @@ function useCreateAction(): { label: string; run: () => void } { const onJourneyList = useMatch('/journey') if (inTrip) { + // On the Costs tab the "+" adds an expense; otherwise it adds a place. + const tripTab = typeof sessionStorage !== 'undefined' ? sessionStorage.getItem(`trip-tab-${inTrip.params.id}`) : null + if (tripTab === 'finanzplan') { + return { label: t('costs.addExpense'), run: () => navigate(`/trips/${inTrip.params.id}?create=expense`) } + } return { label: t('places.addPlace'), run: () => navigate(`/trips/${inTrip.params.id}?create=place`) } } if (inJourney) { diff --git a/client/src/components/Layout/DemoBanner.tsx b/client/src/components/Layout/DemoBanner.tsx index c4068017..1f5f4df0 100644 --- a/client/src/components/Layout/DemoBanner.tsx +++ b/client/src/components/Layout/DemoBanner.tsx @@ -273,7 +273,7 @@ export default function DemoBanner(): React.ReactElement | null { paddingBottom: 'max(16px, calc(env(safe-area-inset-bottom) + 80px))', paddingLeft: 16, paddingRight: 16, overflow: 'auto', - fontFamily: "-apple-system, BlinkMacSystemFont, 'SF Pro Text', system-ui, sans-serif", + fontFamily: "var(--font-system)", }} onClick={() => setDismissed(true)}>
1 ? 7.5 : 9}px;font-weight:800;color:#111827; - font-family:-apple-system,system-ui,sans-serif;line-height:1; + font-family:var(--font-system);line-height:1; box-sizing:border-box;white-space:nowrap; ">${label}` } @@ -592,7 +592,7 @@ export const MapView = memo(function MapView({ borderRadius: 8, boxShadow: '0 2px 10px rgba(0,0,0,0.15)', padding: '6px 10px', - fontFamily: "-apple-system, BlinkMacSystemFont, 'SF Pro Text', system-ui, sans-serif", + fontFamily: "var(--font-system)", maxWidth: 220, whiteSpace: 'nowrap', }}> diff --git a/client/src/components/Map/MapViewGL.tsx b/client/src/components/Map/MapViewGL.tsx index e86f75ee..9e6f4745 100644 --- a/client/src/components/Map/MapViewGL.tsx +++ b/client/src/components/Map/MapViewGL.tsx @@ -79,7 +79,7 @@ function createMarkerElement(place: Place & { category_color?: string; category_ box-shadow:0 1px 4px rgba(0,0,0,0.18); display:flex;align-items:center;justify-content:center; font-size:${orderNumbers.length > 1 ? 7.5 : 9}px;font-weight:800;color:#111827; - font-family:-apple-system,system-ui,sans-serif;line-height:1; + font-family:var(--font-system);line-height:1; box-sizing:border-box;white-space:nowrap; ">${label}` } diff --git a/client/src/components/Map/ReservationOverlay.tsx b/client/src/components/Map/ReservationOverlay.tsx index 6f47d4db..cbdf57d9 100644 --- a/client/src/components/Map/ReservationOverlay.tsx +++ b/client/src/components/Map/ReservationOverlay.tsx @@ -51,7 +51,7 @@ function endpointIcon(type: TransportType, label: string | null): L.DivIcon { padding:0 8px;border-radius:999px; background:${color};box-shadow:0 2px 6px rgba(0,0,0,0.25); border:1.5px solid #fff;color:#fff; - font-family:-apple-system,system-ui,sans-serif;font-size:11px;font-weight:600;letter-spacing:0.3px;line-height:1; + font-family:var(--font-system);font-size:11px;font-weight:600;letter-spacing:0.3px;line-height:1; box-sizing:border-box;height:22px;white-space:nowrap; ">${svg}${labelHtml ? `${label}` : ''}
`, iconSize: [estWidth, 22], @@ -181,7 +181,7 @@ function buildStatsHtml(color: string, mainLabel: string | null, subLabel: strin background:rgba(17,24,39,0.92);color:#fff; box-shadow:0 2px 6px rgba(0,0,0,0.25); border:1px solid ${color}aa; - font-family:-apple-system,system-ui,'SF Pro Text',sans-serif; + font-family:var(--font-system); white-space:nowrap;box-sizing:border-box; transform-origin:center; will-change:transform; diff --git a/client/src/components/Map/reservationsMapbox.ts b/client/src/components/Map/reservationsMapbox.ts index cf644b2f..29395b32 100644 --- a/client/src/components/Map/reservationsMapbox.ts +++ b/client/src/components/Map/reservationsMapbox.ts @@ -167,7 +167,7 @@ function endpointMarkerHtml(type: TransportType, label: string | null): string { padding:0 8px;border-radius:999px; background:${TRANSPORT_COLOR};box-shadow:0 2px 6px rgba(0,0,0,0.25); border:1.5px solid #fff;color:#fff; - font-family:-apple-system,system-ui,sans-serif;font-size:11px;font-weight:600;letter-spacing:0.3px;line-height:1; + font-family:var(--font-system);font-size:11px;font-weight:600;letter-spacing:0.3px;line-height:1; box-sizing:border-box;height:22px;white-space:nowrap;cursor:pointer; ">${svg}${labelHtml}
` } @@ -188,7 +188,7 @@ function buildStatsHtml(mainLabel: string | null, subLabel: string | null): { ht background:rgba(17,24,39,0.92);color:#fff; box-shadow:0 2px 6px rgba(0,0,0,0.25); border:1px solid ${TRANSPORT_COLOR}aa; - font-family:-apple-system,system-ui,'SF Pro Text',sans-serif; + font-family:var(--font-system); white-space:nowrap;box-sizing:border-box;pointer-events:none; transform-origin:center;will-change:transform; ">${main}${sub}
` diff --git a/client/src/components/Packing/usePackingListPanel.ts b/client/src/components/Packing/usePackingListPanel.ts index c90bd526..63d07b52 100644 --- a/client/src/components/Packing/usePackingListPanel.ts +++ b/client/src/components/Packing/usePackingListPanel.ts @@ -294,7 +294,7 @@ export function usePackingList({ tripId, items, openImportSignal = 0, clearCheck reader.readAsText(file) } - const font = { fontFamily: "-apple-system, BlinkMacSystemFont, 'SF Pro Text', system-ui, sans-serif" } + const font = { fontFamily: "var(--font-system)" } return { tripId, items, inlineHeader, t, canEdit, font, diff --git a/client/src/components/Planner/BookingImportModal.tsx b/client/src/components/Planner/BookingImportModal.tsx index c7e02631..042c52de 100644 --- a/client/src/components/Planner/BookingImportModal.tsx +++ b/client/src/components/Planner/BookingImportModal.tsx @@ -194,7 +194,7 @@ export default function BookingImportModal({ isOpen, onClose, tripId, pushUndo }
e.stopPropagation()} className="bg-surface-card" - style={{ borderRadius: 16, width: '100%', maxWidth: 540, padding: 24, boxShadow: '0 8px 32px rgba(0,0,0,0.2)', fontFamily: "-apple-system, BlinkMacSystemFont, 'SF Pro Text', system-ui, sans-serif", maxHeight: '90vh', display: 'flex', flexDirection: 'column' }} + style={{ borderRadius: 16, width: '100%', maxWidth: 540, padding: 24, boxShadow: '0 8px 32px rgba(0,0,0,0.2)', fontFamily: "var(--font-system)", maxHeight: '90vh', display: 'flex', flexDirection: 'column' }} > {/* Header */}
diff --git a/client/src/components/Planner/DayDetailPanel.tsx b/client/src/components/Planner/DayDetailPanel.tsx index 9b4d2fac..c7dad60d 100644 --- a/client/src/components/Planner/DayDetailPanel.tsx +++ b/client/src/components/Planner/DayDetailPanel.tsx @@ -94,7 +94,7 @@ export default function DayDetailPanel({ day, days, places, categories = [], tri ) : null const placesWithCoords = places.filter(p => p.lat && p.lng) - const font = { fontFamily: "-apple-system, BlinkMacSystemFont, 'SF Pro Text', system-ui, sans-serif" } + const font = { fontFamily: "var(--font-system)" } return (
diff --git a/client/src/components/Planner/DayPlanSidebar.tsx b/client/src/components/Planner/DayPlanSidebar.tsx index 1cec1d12..710299d5 100644 --- a/client/src/components/Planner/DayPlanSidebar.tsx +++ b/client/src/components/Planner/DayPlanSidebar.tsx @@ -1068,7 +1068,7 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar(props: DayPlanSidebarP anyGeoPlace, } = S return ( -
+
{/* Toolbar */} e.stopPropagation()} className="bg-surface-card" - style={{ borderRadius: 16, width: '100%', maxWidth: 520, padding: 24, boxShadow: '0 8px 32px rgba(0,0,0,0.2)', fontFamily: "-apple-system, BlinkMacSystemFont, 'SF Pro Text', system-ui, sans-serif" }} + style={{ borderRadius: 16, width: '100%', maxWidth: 520, padding: 24, boxShadow: '0 8px 32px rgba(0,0,0,0.2)', fontFamily: "var(--font-system)" }} >
{t('places.importFile')} diff --git a/client/src/components/Planner/PlaceInspector.tsx b/client/src/components/Planner/PlaceInspector.tsx index b69817b4..ab4f6ddb 100644 --- a/client/src/components/Planner/PlaceInspector.tsx +++ b/client/src/components/Planner/PlaceInspector.tsx @@ -197,7 +197,7 @@ export default function PlaceInspector({ transform: 'translateX(-50%)', width: `min(800px, calc(100% - ${leftWidth}px - ${rightWidth}px - 32px))`, zIndex: 50, - fontFamily: "-apple-system, BlinkMacSystemFont, 'SF Pro Text', system-ui, sans-serif", + fontFamily: "var(--font-system)", }} >
{sidebarDragOver && } {/* Kopfbereich */} diff --git a/client/src/components/Planner/ReservationsPanel.tsx b/client/src/components/Planner/ReservationsPanel.tsx index 1d428d6f..f450b914 100644 --- a/client/src/components/Planner/ReservationsPanel.tsx +++ b/client/src/components/Planner/ReservationsPanel.tsx @@ -519,7 +519,7 @@ export default function ReservationsPanel({ tripId, reservations, days, assignme }, [reservations]) return ( -
+
{/* Unified toolbar */}
+ {/* Display currency */} +
+ + { + try { await updateSetting('default_currency', String(v)) } + catch (e: unknown) { toast.error(e instanceof Error ? e.message : t('common.error')) } + }} + options={CURRENCIES.map(c => ({ value: c, label: `${c} — ${SYMBOLS[c] || c}` }))} + searchable + /> +

{t('settings.currencyHint')}

+
+ {/* Color Mode */}
diff --git a/client/src/components/Trips/TripMembersModal.tsx b/client/src/components/Trips/TripMembersModal.tsx index 6382c37f..1773b5b6 100644 --- a/client/src/components/Trips/TripMembersModal.tsx +++ b/client/src/components/Trips/TripMembersModal.tsx @@ -259,7 +259,7 @@ export default function TripMembersModal({ isOpen, onClose, tripId, tripTitle }: return ( -
+
{/* Left column: Members */} diff --git a/client/src/components/Weather/WeatherWidget.tsx b/client/src/components/Weather/WeatherWidget.tsx index a873e5c2..6bbe7e99 100644 --- a/client/src/components/Weather/WeatherWidget.tsx +++ b/client/src/components/Weather/WeatherWidget.tsx @@ -94,7 +94,7 @@ export default function WeatherWidget({ lat, lng, date, compact = false, stacked if (!lat || !lng) return null - const fontStyle = { fontFamily: "-apple-system, BlinkMacSystemFont, 'SF Pro Text', system-ui, sans-serif" } + const fontStyle = { fontFamily: "var(--font-system)" } if (loading) { return ( diff --git a/client/src/components/shared/ContextMenu.tsx b/client/src/components/shared/ContextMenu.tsx index 71e79850..d40d2f06 100644 --- a/client/src/components/shared/ContextMenu.tsx +++ b/client/src/components/shared/ContextMenu.tsx @@ -72,7 +72,7 @@ export function ContextMenu({ menu, onClose }: ContextMenuProps) { boxShadow: '0 8px 30px rgba(0,0,0,0.15)', backdropFilter: 'blur(20px)', WebkitBackdropFilter: 'blur(20px)', minWidth: 160, - fontFamily: "-apple-system, BlinkMacSystemFont, 'SF Pro Text', system-ui, sans-serif", + fontFamily: "var(--font-system)", transformOrigin: 'top left', }}> {menu.items.filter(Boolean).map((item, i) => { diff --git a/client/src/components/shared/Toast.tsx b/client/src/components/shared/Toast.tsx index 28d47e28..6bb5d3c3 100644 --- a/client/src/components/shared/Toast.tsx +++ b/client/src/components/shared/Toast.tsx @@ -123,7 +123,7 @@ export function ToastContainer() { {toast.message} diff --git a/client/src/components/shared/Tooltip.tsx b/client/src/components/shared/Tooltip.tsx index 9e9829a5..aa704c01 100644 --- a/client/src/components/shared/Tooltip.tsx +++ b/client/src/components/shared/Tooltip.tsx @@ -92,7 +92,7 @@ export function Tooltip({ label, placement = 'bottom', delay = 250, disabled, ch borderRadius: 8, whiteSpace: 'nowrap', boxShadow: '0 4px 12px rgba(0,0,0,0.15)', - fontFamily: "-apple-system, BlinkMacSystemFont, 'SF Pro Text', system-ui, sans-serif", + fontFamily: "var(--font-system)", transformOrigin: placement === 'top' ? 'bottom center' : placement === 'bottom' ? 'top center' : placement === 'left' ? 'center right' : 'center left', }} > diff --git a/client/src/hooks/useExchangeRates.ts b/client/src/hooks/useExchangeRates.ts new file mode 100644 index 00000000..6470b549 --- /dev/null +++ b/client/src/hooks/useExchangeRates.ts @@ -0,0 +1,60 @@ +import { useCallback, useEffect, useState } from 'react' + +/** + * Live FX rates for the Costs panel, used to convert every amount into the user's + * display currency. Fetches exchangerate-api.com (no key, already CSP-allowlisted + * for the dashboard widget) for the given base and caches per base in memory + + * localStorage for a few hours. rates[X] = units of X per 1 base, so an amount in + * currency C converts to base as `amount / rates[C]`. + */ + +const TTL_MS = 6 * 60 * 60 * 1000 // 6h +const mem = new Map; ts: number }>() + +function readCache(base: string): { rates: Record; ts: number } | null { + const m = mem.get(base) + if (m) return m + try { + const raw = localStorage.getItem('trek_fx_' + base) + if (raw) { + const parsed = JSON.parse(raw) as { rates: Record; ts: number } + if (parsed?.rates) { mem.set(base, parsed); return parsed } + } + } catch { /* ignore */ } + return null +} + +export function useExchangeRates(base: string) { + const upper = (base || 'EUR').toUpperCase() + const [rates, setRates] = useState | null>(() => readCache(upper)?.rates ?? null) + + useEffect(() => { + const cached = readCache(upper) + if (cached) setRates(cached.rates) + if (cached && Date.now() - cached.ts < TTL_MS) return + let cancelled = false + fetch(`https://api.exchangerate-api.com/v4/latest/${encodeURIComponent(upper)}`) + .then(r => r.json()) + .then((d: { rates?: Record }) => { + if (cancelled || !d?.rates) return + const entry = { rates: d.rates, ts: Date.now() } + mem.set(upper, entry) + try { localStorage.setItem('trek_fx_' + upper, JSON.stringify(entry)) } catch { /* ignore */ } + setRates(d.rates) + }) + .catch(() => { /* offline → keep cached/identity */ }) + return () => { cancelled = true } + }, [upper]) + + const convert = useCallback( + (amount: number, from: string | null | undefined): number => { + const f = (from || upper).toUpperCase() + if (f === upper || !rates) return amount + const r = rates[f] + return r && r > 0 ? amount / r : amount + }, + [rates, upper], + ) + + return { rates, convert } +} diff --git a/client/src/index.css b/client/src/index.css index 9eb16666..58a1931c 100644 --- a/client/src/index.css +++ b/client/src/index.css @@ -431,7 +431,9 @@ input[type="number"], input[type="time"], input[type="date"], input[type="dateti --safe-top: env(safe-area-inset-top, 0px); --nav-h: 0px; --bottom-nav-h: 0px; - --font-system: -apple-system, BlinkMacSystemFont, 'SF Pro Text', 'Segoe UI', system-ui, sans-serif; + --font-system: 'Poppins', -apple-system, BlinkMacSystemFont, 'SF Pro Text', 'Segoe UI', system-ui, sans-serif; + /* Secondary "subtext"/caption tier renders in Geist; primary text + headings stay Poppins. */ + --font-subtext: 'Geist Sans', 'Poppins', -apple-system, BlinkMacSystemFont, 'Segoe UI', system-ui, sans-serif; --sp-1: 4px; --sp-2: 8px; --sp-3: 12px; @@ -539,6 +541,11 @@ body { transition: background-color 0.2s, color 0.2s; } +/* Subtext tier in Geist. The faint text token is TREK's caption/secondary tier; + a direct rule on the element beats the Poppins inherited from wrapper styles, + giving the design's "Geist text · Poppins numbers" hierarchy. */ +.text-content-faint { font-family: var(--font-subtext); } + /* ── Marker cluster custom styling ────────────── */ .marker-cluster-wrapper { background: transparent !important; @@ -563,7 +570,7 @@ body { } .marker-cluster-custom span { - font-family: -apple-system, BlinkMacSystemFont, 'SF Pro Text', system-ui, sans-serif; + font-family:var(--font-system); font-size: 12px; font-weight: 700; color: #ffffff; diff --git a/client/src/main.tsx b/client/src/main.tsx index db9116c6..e98ef77b 100644 --- a/client/src/main.tsx +++ b/client/src/main.tsx @@ -2,6 +2,17 @@ import React from 'react' import ReactDOM from 'react-dom/client' import { BrowserRouter } from 'react-router-dom' import App from './App' +// Self-hosted Poppins (bundled, same-origin) so the app font can't be blocked by +// ad/tracker blockers the way the Google Fonts CDN can. +import '@fontsource/poppins/300.css' +import '@fontsource/poppins/400.css' +import '@fontsource/poppins/500.css' +import '@fontsource/poppins/600.css' +import '@fontsource/poppins/700.css' +// Geist Sans (self-hosted too) — used only for secondary "subtext" via --font-subtext. +import '@fontsource/geist-sans/400.css' +import '@fontsource/geist-sans/500.css' +import '@fontsource/geist-sans/600.css' import './index.css' import { startConnectivityProbe } from './sync/connectivity' diff --git a/client/src/pages/DashboardPage.tsx b/client/src/pages/DashboardPage.tsx index e21acc65..022facb3 100644 --- a/client/src/pages/DashboardPage.tsx +++ b/client/src/pages/DashboardPage.tsx @@ -90,7 +90,7 @@ export default function DashboardPage(): React.ReactElement { return ( <> {/* Navbar lives outside .trek-dash so it keeps the app-wide font + button - styling instead of inheriting the dashboard scope's Geist font and the + styling instead of inheriting the dashboard scope's font and the `.trek-dash button` reset (which shifted the bell icon + menu items). */}
diff --git a/client/src/pages/LoginPage.tsx b/client/src/pages/LoginPage.tsx index 57cae4cc..a80312dc 100644 --- a/client/src/pages/LoginPage.tsx +++ b/client/src/pages/LoginPage.tsx @@ -176,7 +176,7 @@ export default function LoginPage(): React.ReactElement { } return ( -
+
{/* Language dropdown */}
diff --git a/client/src/pages/SharedTripPage.tsx b/client/src/pages/SharedTripPage.tsx index 2f0fec32..49771820 100644 --- a/client/src/pages/SharedTripPage.tsx +++ b/client/src/pages/SharedTripPage.tsx @@ -71,7 +71,7 @@ export default function SharedTripPage() { const center = mapPlaces.length > 0 ? [mapPlaces[0].lat, mapPlaces[0].lng] : [48.85, 2.35] return ( -
+
{/* Header */}
{/* Cover image background */} diff --git a/client/src/pages/TripPlannerPage.test.tsx b/client/src/pages/TripPlannerPage.test.tsx index ba332f5b..9512c3ec 100644 --- a/client/src/pages/TripPlannerPage.test.tsx +++ b/client/src/pages/TripPlannerPage.test.tsx @@ -97,8 +97,8 @@ vi.mock('../components/Files/FileManager', () => ({ }, })); -vi.mock('../components/Budget/BudgetPanel', () => ({ - default: () => React.createElement('div', { 'data-testid': 'budget-panel' }), +vi.mock('../components/Budget/CostsPanel', () => ({ + default: () => React.createElement('div', { 'data-testid': 'costs-panel' }), })); vi.mock('../components/Packing/PackingListPanel', () => ({ @@ -436,8 +436,8 @@ describe('TripPlannerPage', () => { }); }); - describe('FE-PAGE-PLANNER-012: Budget tab renders BudgetPanel', () => { - it('shows BudgetPanel after clicking the Budget tab with budget addon enabled', async () => { + describe('FE-PAGE-PLANNER-012: Costs tab renders CostsPanel', () => { + it('shows CostsPanel after clicking the Costs tab with budget addon enabled', async () => { server.use( http.get('/api/addons', () => HttpResponse.json({ addons: [{ id: 'budget', type: 'budget' }] }) @@ -454,11 +454,11 @@ describe('TripPlannerPage', () => { vi.useRealTimers(); - const budgetTab = await screen.findByTitle('Budget'); - fireEvent.click(budgetTab); + const costsTab = await screen.findByTitle('Costs'); + fireEvent.click(costsTab); await waitFor(() => { - expect(screen.getByTestId('budget-panel')).toBeInTheDocument(); + expect(screen.getByTestId('costs-panel')).toBeInTheDocument(); }); }); }); diff --git a/client/src/pages/TripPlannerPage.tsx b/client/src/pages/TripPlannerPage.tsx index af7637f5..dc2e7d31 100644 --- a/client/src/pages/TripPlannerPage.tsx +++ b/client/src/pages/TripPlannerPage.tsx @@ -23,7 +23,7 @@ import PackingListPanel from '../components/Packing/PackingListPanel' import ApplyTemplateButton from '../components/Packing/ApplyTemplateButton' import TodoListPanel from '../components/Todo/TodoListPanel' import FileManager from '../components/Files/FileManager' -import BudgetPanel from '../components/Budget/BudgetPanel' +import CostsPanel from '../components/Budget/CostsPanel' import CollabPanel from '../components/Collab/CollabPanel' import Navbar from '../components/Layout/Navbar' import { useToast } from '../components/shared/Toast' @@ -647,7 +647,7 @@ export default function TripPlannerPage(): React.ReactElement | null { {activeTab === 'finanzplan' && (
- +
)} diff --git a/client/src/pages/tripPlanner/useTripPlanner.ts b/client/src/pages/tripPlanner/useTripPlanner.ts index be26c284..945adc62 100644 --- a/client/src/pages/tripPlanner/useTripPlanner.ts +++ b/client/src/pages/tripPlanner/useTripPlanner.ts @@ -604,7 +604,7 @@ export function useTripPlanner() { const defaultCenter = [settings.default_lat || 48.8566, settings.default_lng || 2.3522] const defaultZoom = settings.default_zoom || 10 - const fontStyle = { fontFamily: "-apple-system, BlinkMacSystemFont, 'SF Pro Text', 'Segoe UI', system-ui, sans-serif" } + const fontStyle = { fontFamily: "var(--font-system)" } // Splash screen — show for initial load + a brief moment for photos to start loading const [splashDone, setSplashDone] = useState(false) diff --git a/client/src/styles/dashboard.css b/client/src/styles/dashboard.css index ba8e7ceb..589d9cbc 100644 --- a/client/src/styles/dashboard.css +++ b/client/src/styles/dashboard.css @@ -38,7 +38,7 @@ background: var(--bg); color: var(--ink); - font-family: "Geist", -apple-system, BlinkMacSystemFont, system-ui, sans-serif; + font-family: "Poppins", -apple-system, BlinkMacSystemFont, system-ui, sans-serif; font-feature-settings: "ss01", "cv11"; letter-spacing: -0.005em; min-height: 100%; diff --git a/client/src/utils/formatters.ts b/client/src/utils/formatters.ts index c656f319..94bfe81c 100644 --- a/client/src/utils/formatters.ts +++ b/client/src/utils/formatters.ts @@ -39,6 +39,58 @@ export function currencyDecimals(currency: string): number { return ZERO_DECIMAL_CURRENCIES.has(currency.toUpperCase()) ? 0 : 2 } +// Each currency formats in its own home convention (symbol position, grouping and +// decimal separators) regardless of the app language — so EUR is always "1.234,56 €" +// and USD always "$1,234.56". Intl derives all of that from the locale, so we map +// each supported currency to a representative locale (Latin-digit variants for the +// Arabic/Bengali ones to avoid non-Latin numerals). +const CURRENCY_LOCALE: Record = { + EUR: 'de-DE', USD: 'en-US', GBP: 'en-GB', JPY: 'ja-JP', CHF: 'de-CH', + CZK: 'cs-CZ', PLN: 'pl-PL', SEK: 'sv-SE', NOK: 'nb-NO', DKK: 'da-DK', + TRY: 'tr-TR', THB: 'th-TH', AUD: 'en-AU', CAD: 'en-CA', NZD: 'en-NZ', + BRL: 'pt-BR', MXN: 'es-MX', INR: 'en-IN', IDR: 'id-ID', MYR: 'ms-MY', + PHP: 'en-PH', SGD: 'en-SG', KRW: 'ko-KR', CNY: 'zh-CN', HKD: 'en-HK', + TWD: 'zh-TW', ZAR: 'en-ZA', AED: 'en-AE', SAR: 'en-SA', ILS: 'he-IL', + EGP: 'en-EG', MAD: 'fr-MA', HUF: 'hu-HU', RON: 'ro-RO', BGN: 'bg-BG', + HRK: 'hr-HR', ISK: 'is-IS', RUB: 'ru-RU', UAH: 'uk-UA', BDT: 'en-BD', + LKR: 'en-LK', VND: 'vi-VN', CLP: 'es-CL', COP: 'es-CO', PEN: 'es-PE', + ARS: 'es-AR', +} + +export function currencyLocale(currency: string): string { + return CURRENCY_LOCALE[(currency || '').toUpperCase()] || 'en-US' +} + +/** + * Locale- and currency-correct money formatting via Intl: the symbol position, + * thousands/decimal separators and decimal count all follow the user's locale + * and the currency itself (e.g. de-DE EUR → "1.234,56 €", en-US USD → "$1,234.56", + * ja-JP JPY → "¥1,235"). Falls back to a " CODE" suffix for unknown codes. + */ +export function formatMoney( + value: number, + currency: string, + locale: string, + opts?: { decimals?: number }, +): string { + const cur = (currency || 'EUR').toUpperCase() + const decimals = opts?.decimals ?? currencyDecimals(cur) + // Format in the currency's home convention, not the app language, so the symbol + // position and separators are always correct for that currency. `locale` stays + // as a last-resort fallback for the error path. + const fmtLocale = currencyLocale(cur) + try { + return new Intl.NumberFormat(fmtLocale, { + style: 'currency', + currency: cur, + minimumFractionDigits: decimals, + maximumFractionDigits: decimals, + }).format(value || 0) + } catch { + return `${(value || 0).toLocaleString(locale || fmtLocale, { minimumFractionDigits: decimals, maximumFractionDigits: decimals })} ${cur}` + } +} + export function formatDate(dateStr: string | null | undefined, locale: string, timeZone?: string): string | null { if (!dateStr) return null const opts: Intl.DateTimeFormatOptions = { diff --git a/docker-compose.yml b/docker-compose.yml index 83a173be..bc8dd02c 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -22,6 +22,7 @@ services: - TZ=${TZ:-UTC} # Timezone for logs, reminders and scheduled tasks (e.g. Europe/Berlin) - LOG_LEVEL=${LOG_LEVEL:-info} # info = concise user actions; debug = verbose admin-level details # - DEFAULT_LANGUAGE=en # Default language on the login page for users with no saved preference. Browser/OS language is auto-detected first; this is the fallback. Supported: de, en, es, fr, hu, nl, br, cs, pl, ru, zh, zh-TW, it, ar +# - SESSION_DURATION=30d # How long users stay logged in (trek_session JWT + cookie maxAge). Accepts: 1h | 12h | 7d | 30d | 90d. Default: 24h - ALLOWED_ORIGINS=${ALLOWED_ORIGINS:-} # Comma-separated origins for CORS and email notification links # - FORCE_HTTPS=true # Optional. Enables HTTPS redirect, HSTS, CSP upgrade-insecure-requests, and secure cookies behind a TLS proxy # - HSTS_INCLUDE_SUBDOMAINS=false # When true: adds includeSubDomains to the HSTS header. Only effective when HSTS is active. Leave false if sibling subdomains still run over plain HTTP. diff --git a/package-lock.json b/package-lock.json index be708800..1037c39b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -26,6 +26,8 @@ "name": "@trek/client", "version": "3.0.22", "dependencies": { + "@fontsource/geist-sans": "^5.2.5", + "@fontsource/poppins": "^5.2.7", "@react-pdf/renderer": "^4.5.1", "@trek/shared": "*", "axios": "^1.6.7", @@ -2505,6 +2507,24 @@ } } }, + "node_modules/@fontsource/geist-sans": { + "version": "5.2.5", + "resolved": "https://registry.npmjs.org/@fontsource/geist-sans/-/geist-sans-5.2.5.tgz", + "integrity": "sha512-anllOHyJbElRs9fV15TeDRqAeb1IKm4bSknPl6ZMoyPTx1BBy7logudcUwpNjmQLkzn4Q0JGQLRCUKJYoyST6A==", + "license": "OFL-1.1", + "funding": { + "url": "https://github.com/sponsors/ayuhito" + } + }, + "node_modules/@fontsource/poppins": { + "version": "5.2.7", + "resolved": "https://registry.npmjs.org/@fontsource/poppins/-/poppins-5.2.7.tgz", + "integrity": "sha512-6uQyPmseo4FgI97WIhA4yWRlNaoLk4vSDK/PyRwdqqZb5zAEuc+Kunt8JTMcsHYUEGYBtN15SNkMajMdqUSUmg==", + "license": "OFL-1.1", + "funding": { + "url": "https://github.com/sponsors/ayuhito" + } + }, "node_modules/@hono/node-server": { "version": "1.19.14", "license": "MIT", @@ -5209,6 +5229,7 @@ "os": [ "win32" ], + "peer": true, "engines": { "node": ">=10" } diff --git a/server/.env.example b/server/.env.example index 04d59602..2d9d573d 100644 --- a/server/.env.example +++ b/server/.env.example @@ -8,6 +8,7 @@ NODE_ENV=development # development = development mode; production = production m # Generate with: node -e "console.log(require('crypto').randomBytes(32).toString('hex'))" TZ=UTC # Timezone for logs, reminders and scheduled tasks (e.g. Europe/Berlin) # DEFAULT_LANGUAGE=en # Default language on the login page for users with no saved preference (default: en) +# SESSION_DURATION=30d # How long users stay logged in — sets the trek_session JWT exp + cookie maxAge. Accepts 1h, 12h, 7d, 30d, 90d. Default: 24h # Supported values: de, en, es, fr, hu, nl, br, cs, pl, ru, zh, zh-TW, it, ar # Note: browser/OS language is detected automatically first; this is the fallback when no match is found. LOG_LEVEL=info # info = concise user actions; debug = verbose admin-level details diff --git a/server/src/config.ts b/server/src/config.ts index 59f7079e..4de30de7 100644 --- a/server/src/config.ts +++ b/server/src/config.ts @@ -107,3 +107,32 @@ if (!SUPPORTED_LANG_CODES.includes(rawDefaultLang)) { console.warn(`DEFAULT_LANGUAGE="${rawDefaultLang}" is not supported. Falling back to "en". Supported: ${SUPPORTED_LANG_CODES.join(', ')}`); } export const DEFAULT_LANGUAGE = SUPPORTED_LANG_CODES.includes(rawDefaultLang) ? rawDefaultLang : 'en'; + +// SESSION_DURATION controls how long a TREK session (the `trek_session` JWT +// cookie) stays valid before re-login is required. Accepts ms-style strings: +// '1h', '12h', '7d', '30d', '90d', etc. It applies to BOTH the JWT `exp` claim +// and the cookie `maxAge`, so the two never drift apart. Invalid values warn at +// startup and fall back to the default. Does not affect the short-lived MFA +// challenge token or MCP OAuth tokens — those keep their own TTL. +const DEFAULT_SESSION_DURATION = '24h'; +const DURATION_UNITS_MS: Record = { + ms: 1, s: 1000, m: 60_000, h: 3_600_000, d: 86_400_000, w: 604_800_000, y: 31_557_600_000, +}; +function parseDurationMs(value: string): number | null { + const m = /^(\d+(?:\.\d+)?)\s*(ms|s|m|h|d|w|y)?$/i.exec(value.trim()); + if (!m) return null; + const n = parseFloat(m[1]); + if (!Number.isFinite(n) || n <= 0) return null; + return n * DURATION_UNITS_MS[(m[2] || 'ms').toLowerCase()]; +} +const rawSessionDuration = process.env.SESSION_DURATION?.trim() || DEFAULT_SESSION_DURATION; +const parsedSessionMs = parseDurationMs(rawSessionDuration); +if (parsedSessionMs == null) { + console.warn(`SESSION_DURATION="${rawSessionDuration}" is not a valid duration (use e.g. 1h, 7d, 30d). Falling back to "${DEFAULT_SESSION_DURATION}".`); +} +/** Human-readable session length actually in effect (for logs/diagnostics). */ +export const SESSION_DURATION = parsedSessionMs == null ? DEFAULT_SESSION_DURATION : rawSessionDuration; +/** Session length in milliseconds — used for the cookie `maxAge`. */ +export const SESSION_DURATION_MS = parsedSessionMs ?? parseDurationMs(DEFAULT_SESSION_DURATION)!; +/** Session length in seconds — passed to `jwt.sign({ expiresIn })` (number = seconds). */ +export const SESSION_DURATION_SECONDS = Math.floor(SESSION_DURATION_MS / 1000); diff --git a/server/src/db/migrations.ts b/server/src/db/migrations.ts index e0623524..67563076 100644 --- a/server/src/db/migrations.ts +++ b/server/src/db/migrations.ts @@ -2278,6 +2278,68 @@ function runMigrations(db: Database.Database): void { if (!err.message?.includes('no such table')) throw err; } }, + // Costs rework (budget → "Costs", Tricount/Splitwise style). Adds, additively + // and without touching existing rows: + // - per-expense currency + exchange_rate, so an expense can be entered in a + // foreign currency and converted to the trip base currency (NULL currency = + // base currency; rate 1.0). Closes the multi-currency request (#551). + // - budget_item_payers: several people can each have paid part of one expense + // (amounts in the expense currency), replacing the single paid_by_user_id. + // - budget_settlements: persisted "X paid Y" transfers so the settle-up + // history (with undo) is shared across all trip members. + // The equal-split participants stay in budget_item_members. The single legacy + // payer is backfilled into budget_item_payers as one payer covering the total. + () => { + try { db.exec('ALTER TABLE budget_items ADD COLUMN currency TEXT'); } + catch (err: any) { if (!err.message?.includes('duplicate column name')) throw err; } + try { db.exec('ALTER TABLE budget_items ADD COLUMN exchange_rate REAL NOT NULL DEFAULT 1'); } + catch (err: any) { if (!err.message?.includes('duplicate column name')) throw err; } + + db.exec(` + CREATE TABLE IF NOT EXISTS budget_item_payers ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + budget_item_id INTEGER NOT NULL REFERENCES budget_items(id) ON DELETE CASCADE, + user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + amount REAL NOT NULL DEFAULT 0, + UNIQUE(budget_item_id, user_id) + ) + `); + db.exec('CREATE INDEX IF NOT EXISTS idx_budget_item_payers_item ON budget_item_payers(budget_item_id)'); + + db.exec(` + CREATE TABLE IF NOT EXISTS budget_settlements ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + trip_id INTEGER NOT NULL REFERENCES trips(id) ON DELETE CASCADE, + from_user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + to_user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + amount REAL NOT NULL DEFAULT 0, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + created_by_user_id INTEGER REFERENCES users(id) + ) + `); + db.exec('CREATE INDEX IF NOT EXISTS idx_budget_settlements_trip ON budget_settlements(trip_id)'); + + // Backfill the legacy single payer: that person paid the full total of the + // expense, in the (base) currency the existing amount was already stored in. + try { + db.exec(` + INSERT OR IGNORE INTO budget_item_payers (budget_item_id, user_id, amount) + SELECT id, paid_by_user_id, total_price + FROM budget_items + WHERE paid_by_user_id IS NOT NULL + `); + } catch (err: any) { + if (!err.message?.includes('no such column')) throw err; + } + }, + // Rename the "Budget Planner" addon to "Costs" in the admin add-on list. This + // is a display rename only — the addon id, tables, permissions and MCP tools + // all stay 'budget'. Scoped to the default name so a customised one is kept. + () => { + db.prepare( + "UPDATE addons SET name = 'Costs', description = 'Track and split trip expenses' WHERE id = 'budget' AND name = 'Budget Planner'", + ).run(); + }, ]; if (currentVersion < migrations.length) { diff --git a/server/src/db/seeds.ts b/server/src/db/seeds.ts index 46efc0cf..ad86f771 100644 --- a/server/src/db/seeds.ts +++ b/server/src/db/seeds.ts @@ -90,7 +90,7 @@ function seedAddons(db: Database.Database): void { try { const defaultAddons = [ { id: 'packing', name: 'Lists', description: 'Packing lists and to-do tasks for your trips', type: 'trip', icon: 'ListChecks', enabled: 1, sort_order: 0 }, - { id: 'budget', name: 'Budget Planner', description: 'Track expenses and plan your travel budget', type: 'trip', icon: 'Wallet', enabled: 1, sort_order: 1 }, + { id: 'budget', name: 'Costs', description: 'Track and split trip expenses', type: 'trip', icon: 'Wallet', enabled: 1, sort_order: 1 }, { id: 'documents', name: 'Documents', description: 'Store and manage travel documents', type: 'trip', icon: 'FileText', enabled: 1, sort_order: 2 }, { id: 'vacay', name: 'Vacay', description: 'Personal vacation day planner with calendar view', type: 'global', icon: 'CalendarDays', enabled: 1, sort_order: 10 }, { id: 'atlas', name: 'Atlas', description: 'World map of your visited countries with travel stats', type: 'global', icon: 'Globe', enabled: 1, sort_order: 11 }, diff --git a/server/src/nest/budget/budget.controller.ts b/server/src/nest/budget/budget.controller.ts index 37f8d5e0..f62bcd68 100644 --- a/server/src/nest/budget/budget.controller.ts +++ b/server/src/nest/budget/budget.controller.ts @@ -8,6 +8,7 @@ import { Param, Post, Put, + Query, UseGuards, } from '@nestjs/common'; import type { User } from '../../types'; @@ -57,9 +58,56 @@ export class BudgetController { } @Get('settlement') - settlement(@CurrentUser() user: User, @Param('tripId') tripId: string) { + settlement( + @CurrentUser() user: User, + @Param('tripId') tripId: string, + @Query('base') base?: string, + ) { + const trip = this.requireTrip(tripId, user); + return this.budget.settlement(tripId, base, (trip as { currency?: string }).currency || 'EUR'); + } + + @Get('settlements') + listSettlements(@CurrentUser() user: User, @Param('tripId') tripId: string) { this.requireTrip(tripId, user); - return this.budget.settlement(tripId); + return { settlements: this.budget.listSettlements(tripId) }; + } + + @Post('settlements') + createSettlement( + @CurrentUser() user: User, + @Param('tripId') tripId: string, + @Body() body: { from_user_id?: number; to_user_id?: number; amount?: number }, + @Headers('x-socket-id') socketId?: string, + ) { + const trip = this.requireTrip(tripId, user); + this.requireEdit(trip, user); + if (body.from_user_id == null || body.to_user_id == null || body.amount == null) { + throw new HttpException({ error: 'from_user_id, to_user_id and amount are required' }, 400); + } + const settlement = this.budget.createSettlement( + tripId, + { from_user_id: body.from_user_id, to_user_id: body.to_user_id, amount: body.amount }, + user.id, + ); + this.budget.broadcast(tripId, 'budget:settlement-created', { settlement }, socketId); + return { settlement }; + } + + @Delete('settlements/:settlementId') + deleteSettlement( + @CurrentUser() user: User, + @Param('tripId') tripId: string, + @Param('settlementId') settlementId: string, + @Headers('x-socket-id') socketId?: string, + ) { + const trip = this.requireTrip(tripId, user); + this.requireEdit(trip, user); + if (!this.budget.deleteSettlement(settlementId, tripId)) { + throw new HttpException({ error: 'Settlement not found' }, 404); + } + this.budget.broadcast(tripId, 'budget:settlement-deleted', { settlementId: Number(settlementId) }, socketId); + return { success: true }; } @Post() @@ -149,6 +197,27 @@ export class BudgetController { return { members: result.members, item: result.item }; } + @Put(':id/payers') + setPayers( + @CurrentUser() user: User, + @Param('tripId') tripId: string, + @Param('id') id: string, + @Body('payers') payers: unknown, + @Headers('x-socket-id') socketId?: string, + ) { + const trip = this.requireTrip(tripId, user); + this.requireEdit(trip, user); + if (!Array.isArray(payers)) { + throw new HttpException({ error: 'payers must be an array' }, 400); + } + const item = this.budget.setPayers(id, tripId, payers as { user_id: number; amount: number }[]); + if (!item) { + throw new HttpException({ error: 'Budget item not found' }, 404); + } + this.budget.broadcast(tripId, 'budget:updated', { item }, socketId); + return { item }; + } + @Put(':id/members/:userId/paid') toggleMemberPaid( @CurrentUser() user: User, diff --git a/server/src/nest/budget/budget.service.ts b/server/src/nest/budget/budget.service.ts index 4261fe8f..8f4caccc 100644 --- a/server/src/nest/budget/budget.service.ts +++ b/server/src/nest/budget/budget.service.ts @@ -4,6 +4,7 @@ import { broadcast } from '../../websocket'; import { checkPermission } from '../../services/permissions'; import type { User } from '../../types'; import * as svc from '../../services/budgetService'; +import { getRates } from '../../services/exchangeRateService'; type Trip = NonNullable>; @@ -34,8 +35,10 @@ export class BudgetService { return svc.getPerPersonSummary(tripId); } - settlement(tripId: string) { - return svc.calculateSettlement(tripId); + async settlement(tripId: string, base: string | undefined, tripCurrency: string) { + const effectiveBase = (base || tripCurrency || 'EUR').toUpperCase(); + const rates = await getRates(effectiveBase); + return svc.calculateSettlement(tripId, { base: effectiveBase, rates, tripCurrency }); } create(tripId: string, data: Parameters[1]) { @@ -58,6 +61,22 @@ export class BudgetService { return svc.toggleMemberPaid(id, userId, paid); } + setPayers(id: string, tripId: string, payers: { user_id: number; amount: number }[]) { + return svc.setItemPayers(id, tripId, payers); + } + + listSettlements(tripId: string) { + return svc.listSettlements(tripId); + } + + createSettlement(tripId: string, data: { from_user_id: number; to_user_id: number; amount: number }, userId: number) { + return svc.createSettlement(tripId, data, userId); + } + + deleteSettlement(id: string, tripId: string): boolean { + return svc.deleteSettlement(id, tripId); + } + reorderItems(tripId: string, orderedIds: number[]): void { svc.reorderBudgetItems(tripId, orderedIds); } diff --git a/server/src/nest/journey/journey.controller.ts b/server/src/nest/journey/journey.controller.ts index 26602a4d..2a092f73 100644 --- a/server/src/nest/journey/journey.controller.ts +++ b/server/src/nest/journey/journey.controller.ts @@ -392,7 +392,7 @@ export class JourneyController { // ── Share Link ────────────────────────────────────────────────────────── @Get(':id/share-link') getShareLink(@CurrentUser() user: User, @Param('id') id: string) { - return { link: this.journey.getJourneyShareLink(Number(id)) }; + return { link: this.journey.getJourneyShareLink(Number(id), user.id) }; } @Post(':id/share-link') diff --git a/server/src/nest/journey/journey.service.ts b/server/src/nest/journey/journey.service.ts index 6b3e6e98..bd5e1791 100644 --- a/server/src/nest/journey/journey.service.ts +++ b/server/src/nest/journey/journey.service.ts @@ -61,7 +61,13 @@ export class JourneyService { removeContributor(id: number, userId: number, targetUserId: number) { return svc.removeContributor(id, userId, targetUserId); } // Share links - getJourneyShareLink(id: number) { return share.getJourneyShareLink(id); } + // Authorization: only someone with access to the journey may read its public + // share token — same access model as create/delete here and the + // get_journey_share_link MCP tool. + getJourneyShareLink(id: number, userId: number) { + if (!svc.canAccessJourney(id, userId)) return null; + return share.getJourneyShareLink(id); + } createOrUpdateJourneyShareLink(id: number, userId: number, data: Parameters[2]) { return share.createOrUpdateJourneyShareLink(id, userId, data); } deleteJourneyShareLink(id: number, userId: number) { return share.deleteJourneyShareLink(id, userId); } diff --git a/server/src/services/authService.ts b/server/src/services/authService.ts index c545981b..61b6193d 100644 --- a/server/src/services/authService.ts +++ b/server/src/services/authService.ts @@ -7,7 +7,7 @@ import { authenticator } from 'otplib'; import QRCode from 'qrcode'; import { randomBytes, createHash } from 'crypto'; import { db } from '../db/database'; -import { JWT_SECRET } from '../config'; +import { JWT_SECRET, SESSION_DURATION_SECONDS } from '../config'; import { validatePassword } from './passwordPolicy'; import { encryptMfaSecret, decryptMfaSecret } from './mfaCrypto'; import { getAllPermissions } from './permissions'; @@ -177,7 +177,7 @@ export function generateToken(user: { id: number | bigint; password_version?: nu return jwt.sign( { id: user.id, pv }, JWT_SECRET, - { expiresIn: '24h', algorithm: 'HS256' } + { expiresIn: SESSION_DURATION_SECONDS, algorithm: 'HS256' } ); } diff --git a/server/src/services/budgetService.ts b/server/src/services/budgetService.ts index e31d7410..fc98f6ce 100644 --- a/server/src/services/budgetService.ts +++ b/server/src/services/budgetService.ts @@ -1,5 +1,5 @@ import { db } from '../db/database'; -import { BudgetItem, BudgetItemMember } from '../types'; +import { BudgetItem, BudgetItemMember, BudgetItemPayer } from '../types'; import { avatarUrl } from './avatarUrl'; // --------------------------------------------------------------------------- @@ -19,6 +19,30 @@ function loadItemMembers(itemId: number | string) { return rows.map(m => ({ ...m, avatar_url: avatarUrl(m) })); } +function loadItemPayers(itemId: number | string) { + const rows = db.prepare(` + SELECT bp.user_id, bp.amount, u.username, u.avatar + FROM budget_item_payers bp + JOIN users u ON bp.user_id = u.id + WHERE bp.budget_item_id = ? + `).all(itemId) as BudgetItemPayer[]; + return rows.map(p => ({ ...p, avatar_url: avatarUrl(p) })); +} + +/** Replace the payer rows of an item and keep total_price = sum of payer amounts. */ +function writeItemPayers(itemId: number | string, payers: { user_id: number; amount: number }[]) { + db.prepare('DELETE FROM budget_item_payers WHERE budget_item_id = ?').run(itemId); + const insert = db.prepare('INSERT OR IGNORE INTO budget_item_payers (budget_item_id, user_id, amount) VALUES (?, ?, ?)'); + let total = 0; + for (const p of payers) { + if (!(p.amount > 0)) continue; + insert.run(itemId, p.user_id, p.amount); + total += p.amount; + } + db.prepare('UPDATE budget_items SET total_price = ? WHERE id = ?').run(total, itemId); + return total; +} + // --------------------------------------------------------------------------- // CRUD // --------------------------------------------------------------------------- @@ -50,20 +74,45 @@ export function listBudgetItems(tripId: string | number) { } } - items.forEach(item => { item.members = membersByItem[item.id] || []; }); + const payersByItem: Record = {}; + if (itemIds.length > 0) { + const allPayers = db.prepare(` + SELECT bp.budget_item_id, bp.user_id, bp.amount, u.username, u.avatar + FROM budget_item_payers bp + JOIN users u ON bp.user_id = u.id + WHERE bp.budget_item_id IN (${itemIds.map(() => '?').join(',')}) + `).all(...itemIds) as (BudgetItemPayer & { budget_item_id: number })[]; + + for (const p of allPayers) { + if (!payersByItem[p.budget_item_id]) payersByItem[p.budget_item_id] = []; + payersByItem[p.budget_item_id].push({ + user_id: p.user_id, amount: p.amount, username: p.username, avatar_url: avatarUrl(p), + }); + } + } + + items.forEach(item => { + item.members = membersByItem[item.id] || []; + item.payers = payersByItem[item.id] || []; + }); return items; } export function createBudgetItem( tripId: string | number, - data: { category?: string; name: string; total_price?: number; persons?: number | null; days?: number | null; note?: string | null; expense_date?: string | null }, + data: { + category?: string; name: string; total_price?: number; + currency?: string | null; exchange_rate?: number; + payers?: { user_id: number; amount: number }[]; member_ids?: number[]; + persons?: number | null; days?: number | null; note?: string | null; expense_date?: string | null; + }, ) { const maxOrder = db.prepare( 'SELECT MAX(sort_order) as max FROM budget_items WHERE trip_id = ?' ).get(tripId) as { max: number | null }; const sortOrder = (maxOrder.max !== null ? maxOrder.max : -1) + 1; - const cat = data.category || 'Other'; + const cat = data.category || 'other'; // Ensure category has a sort_order entry const catExists = db.prepare('SELECT 1 FROM budget_category_order WHERE trip_id = ? AND category = ?').get(tripId, cat); @@ -73,22 +122,37 @@ export function createBudgetItem( db.prepare('INSERT OR IGNORE INTO budget_category_order (trip_id, category, sort_order) VALUES (?, ?, ?)').run(tripId, cat, catOrder); } + // total_price is derived from explicit payers when given; otherwise the caller + // value (planning entries, or a bill no one has paid yet). + const payerTotal = (data.payers || []).reduce((a, p) => a + (p.amount > 0 ? p.amount : 0), 0); + const total = data.payers && data.payers.length > 0 ? payerTotal : (data.total_price || 0); + const result = db.prepare( - 'INSERT INTO budget_items (trip_id, category, name, total_price, persons, days, note, sort_order, expense_date) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)' + 'INSERT INTO budget_items (trip_id, category, name, total_price, currency, exchange_rate, persons, days, note, sort_order, expense_date) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)' ).run( tripId, cat, data.name, - data.total_price || 0, - data.persons != null ? data.persons : null, + total, + data.currency || null, + data.exchange_rate != null ? data.exchange_rate : 1, + data.member_ids ? data.member_ids.length : (data.persons != null ? data.persons : null), data.days !== undefined && data.days !== null ? data.days : null, data.note || null, sortOrder, data.expense_date || null, ); - const item = db.prepare('SELECT * FROM budget_items WHERE id = ?').get(result.lastInsertRowid) as BudgetItem & { members?: BudgetItemMember[] }; - item.members = []; + const itemId = result.lastInsertRowid as number; + if (data.payers && data.payers.length > 0) writeItemPayers(itemId, data.payers); + if (data.member_ids && data.member_ids.length > 0) { + const insert = db.prepare('INSERT OR IGNORE INTO budget_item_members (budget_item_id, user_id, paid) VALUES (?, ?, 0)'); + for (const uid of data.member_ids) insert.run(itemId, uid); + } + + const item = db.prepare('SELECT * FROM budget_items WHERE id = ?').get(itemId) as BudgetItem; + item.members = loadItemMembers(itemId); + item.payers = loadItemPayers(itemId); return item; } @@ -106,7 +170,12 @@ export function linkBudgetItemToReservation( export function updateBudgetItem( id: string | number, tripId: string | number, - data: { category?: string; name?: string; total_price?: number; persons?: number | null; days?: number | null; note?: string | null; sort_order?: number; expense_date?: string | null }, + data: { + category?: string; name?: string; total_price?: number; + currency?: string | null; exchange_rate?: number; + payers?: { user_id: number; amount: number }[]; member_ids?: number[]; + persons?: number | null; days?: number | null; note?: string | null; sort_order?: number; expense_date?: string | null; + }, ) { const item = db.prepare('SELECT * FROM budget_items WHERE id = ? AND trip_id = ?').get(id, tripId); if (!item) return null; @@ -116,6 +185,8 @@ export function updateBudgetItem( category = COALESCE(?, category), name = COALESCE(?, name), total_price = CASE WHEN ? IS NOT NULL THEN ? ELSE total_price END, + currency = CASE WHEN ? THEN ? ELSE currency END, + exchange_rate = CASE WHEN ? IS NOT NULL THEN ? ELSE exchange_rate END, persons = CASE WHEN ? IS NOT NULL THEN ? ELSE persons END, days = CASE WHEN ? THEN ? ELSE days END, note = CASE WHEN ? THEN ? ELSE note END, @@ -126,6 +197,8 @@ export function updateBudgetItem( data.category || null, data.name || null, data.total_price !== undefined ? 1 : null, data.total_price !== undefined ? data.total_price : 0, + data.currency !== undefined ? 1 : 0, data.currency !== undefined ? (data.currency || null) : null, + data.exchange_rate !== undefined ? 1 : null, data.exchange_rate !== undefined ? data.exchange_rate : 1, data.persons !== undefined ? 1 : null, data.persons !== undefined ? data.persons : null, data.days !== undefined ? 1 : 0, data.days !== undefined ? data.days : null, data.note !== undefined ? 1 : 0, data.note !== undefined ? data.note : null, @@ -134,6 +207,15 @@ export function updateBudgetItem( id, ); + // Optional inline payer/member replacement (the edit modal saves all at once). + if (data.payers !== undefined) writeItemPayers(id, data.payers); + if (data.member_ids !== undefined) { + db.prepare('DELETE FROM budget_item_members WHERE budget_item_id = ?').run(id); + const insert = db.prepare('INSERT OR IGNORE INTO budget_item_members (budget_item_id, user_id, paid) VALUES (?, ?, 0)'); + for (const uid of data.member_ids) insert.run(id, uid); + db.prepare('UPDATE budget_items SET persons = ? WHERE id = ?').run(data.member_ids.length || null, id); + } + // If category changed, update category order table if (data.category) { const catExists = db.prepare('SELECT 1 FROM budget_category_order WHERE trip_id = ? AND category = ?').get(tripId, data.category); @@ -144,8 +226,23 @@ export function updateBudgetItem( } } - const updated = db.prepare('SELECT * FROM budget_items WHERE id = ?').get(id) as BudgetItem & { members?: BudgetItemMember[] }; + const updated = db.prepare('SELECT * FROM budget_items WHERE id = ?').get(id) as BudgetItem; updated.members = loadItemMembers(id); + updated.payers = loadItemPayers(id); + return updated; +} + +// --------------------------------------------------------------------------- +// Payers +// --------------------------------------------------------------------------- + +export function setItemPayers(id: string | number, tripId: string | number, payers: { user_id: number; amount: number }[]) { + const item = db.prepare('SELECT id FROM budget_items WHERE id = ? AND trip_id = ?').get(id, tripId); + if (!item) return null; + writeItemPayers(id, payers); + const updated = db.prepare('SELECT * FROM budget_items WHERE id = ?').get(id) as BudgetItem; + updated.members = loadItemMembers(id); + updated.payers = loadItemPayers(id); return updated; } @@ -220,37 +317,65 @@ export function getPerPersonSummary(tripId: string | number) { // Settlement calculation (greedy debt matching) // --------------------------------------------------------------------------- -export function calculateSettlement(tripId: string | number) { +export function calculateSettlement( + tripId: string | number, + opts: { base?: string; rates?: Record | null; tripCurrency?: string } = {}, +) { + 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): number => { + const cur = (itemCurrency || tripCurrency).toUpperCase(); + if (cur === base || !rates) return amount; + const r = rates[cur]; + return r && r > 0 ? amount / r : amount; + }; + const items = db.prepare('SELECT * FROM budget_items WHERE trip_id = ?').all(tripId) as BudgetItem[]; const allMembers = db.prepare(` - SELECT bm.budget_item_id, bm.user_id, bm.paid, u.username, u.avatar + SELECT bm.budget_item_id, bm.user_id, u.username, u.avatar FROM budget_item_members bm JOIN users u ON bm.user_id = u.id WHERE bm.budget_item_id IN (SELECT id FROM budget_items WHERE trip_id = ?) `).all(tripId) as (BudgetItemMember & { budget_item_id: number })[]; + const allPayers = db.prepare(` + SELECT bp.budget_item_id, bp.user_id, bp.amount, u.username, u.avatar + FROM budget_item_payers bp + JOIN users u ON bp.user_id = u.id + WHERE bp.budget_item_id IN (SELECT id FROM budget_items WHERE trip_id = ?) + `).all(tripId) as (BudgetItemPayer & { budget_item_id: number })[]; - // Calculate net balance per user: positive = is owed money, negative = owes money + // Net balance per user, in the requested base currency: positive = is owed + // money, negative = owes money. Each expense's amounts are converted from their + // own currency to the base with live rates, so mixed-currency trips net correctly. const balances: Record = {}; + const ensure = (id: number, src: { username?: string; avatar?: string | null }) => { + if (!balances[id]) balances[id] = { user_id: id, username: src.username || '', avatar_url: avatarUrl(src), balance: 0 }; + return balances[id]; + }; for (const item of items) { const members = allMembers.filter(m => m.budget_item_id === item.id); - if (members.length === 0) continue; + const payers = allPayers.filter(p => p.budget_item_id === item.id); + if (members.length === 0) continue; // planning-only entry → doesn't affect balances - const payers = members.filter(m => m.paid); - if (payers.length === 0) continue; // no one marked as paid + const paidBase = payers.reduce((a, p) => a + toBase(p.amount > 0 ? p.amount : 0, item.currency), 0); + const sharePerMember = paidBase / members.length; - const sharePerMember = item.total_price / members.length; - const paidPerPayer = item.total_price / payers.length; + // Payers are credited what they actually paid (converted to base)… + for (const p of payers) ensure(p.user_id, p).balance += toBase(p.amount > 0 ? p.amount : 0, item.currency); + // …and every split participant owes an equal share of the base total. + for (const m of members) ensure(m.user_id, m).balance -= sharePerMember; + } - for (const m of members) { - if (!balances[m.user_id]) { - balances[m.user_id] = { user_id: m.user_id, username: m.username, avatar_url: avatarUrl(m), balance: 0 }; - } - // Everyone owes their share - balances[m.user_id].balance -= sharePerMember; - // Payers get credited what they paid - if (m.paid) balances[m.user_id].balance += paidPerPayer; - } + // Persisted settle-up transfers already moved money: the payer's debt shrinks, + // the receiver's credit shrinks, so the corresponding flow disappears. + const settlements = listSettlements(tripId); + for (const s of settlements) { + if (balances[s.from_user_id]) balances[s.from_user_id].balance += s.amount; + if (balances[s.to_user_id]) balances[s.to_user_id].balance -= s.amount; } // Calculate optimized payment flows (greedy algorithm) @@ -283,9 +408,52 @@ export function calculateSettlement(tripId: string | number) { return { balances: Object.values(balances).map(b => ({ ...b, balance: Math.round(b.balance * 100) / 100 })), flows, + settlements, }; } +// --------------------------------------------------------------------------- +// Settlements (persisted settle-up transfers — history + undo) +// --------------------------------------------------------------------------- + +export function listSettlements(tripId: string | number) { + const rows = db.prepare(` + SELECT s.id, s.trip_id, s.from_user_id, s.to_user_id, s.amount, s.created_at, s.created_by_user_id, + fu.username AS from_username, fu.avatar AS from_avatar, + tu.username AS to_username, tu.avatar AS to_avatar + FROM budget_settlements s + JOIN users fu ON s.from_user_id = fu.id + JOIN users tu ON s.to_user_id = tu.id + WHERE s.trip_id = ? + ORDER BY s.created_at DESC, s.id DESC + `).all(tripId) as any[]; + return rows.map(r => ({ + id: r.id, trip_id: r.trip_id, + from_user_id: r.from_user_id, to_user_id: r.to_user_id, + amount: r.amount, created_at: r.created_at, created_by_user_id: r.created_by_user_id, + from_username: r.from_username, from_avatar_url: avatarUrl({ avatar: r.from_avatar }), + to_username: r.to_username, to_avatar_url: avatarUrl({ avatar: r.to_avatar }), + })); +} + +export function createSettlement( + tripId: string | number, + data: { from_user_id: number; to_user_id: number; amount: number }, + createdByUserId?: number, +) { + const result = db.prepare( + 'INSERT INTO budget_settlements (trip_id, from_user_id, to_user_id, amount, created_by_user_id) VALUES (?, ?, ?, ?, ?)' + ).run(tripId, data.from_user_id, data.to_user_id, Math.round(data.amount * 100) / 100, createdByUserId ?? null); + return listSettlements(tripId).find(s => s.id === Number(result.lastInsertRowid)) || null; +} + +export function deleteSettlement(id: string | number, tripId: string | number): boolean { + const row = db.prepare('SELECT id FROM budget_settlements WHERE id = ? AND trip_id = ?').get(id, tripId); + if (!row) return false; + db.prepare('DELETE FROM budget_settlements WHERE id = ?').run(id); + return true; +} + // --------------------------------------------------------------------------- // Reorder // --------------------------------------------------------------------------- diff --git a/server/src/services/cookie.ts b/server/src/services/cookie.ts index c97e187c..85c5f970 100644 --- a/server/src/services/cookie.ts +++ b/server/src/services/cookie.ts @@ -1,4 +1,5 @@ import { Request, Response } from 'express'; +import { SESSION_DURATION_MS } from '../config'; const COOKIE_NAME = 'trek_session'; @@ -32,7 +33,7 @@ function buildOptions(clear: boolean, secure: boolean) { secure, sameSite: 'lax' as const, path: '/', - ...(clear ? {} : { maxAge: 24 * 60 * 60 * 1000 }), // 24h — matches JWT expiry + ...(clear ? {} : { maxAge: SESSION_DURATION_MS }), // matches the JWT expiry (SESSION_DURATION) }; } diff --git a/server/src/services/exchangeRateService.ts b/server/src/services/exchangeRateService.ts new file mode 100644 index 00000000..bcbf6d0f --- /dev/null +++ b/server/src/services/exchangeRateService.ts @@ -0,0 +1,68 @@ +/** + * Live exchange rates for the Costs/Budget money conversion. + * + * Fetches from exchangerate-api.com (no key, already CSP-allowlisted for the + * dashboard widget) and caches per base currency in-memory for a few hours so a + * settlement request never hammers the upstream. Rates are "units of X per 1 + * base", so an amount in currency C converts to base as `amount / rates[C]`. + * + * Everything degrades gracefully: if the fetch fails (offline, upstream down), + * callers get `null`/identity conversion and amounts are treated as already in + * the base currency rather than throwing. + */ + +const TTL_MS = 6 * 60 * 60 * 1000; // 6h +const cache = new Map; ts: number }>(); +const inflight = new Map | null>>(); + +async function fetchRates(base: string): Promise | null> { + try { + const res = await fetch(`https://api.exchangerate-api.com/v4/latest/${encodeURIComponent(base)}`); + if (!res.ok) return null; + const data = (await res.json()) as { rates?: Record }; + return data.rates && typeof data.rates === 'object' ? data.rates : null; + } catch { + return null; + } +} + +/** Rates map for `base` (cached). Returns null if unavailable. */ +export async function getRates(base: string): Promise | null> { + const key = (base || 'EUR').toUpperCase(); + const hit = cache.get(key); + const now = Date.now(); + if (hit && now - hit.ts < TTL_MS) return hit.rates; + + // Coalesce concurrent fetches for the same base. + let p = inflight.get(key); + if (!p) { + p = fetchRates(key).then(rates => { + if (rates) cache.set(key, { rates, ts: Date.now() }); + inflight.delete(key); + return rates; + }); + inflight.set(key, p); + } + const rates = await p; + // On failure fall back to the last cached value if we have one. + if (!rates && hit) return hit.rates; + return rates; +} + +/** + * Convert `amount` from `from` currency into `base` using a rates map obtained + * from getRates(base). Identity when same currency or the rate is missing. + */ +export function convertWithRates( + amount: number, + from: string | null | undefined, + base: string, + rates: Record | null, +): number { + const fromCur = (from || base).toUpperCase(); + const baseCur = base.toUpperCase(); + if (fromCur === baseCur || !rates) return amount; + const r = rates[fromCur]; + if (!r || r <= 0) return amount; + return amount / r; +} diff --git a/server/src/services/oidcService.ts b/server/src/services/oidcService.ts index d328263c..d032c0b7 100644 --- a/server/src/services/oidcService.ts +++ b/server/src/services/oidcService.ts @@ -1,7 +1,7 @@ import crypto from 'crypto'; import jwt from 'jsonwebtoken'; import { db } from '../db/database'; -import { JWT_SECRET } from '../config'; +import { JWT_SECRET, SESSION_DURATION_SECONDS } from '../config'; import { User } from '../types'; import { decrypt_api_key } from './apiKeyCrypto'; import { resolveAuthToggles } from './authService'; @@ -200,7 +200,7 @@ export function frontendUrl(path: string): string { } export function generateToken(user: { id: number }): string { - return jwt.sign({ id: user.id }, JWT_SECRET, { expiresIn: '24h', algorithm: 'HS256' }); + return jwt.sign({ id: user.id }, JWT_SECRET, { expiresIn: SESSION_DURATION_SECONDS, algorithm: 'HS256' }); } // --------------------------------------------------------------------------- diff --git a/server/src/types.ts b/server/src/types.ts index a1bab82a..1345a624 100644 --- a/server/src/types.ts +++ b/server/src/types.ts @@ -122,13 +122,18 @@ export interface BudgetItem { category: string; name: string; total_price: number; + currency?: string | null; + exchange_rate?: number; persons?: number | null; days?: number | null; note?: string | null; reservation_id?: number | null; + paid_by_user_id?: number | null; + expense_date?: string | null; sort_order: number; created_at?: string; members?: BudgetItemMember[]; + payers?: BudgetItemPayer[]; } export interface BudgetItemMember { @@ -140,6 +145,15 @@ export interface BudgetItemMember { budget_item_id?: number; } +export interface BudgetItemPayer { + user_id: number; + amount: number; + username?: string; + avatar_url?: string | null; + avatar?: string | null; + budget_item_id?: number; +} + export interface ReservationEndpoint { id: number; reservation_id: number; diff --git a/server/tests/helpers/test-db.ts b/server/tests/helpers/test-db.ts index 806e8d01..4274c0c5 100644 --- a/server/tests/helpers/test-db.ts +++ b/server/tests/helpers/test-db.ts @@ -120,7 +120,7 @@ const DEFAULT_CATEGORIES = [ const DEFAULT_ADDONS = [ { id: 'packing', name: 'Packing List', description: 'Pack your bags', type: 'trip', icon: 'ListChecks', enabled: 1, sort_order: 0 }, - { id: 'budget', name: 'Budget Planner', description: 'Track expenses', type: 'trip', icon: 'Wallet', enabled: 1, sort_order: 1 }, + { id: 'budget', name: 'Costs', description: 'Track and split trip expenses', type: 'trip', icon: 'Wallet', enabled: 1, sort_order: 1 }, { id: 'documents', name: 'Documents', description: 'Manage travel documents', type: 'trip', icon: 'FileText', enabled: 1, sort_order: 2 }, { id: 'vacay', name: 'Vacay', description: 'Vacation day planner', type: 'global', icon: 'CalendarDays',enabled: 1, sort_order: 10 }, { id: 'atlas', name: 'Atlas', description: 'Visited countries map', type: 'global', icon: 'Globe', enabled: 1, sort_order: 11 }, @@ -262,4 +262,7 @@ export const TEST_CONFIG = { JWT_SECRET: 'test-jwt-secret-for-trek-testing-only', ENCRYPTION_KEY: 'a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2', updateJwtSecret: () => {}, + SESSION_DURATION: '24h', + SESSION_DURATION_MS: 86400000, + SESSION_DURATION_SECONDS: 86400, }; diff --git a/server/tests/integration/admin.test.ts b/server/tests/integration/admin.test.ts index 7ac70848..9d120d4d 100644 --- a/server/tests/integration/admin.test.ts +++ b/server/tests/integration/admin.test.ts @@ -36,6 +36,9 @@ vi.mock('../../src/config', () => ({ JWT_SECRET: 'test-jwt-secret-for-trek-testing-only', ENCRYPTION_KEY: 'a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2', updateJwtSecret: () => {}, + SESSION_DURATION: '24h', + SESSION_DURATION_MS: 86400000, + SESSION_DURATION_SECONDS: 86400, DEFAULT_LANGUAGE: 'en', })); vi.mock('../../src/websocket', () => ({ broadcast: vi.fn(), broadcastToUser: vi.fn() })); diff --git a/server/tests/integration/assignments.test.ts b/server/tests/integration/assignments.test.ts index 06c323c6..0f3aa085 100644 --- a/server/tests/integration/assignments.test.ts +++ b/server/tests/integration/assignments.test.ts @@ -36,6 +36,9 @@ vi.mock('../../src/config', () => ({ JWT_SECRET: 'test-jwt-secret-for-trek-testing-only', ENCRYPTION_KEY: 'a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2', updateJwtSecret: () => {}, + SESSION_DURATION: '24h', + SESSION_DURATION_MS: 86400000, + SESSION_DURATION_SECONDS: 86400, DEFAULT_LANGUAGE: 'en', })); vi.mock('../../src/websocket', () => ({ broadcast: vi.fn(), broadcastToUser: vi.fn() })); diff --git a/server/tests/integration/atlas.test.ts b/server/tests/integration/atlas.test.ts index 23bc16e6..6c3f85ed 100644 --- a/server/tests/integration/atlas.test.ts +++ b/server/tests/integration/atlas.test.ts @@ -36,6 +36,9 @@ vi.mock('../../src/config', () => ({ JWT_SECRET: 'test-jwt-secret-for-trek-testing-only', ENCRYPTION_KEY: 'a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2', updateJwtSecret: () => {}, + SESSION_DURATION: '24h', + SESSION_DURATION_MS: 86400000, + SESSION_DURATION_SECONDS: 86400, DEFAULT_LANGUAGE: 'en', })); vi.mock('../../src/websocket', () => ({ broadcast: vi.fn(), broadcastToUser: vi.fn() })); diff --git a/server/tests/integration/auth.test.ts b/server/tests/integration/auth.test.ts index 9a4f4451..d94e15b8 100644 --- a/server/tests/integration/auth.test.ts +++ b/server/tests/integration/auth.test.ts @@ -47,6 +47,9 @@ vi.mock('../../src/config', () => ({ JWT_SECRET: 'test-jwt-secret-for-trek-testing-only', ENCRYPTION_KEY: 'a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2', updateJwtSecret: () => {}, + SESSION_DURATION: '24h', + SESSION_DURATION_MS: 86400000, + SESSION_DURATION_SECONDS: 86400, DEFAULT_LANGUAGE: 'en', })); vi.mock('../../src/websocket', () => ({ broadcast: vi.fn(), broadcastToUser: vi.fn() })); diff --git a/server/tests/integration/backup.test.ts b/server/tests/integration/backup.test.ts index 2cc79eec..60149e47 100644 --- a/server/tests/integration/backup.test.ts +++ b/server/tests/integration/backup.test.ts @@ -40,6 +40,9 @@ vi.mock('../../src/config', () => ({ JWT_SECRET: 'test-jwt-secret-for-trek-testing-only', ENCRYPTION_KEY: 'a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2', updateJwtSecret: () => {}, + SESSION_DURATION: '24h', + SESSION_DURATION_MS: 86400000, + SESSION_DURATION_SECONDS: 86400, DEFAULT_LANGUAGE: 'en', })); vi.mock('../../src/websocket', () => ({ broadcast: vi.fn(), broadcastToUser: vi.fn() })); diff --git a/server/tests/integration/bootstrap.test.ts b/server/tests/integration/bootstrap.test.ts index 53f69d05..7f32b2e4 100644 --- a/server/tests/integration/bootstrap.test.ts +++ b/server/tests/integration/bootstrap.test.ts @@ -38,6 +38,9 @@ vi.mock('../../src/config', () => ({ JWT_SECRET: 'test-jwt-secret-for-trek-testing-only', ENCRYPTION_KEY: 'a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2', updateJwtSecret: () => {}, + SESSION_DURATION: '24h', + SESSION_DURATION_MS: 86400000, + SESSION_DURATION_SECONDS: 86400, DEFAULT_LANGUAGE: 'en', })); vi.mock('../../src/websocket', () => ({ broadcast: vi.fn(), broadcastToUser: vi.fn() })); diff --git a/server/tests/integration/budget.test.ts b/server/tests/integration/budget.test.ts index 6c1208b9..c1a88cf6 100644 --- a/server/tests/integration/budget.test.ts +++ b/server/tests/integration/budget.test.ts @@ -36,6 +36,9 @@ vi.mock('../../src/config', () => ({ JWT_SECRET: 'test-jwt-secret-for-trek-testing-only', ENCRYPTION_KEY: 'a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2', updateJwtSecret: () => {}, + SESSION_DURATION: '24h', + SESSION_DURATION_MS: 86400000, + SESSION_DURATION_SECONDS: 86400, DEFAULT_LANGUAGE: 'en', })); vi.mock('../../src/websocket', () => ({ broadcast: vi.fn(), broadcastToUser: vi.fn() })); @@ -347,10 +350,12 @@ describe('Budget summary and settlement', () => { .put(`/api/trips/${trip.id}/budget/${item.id}/members`) .set('Cookie', authCookie(user.id)) .send({ user_ids: [user.id, user2.id] }); + // New model: who actually paid is recorded as an explicit payer (amount in + // the expense currency), not a per-member "paid" toggle. await request(app) - .put(`/api/trips/${trip.id}/budget/${item.id}/members/${user.id}/paid`) + .put(`/api/trips/${trip.id}/budget/${item.id}/payers`) .set('Cookie', authCookie(user.id)) - .send({ paid: true }); + .send({ payers: [{ user_id: user.id, amount: 60 }] }); const res = await request(app) .get(`/api/trips/${trip.id}/budget/settlement`) diff --git a/server/tests/integration/categories.test.ts b/server/tests/integration/categories.test.ts index c03dfe11..bc32a5c2 100644 --- a/server/tests/integration/categories.test.ts +++ b/server/tests/integration/categories.test.ts @@ -31,6 +31,9 @@ vi.mock('../../src/config', () => ({ JWT_SECRET: 'test-jwt-secret-for-trek-testing-only', ENCRYPTION_KEY: 'a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2', updateJwtSecret: () => {}, + SESSION_DURATION: '24h', + SESSION_DURATION_MS: 86400000, + SESSION_DURATION_SECONDS: 86400, DEFAULT_LANGUAGE: 'en', })); vi.mock('../../src/websocket', () => ({ broadcast: vi.fn(), broadcastToUser: vi.fn() })); diff --git a/server/tests/integration/collab.test.ts b/server/tests/integration/collab.test.ts index 75de00e9..4d1c359a 100644 --- a/server/tests/integration/collab.test.ts +++ b/server/tests/integration/collab.test.ts @@ -41,6 +41,9 @@ vi.mock('../../src/config', () => ({ JWT_SECRET: 'test-jwt-secret-for-trek-testing-only', ENCRYPTION_KEY: 'a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2', updateJwtSecret: () => {}, + SESSION_DURATION: '24h', + SESSION_DURATION_MS: 86400000, + SESSION_DURATION_SECONDS: 86400, DEFAULT_LANGUAGE: 'en', })); vi.mock('../../src/websocket', () => ({ broadcast: vi.fn(), broadcastToUser: vi.fn() })); diff --git a/server/tests/integration/dayNotes.test.ts b/server/tests/integration/dayNotes.test.ts index f9a02491..c82c0ee1 100644 --- a/server/tests/integration/dayNotes.test.ts +++ b/server/tests/integration/dayNotes.test.ts @@ -36,6 +36,9 @@ vi.mock('../../src/config', () => ({ JWT_SECRET: 'test-jwt-secret-for-trek-testing-only', ENCRYPTION_KEY: 'a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2', updateJwtSecret: () => {}, + SESSION_DURATION: '24h', + SESSION_DURATION_MS: 86400000, + SESSION_DURATION_SECONDS: 86400, DEFAULT_LANGUAGE: 'en', })); vi.mock('../../src/websocket', () => ({ broadcast: vi.fn(), broadcastToUser: vi.fn() })); diff --git a/server/tests/integration/days.test.ts b/server/tests/integration/days.test.ts index 47fce35a..e0bbaf6d 100644 --- a/server/tests/integration/days.test.ts +++ b/server/tests/integration/days.test.ts @@ -39,6 +39,9 @@ vi.mock('../../src/config', () => ({ JWT_SECRET: 'test-jwt-secret-for-trek-testing-only', ENCRYPTION_KEY: 'a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2', updateJwtSecret: () => {}, + SESSION_DURATION: '24h', + SESSION_DURATION_MS: 86400000, + SESSION_DURATION_SECONDS: 86400, DEFAULT_LANGUAGE: 'en', })); vi.mock('../../src/websocket', () => ({ broadcast: vi.fn(), broadcastToUser: vi.fn() })); diff --git a/server/tests/integration/files.test.ts b/server/tests/integration/files.test.ts index 4dd3c909..2acff907 100644 --- a/server/tests/integration/files.test.ts +++ b/server/tests/integration/files.test.ts @@ -43,6 +43,9 @@ vi.mock('../../src/config', () => ({ JWT_SECRET: 'test-jwt-secret-for-trek-testing-only', ENCRYPTION_KEY: 'a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2', updateJwtSecret: () => {}, + SESSION_DURATION: '24h', + SESSION_DURATION_MS: 86400000, + SESSION_DURATION_SECONDS: 86400, DEFAULT_LANGUAGE: 'en', })); vi.mock('../../src/websocket', () => ({ broadcast: vi.fn(), broadcastToUser: vi.fn() })); diff --git a/server/tests/integration/immich.test.ts b/server/tests/integration/immich.test.ts index d6c549d2..1c6a7845 100644 --- a/server/tests/integration/immich.test.ts +++ b/server/tests/integration/immich.test.ts @@ -39,6 +39,9 @@ vi.mock('../../src/config', () => ({ JWT_SECRET: 'test-jwt-secret-for-trek-testing-only', ENCRYPTION_KEY: 'a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2', updateJwtSecret: () => {}, + SESSION_DURATION: '24h', + SESSION_DURATION_MS: 86400000, + SESSION_DURATION_SECONDS: 86400, DEFAULT_LANGUAGE: 'en', })); vi.mock('../../src/websocket', () => ({ broadcast: vi.fn(), broadcastToUser: vi.fn() })); diff --git a/server/tests/integration/journey.test.ts b/server/tests/integration/journey.test.ts index 3d27388f..4cbfc65c 100644 --- a/server/tests/integration/journey.test.ts +++ b/server/tests/integration/journey.test.ts @@ -44,6 +44,9 @@ vi.mock('../../src/config', () => ({ JWT_SECRET: 'test-jwt-secret-for-trek-testing-only', ENCRYPTION_KEY: 'a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2', updateJwtSecret: () => {}, + SESSION_DURATION: '24h', + SESSION_DURATION_MS: 86400000, + SESSION_DURATION_SECONDS: 86400, DEFAULT_LANGUAGE: 'en', })); vi.mock('../../src/websocket', () => ({ diff --git a/server/tests/integration/maps.test.ts b/server/tests/integration/maps.test.ts index 47ebaa62..0b95e9d9 100644 --- a/server/tests/integration/maps.test.ts +++ b/server/tests/integration/maps.test.ts @@ -39,6 +39,9 @@ vi.mock('../../src/config', () => ({ JWT_SECRET: 'test-jwt-secret-for-trek-testing-only', ENCRYPTION_KEY: 'a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2', updateJwtSecret: () => {}, + SESSION_DURATION: '24h', + SESSION_DURATION_MS: 86400000, + SESSION_DURATION_SECONDS: 86400, DEFAULT_LANGUAGE: 'en', })); vi.mock('../../src/websocket', () => ({ broadcast: vi.fn(), broadcastToUser: vi.fn() })); diff --git a/server/tests/integration/mcp.test.ts b/server/tests/integration/mcp.test.ts index 5a59071f..ddf5c829 100644 --- a/server/tests/integration/mcp.test.ts +++ b/server/tests/integration/mcp.test.ts @@ -39,6 +39,9 @@ vi.mock('../../src/config', () => ({ JWT_SECRET: 'test-jwt-secret-for-trek-testing-only', ENCRYPTION_KEY: 'a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2', updateJwtSecret: () => {}, + SESSION_DURATION: '24h', + SESSION_DURATION_MS: 86400000, + SESSION_DURATION_SECONDS: 86400, DEFAULT_LANGUAGE: 'en', })); vi.mock('../../src/websocket', () => ({ broadcast: vi.fn(), broadcastToUser: vi.fn() })); diff --git a/server/tests/integration/memories-immich.test.ts b/server/tests/integration/memories-immich.test.ts index 666e0723..cfc31ca3 100644 --- a/server/tests/integration/memories-immich.test.ts +++ b/server/tests/integration/memories-immich.test.ts @@ -37,6 +37,9 @@ vi.mock('../../src/config', () => ({ JWT_SECRET: 'test-jwt-secret-for-trek-testing-only', ENCRYPTION_KEY: 'a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2', updateJwtSecret: () => {}, + SESSION_DURATION: '24h', + SESSION_DURATION_MS: 86400000, + SESSION_DURATION_SECONDS: 86400, DEFAULT_LANGUAGE: 'en', })); vi.mock('../../src/websocket', () => ({ broadcast: vi.fn(), broadcastToUser: vi.fn() })); diff --git a/server/tests/integration/memories-synology.test.ts b/server/tests/integration/memories-synology.test.ts index c277a9ef..9f0c260e 100644 --- a/server/tests/integration/memories-synology.test.ts +++ b/server/tests/integration/memories-synology.test.ts @@ -39,6 +39,9 @@ vi.mock('../../src/config', () => ({ JWT_SECRET: 'test-jwt-secret-for-trek-testing-only', ENCRYPTION_KEY: 'a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2', updateJwtSecret: () => {}, + SESSION_DURATION: '24h', + SESSION_DURATION_MS: 86400000, + SESSION_DURATION_SECONDS: 86400, DEFAULT_LANGUAGE: 'en', })); vi.mock('../../src/websocket', () => ({ broadcast: vi.fn(), broadcastToUser: vi.fn() })); diff --git a/server/tests/integration/memories-unified.test.ts b/server/tests/integration/memories-unified.test.ts index 4d3298a6..9865512b 100644 --- a/server/tests/integration/memories-unified.test.ts +++ b/server/tests/integration/memories-unified.test.ts @@ -37,6 +37,9 @@ vi.mock('../../src/config', () => ({ JWT_SECRET: 'test-jwt-secret-for-trek-testing-only', ENCRYPTION_KEY: 'a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2', updateJwtSecret: () => {}, + SESSION_DURATION: '24h', + SESSION_DURATION_MS: 86400000, + SESSION_DURATION_SECONDS: 86400, DEFAULT_LANGUAGE: 'en', })); vi.mock('../../src/websocket', () => ({ broadcast: vi.fn(), broadcastToUser: vi.fn() })); diff --git a/server/tests/integration/misc.test.ts b/server/tests/integration/misc.test.ts index 5b07982b..7966c2d8 100644 --- a/server/tests/integration/misc.test.ts +++ b/server/tests/integration/misc.test.ts @@ -36,6 +36,9 @@ vi.mock('../../src/config', () => ({ JWT_SECRET: 'test-jwt-secret-for-trek-testing-only', ENCRYPTION_KEY: 'a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2', updateJwtSecret: () => {}, + SESSION_DURATION: '24h', + SESSION_DURATION_MS: 86400000, + SESSION_DURATION_SECONDS: 86400, DEFAULT_LANGUAGE: 'en', })); vi.mock('../../src/websocket', () => ({ broadcast: vi.fn(), broadcastToUser: vi.fn() })); diff --git a/server/tests/integration/notifications.test.ts b/server/tests/integration/notifications.test.ts index 5d2df558..46ea0684 100644 --- a/server/tests/integration/notifications.test.ts +++ b/server/tests/integration/notifications.test.ts @@ -39,6 +39,9 @@ vi.mock('../../src/config', () => ({ JWT_SECRET: 'test-jwt-secret-for-trek-testing-only', ENCRYPTION_KEY: 'a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2', updateJwtSecret: () => {}, + SESSION_DURATION: '24h', + SESSION_DURATION_MS: 86400000, + SESSION_DURATION_SECONDS: 86400, DEFAULT_LANGUAGE: 'en', })); vi.mock('../../src/websocket', () => ({ broadcast: vi.fn(), broadcastToUser: vi.fn() })); diff --git a/server/tests/integration/oauth.test.ts b/server/tests/integration/oauth.test.ts index 4fc861fa..0736d6c0 100644 --- a/server/tests/integration/oauth.test.ts +++ b/server/tests/integration/oauth.test.ts @@ -38,6 +38,9 @@ vi.mock('../../src/config', () => ({ JWT_SECRET: 'test-jwt-secret-for-trek-testing-only', ENCRYPTION_KEY: 'a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2', updateJwtSecret: () => {}, + SESSION_DURATION: '24h', + SESSION_DURATION_MS: 86400000, + SESSION_DURATION_SECONDS: 86400, DEFAULT_LANGUAGE: 'en', })); diff --git a/server/tests/integration/oidc.test.ts b/server/tests/integration/oidc.test.ts index 017145a8..9d6b656a 100644 --- a/server/tests/integration/oidc.test.ts +++ b/server/tests/integration/oidc.test.ts @@ -35,6 +35,9 @@ vi.mock('../../src/config', () => ({ JWT_SECRET: 'test-jwt-secret-for-trek-testing-only', ENCRYPTION_KEY: 'a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2', updateJwtSecret: () => {}, + SESSION_DURATION: '24h', + SESSION_DURATION_MS: 86400000, + SESSION_DURATION_SECONDS: 86400, DEFAULT_LANGUAGE: 'en', })); vi.mock('../../src/websocket', () => ({ broadcast: vi.fn(), broadcastToUser: vi.fn() })); diff --git a/server/tests/integration/packing.test.ts b/server/tests/integration/packing.test.ts index c749319f..61f6e80c 100644 --- a/server/tests/integration/packing.test.ts +++ b/server/tests/integration/packing.test.ts @@ -36,6 +36,9 @@ vi.mock('../../src/config', () => ({ JWT_SECRET: 'test-jwt-secret-for-trek-testing-only', ENCRYPTION_KEY: 'a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2', updateJwtSecret: () => {}, + SESSION_DURATION: '24h', + SESSION_DURATION_MS: 86400000, + SESSION_DURATION_SECONDS: 86400, DEFAULT_LANGUAGE: 'en', })); vi.mock('../../src/websocket', () => ({ broadcast: vi.fn(), broadcastToUser: vi.fn() })); diff --git a/server/tests/integration/places.test.ts b/server/tests/integration/places.test.ts index f2206a6c..2e473abd 100644 --- a/server/tests/integration/places.test.ts +++ b/server/tests/integration/places.test.ts @@ -42,6 +42,9 @@ vi.mock('../../src/config', () => ({ JWT_SECRET: 'test-jwt-secret-for-trek-testing-only', ENCRYPTION_KEY: 'a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2', updateJwtSecret: () => {}, + SESSION_DURATION: '24h', + SESSION_DURATION_MS: 86400000, + SESSION_DURATION_SECONDS: 86400, DEFAULT_LANGUAGE: 'en', })); vi.mock('../../src/websocket', () => ({ broadcast: vi.fn(), broadcastToUser: vi.fn() })); diff --git a/server/tests/integration/profile.test.ts b/server/tests/integration/profile.test.ts index d77ae780..4a71a82c 100644 --- a/server/tests/integration/profile.test.ts +++ b/server/tests/integration/profile.test.ts @@ -37,6 +37,9 @@ vi.mock('../../src/config', () => ({ JWT_SECRET: 'test-jwt-secret-for-trek-testing-only', ENCRYPTION_KEY: 'a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2', updateJwtSecret: () => {}, + SESSION_DURATION: '24h', + SESSION_DURATION_MS: 86400000, + SESSION_DURATION_SECONDS: 86400, DEFAULT_LANGUAGE: 'en', })); vi.mock('../../src/websocket', () => ({ broadcast: vi.fn(), broadcastToUser: vi.fn() })); diff --git a/server/tests/integration/reservations.test.ts b/server/tests/integration/reservations.test.ts index ae7196f7..2e8dc0e3 100644 --- a/server/tests/integration/reservations.test.ts +++ b/server/tests/integration/reservations.test.ts @@ -36,6 +36,9 @@ vi.mock('../../src/config', () => ({ JWT_SECRET: 'test-jwt-secret-for-trek-testing-only', ENCRYPTION_KEY: 'a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2', updateJwtSecret: () => {}, + SESSION_DURATION: '24h', + SESSION_DURATION_MS: 86400000, + SESSION_DURATION_SECONDS: 86400, DEFAULT_LANGUAGE: 'en', })); vi.mock('../../src/websocket', () => ({ broadcast: vi.fn(), broadcastToUser: vi.fn() })); diff --git a/server/tests/integration/security.test.ts b/server/tests/integration/security.test.ts index f5ad1088..7745e3c1 100644 --- a/server/tests/integration/security.test.ts +++ b/server/tests/integration/security.test.ts @@ -43,6 +43,9 @@ vi.mock('../../src/config', () => ({ JWT_SECRET: 'test-jwt-secret-for-trek-testing-only', ENCRYPTION_KEY: 'a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2', updateJwtSecret: () => {}, + SESSION_DURATION: '24h', + SESSION_DURATION_MS: 86400000, + SESSION_DURATION_SECONDS: 86400, DEFAULT_LANGUAGE: 'en', })); vi.mock('../../src/websocket', () => ({ broadcast: vi.fn(), broadcastToUser: vi.fn() })); diff --git a/server/tests/integration/settings.test.ts b/server/tests/integration/settings.test.ts index d59c7827..0d393ddd 100644 --- a/server/tests/integration/settings.test.ts +++ b/server/tests/integration/settings.test.ts @@ -31,6 +31,9 @@ vi.mock('../../src/config', () => ({ JWT_SECRET: 'test-jwt-secret-for-trek-testing-only', ENCRYPTION_KEY: 'a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2', updateJwtSecret: () => {}, + SESSION_DURATION: '24h', + SESSION_DURATION_MS: 86400000, + SESSION_DURATION_SECONDS: 86400, DEFAULT_LANGUAGE: 'en', })); vi.mock('../../src/websocket', () => ({ broadcast: vi.fn(), broadcastToUser: vi.fn() })); diff --git a/server/tests/integration/share.test.ts b/server/tests/integration/share.test.ts index 032a68db..7cf8b68a 100644 --- a/server/tests/integration/share.test.ts +++ b/server/tests/integration/share.test.ts @@ -36,6 +36,9 @@ vi.mock('../../src/config', () => ({ JWT_SECRET: 'test-jwt-secret-for-trek-testing-only', ENCRYPTION_KEY: 'a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2', updateJwtSecret: () => {}, + SESSION_DURATION: '24h', + SESSION_DURATION_MS: 86400000, + SESSION_DURATION_SECONDS: 86400, DEFAULT_LANGUAGE: 'en', })); vi.mock('../../src/websocket', () => ({ broadcast: vi.fn(), broadcastToUser: vi.fn() })); diff --git a/server/tests/integration/systemNotices.test.ts b/server/tests/integration/systemNotices.test.ts index 853d5b3e..2f80816c 100644 --- a/server/tests/integration/systemNotices.test.ts +++ b/server/tests/integration/systemNotices.test.ts @@ -34,6 +34,9 @@ vi.mock('../../src/config', () => ({ JWT_SECRET: 'test-jwt-secret-for-trek-testing-only', ENCRYPTION_KEY: 'a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2', updateJwtSecret: () => {}, + SESSION_DURATION: '24h', + SESSION_DURATION_MS: 86400000, + SESSION_DURATION_SECONDS: 86400, DEFAULT_LANGUAGE: 'en', })); vi.mock('../../src/websocket', () => ({ broadcast: vi.fn(), broadcastToUser: vi.fn() })); diff --git a/server/tests/integration/tags.test.ts b/server/tests/integration/tags.test.ts index b0dc7ad9..6b7961ff 100644 --- a/server/tests/integration/tags.test.ts +++ b/server/tests/integration/tags.test.ts @@ -31,6 +31,9 @@ vi.mock('../../src/config', () => ({ JWT_SECRET: 'test-jwt-secret-for-trek-testing-only', ENCRYPTION_KEY: 'a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2', updateJwtSecret: () => {}, + SESSION_DURATION: '24h', + SESSION_DURATION_MS: 86400000, + SESSION_DURATION_SECONDS: 86400, DEFAULT_LANGUAGE: 'en', })); vi.mock('../../src/websocket', () => ({ broadcast: vi.fn(), broadcastToUser: vi.fn() })); diff --git a/server/tests/integration/todo.test.ts b/server/tests/integration/todo.test.ts index 7a3f25e3..efbcbde2 100644 --- a/server/tests/integration/todo.test.ts +++ b/server/tests/integration/todo.test.ts @@ -31,6 +31,9 @@ vi.mock('../../src/config', () => ({ JWT_SECRET: 'test-jwt-secret-for-trek-testing-only', ENCRYPTION_KEY: 'a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2', updateJwtSecret: () => {}, + SESSION_DURATION: '24h', + SESSION_DURATION_MS: 86400000, + SESSION_DURATION_SECONDS: 86400, DEFAULT_LANGUAGE: 'en', })); vi.mock('../../src/websocket', () => ({ broadcast: vi.fn(), broadcastToUser: vi.fn() })); diff --git a/server/tests/integration/trips.test.ts b/server/tests/integration/trips.test.ts index acf1c6ab..32694b71 100644 --- a/server/tests/integration/trips.test.ts +++ b/server/tests/integration/trips.test.ts @@ -44,6 +44,9 @@ vi.mock('../../src/config', () => ({ JWT_SECRET: 'test-jwt-secret-for-trek-testing-only', ENCRYPTION_KEY: 'a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2', updateJwtSecret: () => {}, + SESSION_DURATION: '24h', + SESSION_DURATION_MS: 86400000, + SESSION_DURATION_SECONDS: 86400, DEFAULT_LANGUAGE: 'en', })); vi.mock('../../src/websocket', () => ({ broadcast: vi.fn(), broadcastToUser: vi.fn() })); diff --git a/server/tests/integration/vacay.test.ts b/server/tests/integration/vacay.test.ts index 043db639..0eba19ab 100644 --- a/server/tests/integration/vacay.test.ts +++ b/server/tests/integration/vacay.test.ts @@ -36,6 +36,9 @@ vi.mock('../../src/config', () => ({ JWT_SECRET: 'test-jwt-secret-for-trek-testing-only', ENCRYPTION_KEY: 'a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2', updateJwtSecret: () => {}, + SESSION_DURATION: '24h', + SESSION_DURATION_MS: 86400000, + SESSION_DURATION_SECONDS: 86400, DEFAULT_LANGUAGE: 'en', })); vi.mock('../../src/websocket', () => ({ broadcast: vi.fn(), broadcastToUser: vi.fn() })); diff --git a/server/tests/unit/mcp/tools-budget.test.ts b/server/tests/unit/mcp/tools-budget.test.ts index 38e33b49..ff3424fa 100644 --- a/server/tests/unit/mcp/tools-budget.test.ts +++ b/server/tests/unit/mcp/tools-budget.test.ts @@ -88,7 +88,7 @@ describe('Tool: create_budget_item', () => { arguments: { tripId: trip.id, name: 'Misc', total_price: 10 }, }); const data = parseToolResult(result) as any; - expect(data.item.category).toBe('Other'); + expect(data.item.category).toBe('other'); }); }); diff --git a/server/tests/unit/services/budgetService.test.ts b/server/tests/unit/services/budgetService.test.ts index 93173684..c9ca9296 100644 --- a/server/tests/unit/services/budgetService.test.ts +++ b/server/tests/unit/services/budgetService.test.ts @@ -2,26 +2,14 @@ import { describe, it, expect, vi, beforeEach } from 'vitest'; // ── DB mock setup ──────────────────────────────────────────────────────────── -interface MockPrepared { - all: ReturnType; - get: ReturnType; - run: ReturnType; -} - -const preparedMap: Record = {}; -let defaultAll: ReturnType; -let defaultGet: ReturnType; - const mockDb = vi.hoisted(() => { return { db: { - prepare: vi.fn((sql: string) => { - return { - all: vi.fn(() => []), - get: vi.fn(() => undefined), - run: vi.fn(), - }; - }), + prepare: vi.fn(() => ({ + all: vi.fn(() => []), + get: vi.fn(() => undefined), + run: vi.fn(), + })), }, canAccessTrip: vi.fn(() => true), }; @@ -30,25 +18,29 @@ const mockDb = vi.hoisted(() => { vi.mock('../../../src/db/database', () => mockDb); import { calculateSettlement } from '../../../src/services/budgetService'; -import type { BudgetItem, BudgetItemMember } from '../../../src/types'; +import type { BudgetItem, BudgetItemMember, BudgetItemPayer } from '../../../src/types'; // ── Helpers ────────────────────────────────────────────────────────────────── +// Who actually paid is recorded as explicit payers (budget_item_payers); members +// are only the equal-split participants. function makeItem(id: number, total_price: number, trip_id = 1): BudgetItem { - return { id, trip_id, name: `Item ${id}`, total_price, category: 'Other' } as BudgetItem; + return { id, trip_id, name: `Item ${id}`, total_price, category: 'other' } as BudgetItem; } -function makeMember(budget_item_id: number, user_id: number, paid: boolean | 0 | 1, username: string): BudgetItemMember & { budget_item_id: number } { - return { - budget_item_id, - user_id, - paid: paid ? 1 : 0, - username, - avatar: null, - } as BudgetItemMember & { budget_item_id: number }; +function makeMember(budget_item_id: number, user_id: number, username: string): BudgetItemMember & { budget_item_id: number } { + return { budget_item_id, user_id, paid: 0, username, avatar: null } as BudgetItemMember & { budget_item_id: number }; } -function setupDb(items: BudgetItem[], members: (BudgetItemMember & { budget_item_id: number })[]) { +function makePayer(budget_item_id: number, user_id: number, amount: number, username: string): BudgetItemPayer & { budget_item_id: number } { + return { budget_item_id, user_id, amount, username, avatar: null } as BudgetItemPayer & { budget_item_id: number }; +} + +function setupDb( + items: BudgetItem[], + members: (BudgetItemMember & { budget_item_id: number })[], + payers: (BudgetItemPayer & { budget_item_id: number })[] = [], +) { mockDb.db.prepare.mockImplementation((sql: string) => { if (sql.includes('SELECT * FROM budget_items')) { return { all: vi.fn(() => items), get: vi.fn(), run: vi.fn() }; @@ -56,45 +48,51 @@ function setupDb(items: BudgetItem[], members: (BudgetItemMember & { budget_item if (sql.includes('budget_item_members')) { return { all: vi.fn(() => members), get: vi.fn(), run: vi.fn() }; } + if (sql.includes('budget_item_payers')) { + return { all: vi.fn(() => payers), get: vi.fn(), run: vi.fn() }; + } + // budget_settlements and anything else → empty return { all: vi.fn(() => []), get: vi.fn(), run: vi.fn() }; }); } beforeEach(() => { vi.clearAllMocks(); - setupDb([], []); + setupDb([], [], []); }); // ── calculateSettlement ────────────────────────────────────────────────────── describe('calculateSettlement', () => { it('returns empty balances and flows when trip has no items', () => { - setupDb([], []); + setupDb([], [], []); const result = calculateSettlement(1); expect(result.balances).toEqual([]); expect(result.flows).toEqual([]); }); it('returns no flows when there are items but no members', () => { - setupDb([makeItem(1, 100)], []); + setupDb([makeItem(1, 100)], [], [makePayer(1, 1, 100, 'alice')]); const result = calculateSettlement(1); expect(result.flows).toEqual([]); }); - it('returns no flows when no one is marked as paid', () => { + it('returns no flows when no one has paid', () => { setupDb( [makeItem(1, 100)], - [makeMember(1, 1, 0, 'alice'), makeMember(1, 2, 0, 'bob')], + [makeMember(1, 1, 'alice'), makeMember(1, 2, 'bob')], + [], ); const result = calculateSettlement(1); expect(result.flows).toEqual([]); }); it('2 members, 1 payer: payer is owed half, non-payer owes half', () => { - // Item: $100. Alice paid, Bob did not. Each owes $50. Alice net: +$50. Bob net: -$50. + // Item: $100. Alice paid all, [Alice, Bob] split. Each owes $50. Alice net: +$50. Bob: -$50. setupDb( [makeItem(1, 100)], - [makeMember(1, 1, 1, 'alice'), makeMember(1, 2, 0, 'bob')], + [makeMember(1, 1, 'alice'), makeMember(1, 2, 'bob')], + [makePayer(1, 1, 100, 'alice')], ); const result = calculateSettlement(1); const alice = result.balances.find(b => b.user_id === 1)!; @@ -111,7 +109,8 @@ describe('calculateSettlement', () => { // Item: $90. Alice paid. Each of 3 owes $30. Alice net: +$60. Bob: -$30. Carol: -$30. setupDb( [makeItem(1, 90)], - [makeMember(1, 1, 1, 'alice'), makeMember(1, 2, 0, 'bob'), makeMember(1, 3, 0, 'carol')], + [makeMember(1, 1, 'alice'), makeMember(1, 2, 'bob'), makeMember(1, 3, 'carol')], + [makePayer(1, 1, 90, 'alice')], ); const result = calculateSettlement(1); const alice = result.balances.find(b => b.user_id === 1)!; @@ -124,12 +123,11 @@ describe('calculateSettlement', () => { }); it('all paid equally: all balances are zero, no flows', () => { - // Item: $60. 3 members, all paid equally (each paid $20, each owes $20). Net: 0. - // Actually with "paid" flag it means: paidPerPayer = item.total / numPayers. - // If all 3 paid: each gets +20 credit, each owes -20 = net 0 for everyone. + // Item: $60. 3 members, each paid $20 and owes $20. Net: 0 for everyone. setupDb( [makeItem(1, 60)], - [makeMember(1, 1, 1, 'alice'), makeMember(1, 2, 1, 'bob'), makeMember(1, 3, 1, 'carol')], + [makeMember(1, 1, 'alice'), makeMember(1, 2, 'bob'), makeMember(1, 3, 'carol')], + [makePayer(1, 1, 20, 'alice'), makePayer(1, 2, 20, 'bob'), makePayer(1, 3, 20, 'carol')], ); const result = calculateSettlement(1); for (const b of result.balances) { @@ -142,7 +140,8 @@ describe('calculateSettlement', () => { // Alice paid $100 for 2 people. Bob owes Alice $50. setupDb( [makeItem(1, 100)], - [makeMember(1, 1, 1, 'alice'), makeMember(1, 2, 0, 'bob')], + [makeMember(1, 1, 'alice'), makeMember(1, 2, 'bob')], + [makePayer(1, 1, 100, 'alice')], ); const result = calculateSettlement(1); const flow = result.flows[0]; @@ -154,7 +153,8 @@ describe('calculateSettlement', () => { // Item: $10. 3 members, 1 payer. Share = 3.333... Each rounded to 3.33. setupDb( [makeItem(1, 10)], - [makeMember(1, 1, 1, 'alice'), makeMember(1, 2, 0, 'bob'), makeMember(1, 3, 0, 'carol')], + [makeMember(1, 1, 'alice'), makeMember(1, 2, 'bob'), makeMember(1, 3, 'carol')], + [makePayer(1, 1, 10, 'alice')], ); const result = calculateSettlement(1); for (const b of result.balances) { @@ -176,9 +176,10 @@ describe('calculateSettlement', () => { setupDb( [makeItem(1, 100), makeItem(2, 60)], [ - makeMember(1, 1, 1, 'alice'), makeMember(1, 2, 0, 'bob'), - makeMember(2, 1, 0, 'alice'), makeMember(2, 2, 1, 'bob'), + makeMember(1, 1, 'alice'), makeMember(1, 2, 'bob'), + makeMember(2, 1, 'alice'), makeMember(2, 2, 'bob'), ], + [makePayer(1, 1, 100, 'alice'), makePayer(2, 2, 60, 'bob')], ); const result = calculateSettlement(1); const alice = result.balances.find(b => b.user_id === 1)!; diff --git a/server/tests/websocket/connection.test.ts b/server/tests/websocket/connection.test.ts index 917777c6..bf177149 100644 --- a/server/tests/websocket/connection.test.ts +++ b/server/tests/websocket/connection.test.ts @@ -39,6 +39,9 @@ vi.mock('../../src/config', () => ({ JWT_SECRET: 'test-jwt-secret-for-trek-testing-only', ENCRYPTION_KEY: 'a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2', updateJwtSecret: () => {}, + SESSION_DURATION: '24h', + SESSION_DURATION_MS: 86400000, + SESSION_DURATION_SECONDS: 86400, DEFAULT_LANGUAGE: 'en', })); diff --git a/shared/src/budget/budget.schema.ts b/shared/src/budget/budget.schema.ts index b12b1208..717915bf 100644 --- a/shared/src/budget/budget.schema.ts +++ b/shared/src/budget/budget.schema.ts @@ -27,10 +27,49 @@ export const budgetItemMemberSchema = z.object({ }); export type BudgetItemMember = z.infer; +/** + * The fixed "Costs" expense categories. Unlike the old budget, users cannot + * create their own categories — every expense maps to one of these keys. The + * label/icon/colour per key live in the client; the server only stores the key. + * Pre-rework rows used free-text categories; those are shown as `other`. + */ +export const COST_CATEGORIES = [ + 'accommodation', + 'food', + 'groceries', + 'transport', + 'flights', + 'activities', + 'sightseeing', + 'shopping', + 'fees', + 'health', + 'tips', + 'other', +] as const; +export type CostCategory = (typeof COST_CATEGORIES)[number]; + +/** + * One payer of an expense — a row of budget_item_payers. `amount` is in the + * expense's own currency (budget_items.currency). Several payers can split who + * actually paid one bill. Username/avatar are joined for display. + */ +export const budgetItemPayerSchema = z.object({ + user_id: z.number(), + amount: z.number(), + username: z.string().optional(), + avatar_url: z.string().nullable().optional(), + avatar: z.string().nullable().optional(), + budget_item_id: z.number().optional(), +}); +export type BudgetItemPayer = z.infer; + /** * Budget item entity as returned by the budget list/create/update endpoints * (server/src/services/budgetService.ts). Columns of the `budget_items` table - * plus the embedded `members` array. total_price is SQLite REAL. + * plus the embedded `members` (equal-split participants) and `payers` arrays. + * total_price is the sum of payer amounts in `currency`; `exchange_rate` converts + * that to the trip base currency (NULL currency + rate 1 = base currency). */ export const budgetItemSchema = z.object({ id: z.number(), @@ -38,6 +77,8 @@ export const budgetItemSchema = z.object({ category: z.string(), name: z.string(), total_price: z.number(), + currency: z.string().nullable().optional(), + exchange_rate: z.number().optional(), persons: z.number().nullable().optional(), days: z.number().nullable().optional(), note: z.string().nullable().optional(), @@ -47,13 +88,26 @@ export const budgetItemSchema = z.object({ sort_order: z.number().optional(), created_at: z.string().optional(), members: z.array(budgetItemMemberSchema).optional(), + payers: z.array(budgetItemPayerSchema).optional(), }); export type BudgetItem = z.infer; +const payerInputSchema = z.object({ + user_id: z.number(), + amount: z.number(), +}); + export const budgetCreateItemRequestSchema = z.object({ name: z.string().min(1), category: z.string().optional(), total_price: z.number().optional(), + currency: z.string().nullable().optional(), + exchange_rate: z.number().optional(), + // Multi-payer: who paid how much (in the expense currency). When omitted, the + // server falls back to total_price with no explicit payer. + payers: z.array(payerInputSchema).optional(), + // Equal-split participants. When omitted, the item has no split (planning-only). + member_ids: z.array(z.number()).optional(), persons: z.number().nullable().optional(), days: z.number().nullable().optional(), note: z.string().nullable().optional(), @@ -68,6 +122,10 @@ export const budgetUpdateItemRequestSchema = z.object({ name: z.string().optional(), category: z.string().optional(), total_price: z.number().optional(), + currency: z.string().nullable().optional(), + exchange_rate: z.number().optional(), + payers: z.array(payerInputSchema).optional(), + member_ids: z.array(z.number()).optional(), persons: z.number().nullable().optional(), days: z.number().nullable().optional(), note: z.string().nullable().optional(), @@ -77,6 +135,43 @@ export type BudgetUpdateItemRequest = z.infer< typeof budgetUpdateItemRequestSchema >; +/** Replace the explicit payers of an expense (amounts in expense currency). */ +export const budgetUpdatePayersRequestSchema = z.object({ + payers: z.array(payerInputSchema), +}); +export type BudgetUpdatePayersRequest = z.infer< + typeof budgetUpdatePayersRequestSchema +>; + +/** + * A persisted settle-up transfer (budget_settlements row): "from paid to" a + * given amount in the trip base currency. Creating one marks a suggested flow as + * paid; deleting it (undo) brings the flow back. Names joined for display. + */ +export const budgetSettlementSchema = z.object({ + id: z.number(), + trip_id: z.number(), + from_user_id: z.number(), + to_user_id: z.number(), + amount: z.number(), + created_at: z.string().optional(), + created_by_user_id: z.number().nullable().optional(), + from_username: z.string().optional(), + from_avatar_url: z.string().nullable().optional(), + to_username: z.string().optional(), + to_avatar_url: z.string().nullable().optional(), +}); +export type BudgetSettlement = z.infer; + +export const budgetCreateSettlementRequestSchema = z.object({ + from_user_id: z.number(), + to_user_id: z.number(), + amount: z.number(), +}); +export type BudgetCreateSettlementRequest = z.infer< + typeof budgetCreateSettlementRequestSchema +>; + export const budgetUpdateMembersRequestSchema = z.object({ user_ids: z.array(z.number()), }); diff --git a/shared/src/i18n/ar/budget.ts b/shared/src/i18n/ar/budget.ts index 6ee60f0c..0e9a8989 100644 --- a/shared/src/i18n/ar/budget.ts +++ b/shared/src/i18n/ar/budget.ts @@ -38,5 +38,78 @@ const budget: TranslationStrings = { 'انقر على صورة العضو في بند الميزانية لتحديده باللون الأخضر — وهذا يعني أنه دفع. ثم تُظهر التسوية من يدين لمن وبكم.', 'budget.netBalances': 'الأرصدة الصافية', 'budget.categoriesLabel': 'فئات', + "costs.you": "You", + "costs.youShort": "Y", + "costs.youLower": "you", + "costs.youOwe": "You owe", + "costs.youOweSub": "You should pay others", + "costs.youreOwed": "You're owed", + "costs.youreOwedSub": "Others should pay you", + "costs.totalSpend": "Total trip spend", + "costs.totalSpendSub": "Across all travelers", + "costs.to": "To", + "costs.from": "From", + "costs.allSettled": "You're all settled up", + "costs.nothingOwed": "Nothing owed to you", + "costs.yourShare": "Your share", + "costs.youPaid": "You paid", + "costs.expenses": "Expenses", + "costs.entries": "{count} entries", + "costs.searchPlaceholder": "Search expenses…", + "costs.filter.all": "All", + "costs.filter.mine": "Paid by me", + "costs.filter.owed": "I'm owed", + "costs.addExpense": "Add expense", + "costs.editExpense": "Edit expense", + "costs.noMatch": "No expenses match your search.", + "costs.emptyText": "No expenses yet. Add your first one.", + "costs.spent": "{amount} spent", + "costs.noDate": "No date", + "costs.noOnePaid": "No one paid yet", + "costs.youLent": "you lent {amount}", + "costs.youBorrowed": "you borrowed {amount}", + "costs.settleUp": "Settle up", + "costs.history": "History", + "costs.everyoneSquare": "Everyone's square", + "costs.nothingOutstanding": "No payments outstanding right now.", + "costs.pay": "pay", + "costs.pays": "pays", + "costs.settle": "Settle", + "costs.balances": "Balances", + "costs.byCategory": "By category", + "costs.noCategories": "No expenses yet.", + "costs.settleHistory": "Settle history", + "costs.noSettlements": "No settled payments yet.", + "costs.paymentsSettled": "{count} payments settled", + "costs.paid": "paid", + "costs.undo": "Undo", + "costs.whatFor": "What was it for?", + "costs.namePlaceholder": "e.g. Dinner, souvenirs, gas…", + "costs.totalAmount": "Total amount", + "costs.currency": "Currency", + "costs.day": "Day", + "costs.rateLabel": "1 {from} in {to}", + "costs.category": "Category", + "costs.whoPaid": "Who paid?", + "costs.splitBetween": "Split equally between", + "costs.pickSomeone": "Pick at least one person to split with.", + "costs.splitSummary": "Split {count} ways · {amount} each", + "costs.cat.accommodation": "Accommodation", + "costs.cat.food": "Food & drink", + "costs.cat.groceries": "Groceries", + "costs.cat.transport": "Transport", + "costs.cat.flights": "Flights", + "costs.cat.activities": "Activities", + "costs.cat.sightseeing": "Sightseeing", + "costs.cat.shopping": "Shopping", + "costs.cat.fees": "Fees & tickets", + "costs.cat.health": "Health", + "costs.cat.tips": "Tips", + "costs.cat.other": "Other", + "costs.daysCount": "{count} days", + "costs.travelers": "{count} travelers", + "costs.liveRate": "live rate", + "costs.settleAll": "Settle all", }; + export default budget; diff --git a/shared/src/i18n/ar/settings.ts b/shared/src/i18n/ar/settings.ts index 2f5b9409..45415afb 100644 --- a/shared/src/i18n/ar/settings.ts +++ b/shared/src/i18n/ar/settings.ts @@ -288,5 +288,8 @@ const settings: TranslationStrings = { 'Business Class Dreamer', // en-fallback 'settings.about.supporter.tier.budgetTraveller': 'Budget Traveller', // en-fallback 'settings.about.supporter.tier.hostelBunkmate': 'Hostel Bunkmate', // en-fallback + "settings.currency": "Currency", + "settings.currencyHint": "All amounts in Costs are converted to and shown in this currency.", }; + export default settings; diff --git a/shared/src/i18n/ar/trip.ts b/shared/src/i18n/ar/trip.ts index f4227d26..9b1a0348 100644 --- a/shared/src/i18n/ar/trip.ts +++ b/shared/src/i18n/ar/trip.ts @@ -9,7 +9,7 @@ const trip: TranslationStrings = { 'trip.tabs.packingShort': 'تجهيز', 'trip.tabs.lists': 'القوائم', 'trip.tabs.listsShort': 'القوائم', - 'trip.tabs.budget': 'الميزانية', + 'trip.tabs.budget': "Costs", 'trip.tabs.files': 'الملفات', 'trip.loading': 'جارٍ تحميل الرحلة...', 'trip.loadingPhotos': 'جارٍ تحميل صور الأماكن...', diff --git a/shared/src/i18n/br/budget.ts b/shared/src/i18n/br/budget.ts index bd3d66fc..c280e442 100644 --- a/shared/src/i18n/br/budget.ts +++ b/shared/src/i18n/br/budget.ts @@ -39,5 +39,78 @@ const budget: TranslationStrings = { 'Clique no avatar de um membro em um item do orçamento para marcá-lo em verde — significa que ele pagou. O acerto mostra quem deve quanto a quem.', 'budget.netBalances': 'Saldos líquidos', 'budget.categoriesLabel': 'categorias', + "costs.you": "You", + "costs.youShort": "Y", + "costs.youLower": "you", + "costs.youOwe": "You owe", + "costs.youOweSub": "You should pay others", + "costs.youreOwed": "You're owed", + "costs.youreOwedSub": "Others should pay you", + "costs.totalSpend": "Total trip spend", + "costs.totalSpendSub": "Across all travelers", + "costs.to": "To", + "costs.from": "From", + "costs.allSettled": "You're all settled up", + "costs.nothingOwed": "Nothing owed to you", + "costs.yourShare": "Your share", + "costs.youPaid": "You paid", + "costs.expenses": "Expenses", + "costs.entries": "{count} entries", + "costs.searchPlaceholder": "Search expenses…", + "costs.filter.all": "All", + "costs.filter.mine": "Paid by me", + "costs.filter.owed": "I'm owed", + "costs.addExpense": "Add expense", + "costs.editExpense": "Edit expense", + "costs.noMatch": "No expenses match your search.", + "costs.emptyText": "No expenses yet. Add your first one.", + "costs.spent": "{amount} spent", + "costs.noDate": "No date", + "costs.noOnePaid": "No one paid yet", + "costs.youLent": "you lent {amount}", + "costs.youBorrowed": "you borrowed {amount}", + "costs.settleUp": "Settle up", + "costs.history": "History", + "costs.everyoneSquare": "Everyone's square", + "costs.nothingOutstanding": "No payments outstanding right now.", + "costs.pay": "pay", + "costs.pays": "pays", + "costs.settle": "Settle", + "costs.balances": "Balances", + "costs.byCategory": "By category", + "costs.noCategories": "No expenses yet.", + "costs.settleHistory": "Settle history", + "costs.noSettlements": "No settled payments yet.", + "costs.paymentsSettled": "{count} payments settled", + "costs.paid": "paid", + "costs.undo": "Undo", + "costs.whatFor": "What was it for?", + "costs.namePlaceholder": "e.g. Dinner, souvenirs, gas…", + "costs.totalAmount": "Total amount", + "costs.currency": "Currency", + "costs.day": "Day", + "costs.rateLabel": "1 {from} in {to}", + "costs.category": "Category", + "costs.whoPaid": "Who paid?", + "costs.splitBetween": "Split equally between", + "costs.pickSomeone": "Pick at least one person to split with.", + "costs.splitSummary": "Split {count} ways · {amount} each", + "costs.cat.accommodation": "Accommodation", + "costs.cat.food": "Food & drink", + "costs.cat.groceries": "Groceries", + "costs.cat.transport": "Transport", + "costs.cat.flights": "Flights", + "costs.cat.activities": "Activities", + "costs.cat.sightseeing": "Sightseeing", + "costs.cat.shopping": "Shopping", + "costs.cat.fees": "Fees & tickets", + "costs.cat.health": "Health", + "costs.cat.tips": "Tips", + "costs.cat.other": "Other", + "costs.daysCount": "{count} days", + "costs.travelers": "{count} travelers", + "costs.liveRate": "live rate", + "costs.settleAll": "Settle all", }; + export default budget; diff --git a/shared/src/i18n/br/settings.ts b/shared/src/i18n/br/settings.ts index dfda0a8e..6248906b 100644 --- a/shared/src/i18n/br/settings.ts +++ b/shared/src/i18n/br/settings.ts @@ -294,5 +294,8 @@ const settings: TranslationStrings = { 'settings.notificationPreferences.webhook': 'Webhook', 'settings.notificationPreferences.email': 'Email', 'settings.notificationPreferences.ntfy': 'Ntfy', + "settings.currency": "Currency", + "settings.currencyHint": "All amounts in Costs are converted to and shown in this currency.", }; + export default settings; diff --git a/shared/src/i18n/br/trip.ts b/shared/src/i18n/br/trip.ts index d725a110..62e740ca 100644 --- a/shared/src/i18n/br/trip.ts +++ b/shared/src/i18n/br/trip.ts @@ -9,7 +9,7 @@ const trip: TranslationStrings = { 'trip.tabs.packingShort': 'Mala', 'trip.tabs.lists': 'Listas', 'trip.tabs.listsShort': 'Listas', - 'trip.tabs.budget': 'Orçamento', + 'trip.tabs.budget': "Costs", 'trip.tabs.files': 'Arquivos', 'trip.loading': 'Carregando viagem...', 'trip.mobilePlan': 'Plano', diff --git a/shared/src/i18n/cs/budget.ts b/shared/src/i18n/cs/budget.ts index 0c8d8c62..a7e292ea 100644 --- a/shared/src/i18n/cs/budget.ts +++ b/shared/src/i18n/cs/budget.ts @@ -39,5 +39,78 @@ const budget: TranslationStrings = { 'Klikněte na avatar člena u rozpočtové položky pro zelené označení – to znamená, že zaplatil. Vyúčtování pak ukazuje, kdo komu a kolik dluží.', 'budget.netBalances': 'Čisté zůstatky', 'budget.categoriesLabel': 'kategorie', + "costs.you": "You", + "costs.youShort": "Y", + "costs.youLower": "you", + "costs.youOwe": "You owe", + "costs.youOweSub": "You should pay others", + "costs.youreOwed": "You're owed", + "costs.youreOwedSub": "Others should pay you", + "costs.totalSpend": "Total trip spend", + "costs.totalSpendSub": "Across all travelers", + "costs.to": "To", + "costs.from": "From", + "costs.allSettled": "You're all settled up", + "costs.nothingOwed": "Nothing owed to you", + "costs.yourShare": "Your share", + "costs.youPaid": "You paid", + "costs.expenses": "Expenses", + "costs.entries": "{count} entries", + "costs.searchPlaceholder": "Search expenses…", + "costs.filter.all": "All", + "costs.filter.mine": "Paid by me", + "costs.filter.owed": "I'm owed", + "costs.addExpense": "Add expense", + "costs.editExpense": "Edit expense", + "costs.noMatch": "No expenses match your search.", + "costs.emptyText": "No expenses yet. Add your first one.", + "costs.spent": "{amount} spent", + "costs.noDate": "No date", + "costs.noOnePaid": "No one paid yet", + "costs.youLent": "you lent {amount}", + "costs.youBorrowed": "you borrowed {amount}", + "costs.settleUp": "Settle up", + "costs.history": "History", + "costs.everyoneSquare": "Everyone's square", + "costs.nothingOutstanding": "No payments outstanding right now.", + "costs.pay": "pay", + "costs.pays": "pays", + "costs.settle": "Settle", + "costs.balances": "Balances", + "costs.byCategory": "By category", + "costs.noCategories": "No expenses yet.", + "costs.settleHistory": "Settle history", + "costs.noSettlements": "No settled payments yet.", + "costs.paymentsSettled": "{count} payments settled", + "costs.paid": "paid", + "costs.undo": "Undo", + "costs.whatFor": "What was it for?", + "costs.namePlaceholder": "e.g. Dinner, souvenirs, gas…", + "costs.totalAmount": "Total amount", + "costs.currency": "Currency", + "costs.day": "Day", + "costs.rateLabel": "1 {from} in {to}", + "costs.category": "Category", + "costs.whoPaid": "Who paid?", + "costs.splitBetween": "Split equally between", + "costs.pickSomeone": "Pick at least one person to split with.", + "costs.splitSummary": "Split {count} ways · {amount} each", + "costs.cat.accommodation": "Accommodation", + "costs.cat.food": "Food & drink", + "costs.cat.groceries": "Groceries", + "costs.cat.transport": "Transport", + "costs.cat.flights": "Flights", + "costs.cat.activities": "Activities", + "costs.cat.sightseeing": "Sightseeing", + "costs.cat.shopping": "Shopping", + "costs.cat.fees": "Fees & tickets", + "costs.cat.health": "Health", + "costs.cat.tips": "Tips", + "costs.cat.other": "Other", + "costs.daysCount": "{count} days", + "costs.travelers": "{count} travelers", + "costs.liveRate": "live rate", + "costs.settleAll": "Settle all", }; + export default budget; diff --git a/shared/src/i18n/cs/settings.ts b/shared/src/i18n/cs/settings.ts index f7b9deae..ee7502e8 100644 --- a/shared/src/i18n/cs/settings.ts +++ b/shared/src/i18n/cs/settings.ts @@ -295,5 +295,8 @@ const settings: TranslationStrings = { 'settings.notificationPreferences.webhook': 'Webhook', 'settings.notificationPreferences.email': 'Email', 'settings.notificationPreferences.ntfy': 'Ntfy', + "settings.currency": "Currency", + "settings.currencyHint": "All amounts in Costs are converted to and shown in this currency.", }; + export default settings; diff --git a/shared/src/i18n/cs/trip.ts b/shared/src/i18n/cs/trip.ts index 24307285..31214c9a 100644 --- a/shared/src/i18n/cs/trip.ts +++ b/shared/src/i18n/cs/trip.ts @@ -9,7 +9,7 @@ const trip: TranslationStrings = { 'trip.tabs.packingShort': 'Balení', 'trip.tabs.lists': 'Seznamy', 'trip.tabs.listsShort': 'Seznamy', - 'trip.tabs.budget': 'Rozpočet', + 'trip.tabs.budget': "Costs", 'trip.tabs.files': 'Soubory', 'trip.loading': 'Načítání cesty...', 'trip.loadingPhotos': 'Načítání fotek míst...', diff --git a/shared/src/i18n/de/budget.ts b/shared/src/i18n/de/budget.ts index feae9cff..8cd2a6ef 100644 --- a/shared/src/i18n/de/budget.ts +++ b/shared/src/i18n/de/budget.ts @@ -40,5 +40,78 @@ const budget: TranslationStrings = { 'Klicke auf ein Mitglied-Bild bei einem Eintrag, um es grün zu markieren — das bedeutet, diese Person hat bezahlt. Der Ausgleich zeigt dann, wer wem wie viel schuldet.', 'budget.netBalances': 'Netto-Salden', 'budget.categoriesLabel': 'Kategorien', + "costs.you": "Du", + "costs.youShort": "Du", + "costs.youLower": "dir", + "costs.youOwe": "Du schuldest", + "costs.youOweSub": "Du solltest anderen zahlen", + "costs.youreOwed": "Dir wird geschuldet", + "costs.youreOwedSub": "Andere sollten dir zahlen", + "costs.totalSpend": "Gesamtausgaben", + "costs.totalSpendSub": "Über alle Reisenden", + "costs.to": "An", + "costs.from": "Von", + "costs.allSettled": "Alles ausgeglichen", + "costs.nothingOwed": "Dir wird nichts geschuldet", + "costs.yourShare": "Dein Anteil", + "costs.youPaid": "Du zahltest", + "costs.expenses": "Ausgaben", + "costs.entries": "{count} Einträge", + "costs.searchPlaceholder": "Ausgaben suchen…", + "costs.filter.all": "Alle", + "costs.filter.mine": "Von mir bezahlt", + "costs.filter.owed": "Mir geschuldet", + "costs.addExpense": "Ausgabe hinzufügen", + "costs.editExpense": "Ausgabe bearbeiten", + "costs.noMatch": "Keine Ausgaben passen zur Suche.", + "costs.emptyText": "Noch keine Ausgaben. Füge die erste hinzu.", + "costs.spent": "{amount} ausgegeben", + "costs.noDate": "Kein Datum", + "costs.noOnePaid": "Noch niemand bezahlt", + "costs.youLent": "{amount} ausgelegt", + "costs.youBorrowed": "{amount} geliehen", + "costs.settleUp": "Ausgleichen", + "costs.history": "Verlauf", + "costs.everyoneSquare": "Alle quitt", + "costs.nothingOutstanding": "Aktuell keine offenen Zahlungen.", + "costs.pay": "zahlst", + "costs.pays": "zahlt", + "costs.settle": "Ausgleichen", + "costs.balances": "Salden", + "costs.byCategory": "Nach Kategorie", + "costs.noCategories": "Noch keine Ausgaben.", + "costs.settleHistory": "Ausgleichs-Verlauf", + "costs.noSettlements": "Noch keine ausgeglichenen Zahlungen.", + "costs.paymentsSettled": "{count} Zahlungen ausgeglichen", + "costs.paid": "zahlte", + "costs.undo": "Rückgängig", + "costs.whatFor": "Wofür war es?", + "costs.namePlaceholder": "z.B. Abendessen, Souvenirs, Benzin…", + "costs.totalAmount": "Gesamtbetrag", + "costs.currency": "Währung", + "costs.day": "Tag", + "costs.rateLabel": "1 {from} in {to}", + "costs.category": "Kategorie", + "costs.whoPaid": "Wer hat bezahlt?", + "costs.splitBetween": "Gleichmäßig teilen zwischen", + "costs.pickSomeone": "Wähle mindestens eine Person zum Teilen.", + "costs.splitSummary": "Auf {count} aufgeteilt · {amount} pro Person", + "costs.cat.accommodation": "Unterkunft", + "costs.cat.food": "Essen & Trinken", + "costs.cat.groceries": "Lebensmittel", + "costs.cat.transport": "Transport", + "costs.cat.flights": "Flüge", + "costs.cat.activities": "Aktivitäten", + "costs.cat.sightseeing": "Sightseeing", + "costs.cat.shopping": "Shopping", + "costs.cat.fees": "Gebühren & Tickets", + "costs.cat.health": "Gesundheit", + "costs.cat.tips": "Trinkgeld", + "costs.cat.other": "Sonstiges", + "costs.daysCount": "{count} Tage", + "costs.travelers": "{count} Reisende", + "costs.liveRate": "Live-Kurs", + "costs.settleAll": "Alle ausgleichen", }; + export default budget; diff --git a/shared/src/i18n/de/settings.ts b/shared/src/i18n/de/settings.ts index e62d9da7..a1d0df6b 100644 --- a/shared/src/i18n/de/settings.ts +++ b/shared/src/i18n/de/settings.ts @@ -298,5 +298,8 @@ const settings: TranslationStrings = { 'settings.notificationPreferences.webhook': 'Webhook', 'settings.notificationPreferences.email': 'Email', 'settings.notificationPreferences.ntfy': 'Ntfy', + "settings.currency": "Währung", + "settings.currencyHint": "Alle Beträge in Costs werden in diese Währung umgerechnet und angezeigt.", }; + export default settings; diff --git a/shared/src/i18n/de/trip.ts b/shared/src/i18n/de/trip.ts index 10373723..0dccb764 100644 --- a/shared/src/i18n/de/trip.ts +++ b/shared/src/i18n/de/trip.ts @@ -9,7 +9,7 @@ const trip: TranslationStrings = { 'trip.tabs.packingShort': 'Liste', 'trip.tabs.lists': 'Listen', 'trip.tabs.listsShort': 'Listen', - 'trip.tabs.budget': 'Budget', + 'trip.tabs.budget': "Kosten", 'trip.tabs.files': 'Dateien', 'trip.loading': 'Reise wird geladen...', 'trip.loadingPhotos': 'Fotos der Orte werden geladen...', diff --git a/shared/src/i18n/en/budget.ts b/shared/src/i18n/en/budget.ts index 733abca5..310ea986 100644 --- a/shared/src/i18n/en/budget.ts +++ b/shared/src/i18n/en/budget.ts @@ -39,5 +39,78 @@ const budget: TranslationStrings = { 'Click a member avatar on a budget item to mark them green — this means they paid. The settlement then shows who owes whom and how much.', 'budget.netBalances': 'Net Balances', 'budget.categoriesLabel': 'categories', + "costs.you": "You", + "costs.youShort": "Y", + "costs.youLower": "you", + "costs.youOwe": "You owe", + "costs.youOweSub": "You should pay others", + "costs.youreOwed": "You're owed", + "costs.youreOwedSub": "Others should pay you", + "costs.totalSpend": "Total trip spend", + "costs.totalSpendSub": "Across all travelers", + "costs.to": "To", + "costs.from": "From", + "costs.allSettled": "You're all settled up", + "costs.nothingOwed": "Nothing owed to you", + "costs.yourShare": "Your share", + "costs.youPaid": "You paid", + "costs.expenses": "Expenses", + "costs.entries": "{count} entries", + "costs.searchPlaceholder": "Search expenses…", + "costs.filter.all": "All", + "costs.filter.mine": "Paid by me", + "costs.filter.owed": "I'm owed", + "costs.addExpense": "Add expense", + "costs.editExpense": "Edit expense", + "costs.noMatch": "No expenses match your search.", + "costs.emptyText": "No expenses yet. Add your first one.", + "costs.spent": "{amount} spent", + "costs.noDate": "No date", + "costs.noOnePaid": "No one paid yet", + "costs.youLent": "you lent {amount}", + "costs.youBorrowed": "you borrowed {amount}", + "costs.settleUp": "Settle up", + "costs.history": "History", + "costs.everyoneSquare": "Everyone's square", + "costs.nothingOutstanding": "No payments outstanding right now.", + "costs.pay": "pay", + "costs.pays": "pays", + "costs.settle": "Settle", + "costs.balances": "Balances", + "costs.byCategory": "By category", + "costs.noCategories": "No expenses yet.", + "costs.settleHistory": "Settle history", + "costs.noSettlements": "No settled payments yet.", + "costs.paymentsSettled": "{count} payments settled", + "costs.paid": "paid", + "costs.undo": "Undo", + "costs.whatFor": "What was it for?", + "costs.namePlaceholder": "e.g. Dinner, souvenirs, gas…", + "costs.totalAmount": "Total amount", + "costs.currency": "Currency", + "costs.day": "Day", + "costs.rateLabel": "1 {from} in {to}", + "costs.category": "Category", + "costs.whoPaid": "Who paid?", + "costs.splitBetween": "Split equally between", + "costs.pickSomeone": "Pick at least one person to split with.", + "costs.splitSummary": "Split {count} ways · {amount} each", + "costs.cat.accommodation": "Accommodation", + "costs.cat.food": "Food & drink", + "costs.cat.groceries": "Groceries", + "costs.cat.transport": "Transport", + "costs.cat.flights": "Flights", + "costs.cat.activities": "Activities", + "costs.cat.sightseeing": "Sightseeing", + "costs.cat.shopping": "Shopping", + "costs.cat.fees": "Fees & tickets", + "costs.cat.health": "Health", + "costs.cat.tips": "Tips", + "costs.cat.other": "Other", + "costs.daysCount": "{count} days", + "costs.travelers": "{count} travelers", + "costs.liveRate": "live rate", + "costs.settleAll": "Settle all", }; + export default budget; diff --git a/shared/src/i18n/en/settings.ts b/shared/src/i18n/en/settings.ts index df8f15ef..7eaabb50 100644 --- a/shared/src/i18n/en/settings.ts +++ b/shared/src/i18n/en/settings.ts @@ -288,5 +288,8 @@ const settings: TranslationStrings = { 'settings.mfa.toastEnabled': 'Two-factor authentication enabled', 'settings.mfa.toastDisabled': 'Two-factor authentication disabled', 'settings.mfa.demoBlocked': 'Not available in demo mode', + "settings.currency": "Currency", + "settings.currencyHint": "All amounts in Costs are converted to and shown in this currency.", }; + export default settings; diff --git a/shared/src/i18n/en/trip.ts b/shared/src/i18n/en/trip.ts index cb62f959..717490e8 100644 --- a/shared/src/i18n/en/trip.ts +++ b/shared/src/i18n/en/trip.ts @@ -9,7 +9,7 @@ const trip: TranslationStrings = { 'trip.tabs.packingShort': 'Packing', 'trip.tabs.lists': 'Lists', 'trip.tabs.listsShort': 'Lists', - 'trip.tabs.budget': 'Budget', + 'trip.tabs.budget': "Costs", 'trip.tabs.files': 'Files', 'trip.loading': 'Loading trip...', 'trip.loadingPhotos': 'Loading place photos...', diff --git a/shared/src/i18n/es/budget.ts b/shared/src/i18n/es/budget.ts index 0fb7c5e2..b865f36b 100644 --- a/shared/src/i18n/es/budget.ts +++ b/shared/src/i18n/es/budget.ts @@ -40,5 +40,78 @@ const budget: TranslationStrings = { 'Haz clic en el avatar de un miembro en una partida del presupuesto para marcarlo en verde — esto significa que ha pagado. La liquidación muestra quién debe cuánto a quién.', 'budget.netBalances': 'Saldos netos', 'budget.categoriesLabel': 'categorías', + "costs.you": "You", + "costs.youShort": "Y", + "costs.youLower": "you", + "costs.youOwe": "You owe", + "costs.youOweSub": "You should pay others", + "costs.youreOwed": "You're owed", + "costs.youreOwedSub": "Others should pay you", + "costs.totalSpend": "Total trip spend", + "costs.totalSpendSub": "Across all travelers", + "costs.to": "To", + "costs.from": "From", + "costs.allSettled": "You're all settled up", + "costs.nothingOwed": "Nothing owed to you", + "costs.yourShare": "Your share", + "costs.youPaid": "You paid", + "costs.expenses": "Expenses", + "costs.entries": "{count} entries", + "costs.searchPlaceholder": "Search expenses…", + "costs.filter.all": "All", + "costs.filter.mine": "Paid by me", + "costs.filter.owed": "I'm owed", + "costs.addExpense": "Add expense", + "costs.editExpense": "Edit expense", + "costs.noMatch": "No expenses match your search.", + "costs.emptyText": "No expenses yet. Add your first one.", + "costs.spent": "{amount} spent", + "costs.noDate": "No date", + "costs.noOnePaid": "No one paid yet", + "costs.youLent": "you lent {amount}", + "costs.youBorrowed": "you borrowed {amount}", + "costs.settleUp": "Settle up", + "costs.history": "History", + "costs.everyoneSquare": "Everyone's square", + "costs.nothingOutstanding": "No payments outstanding right now.", + "costs.pay": "pay", + "costs.pays": "pays", + "costs.settle": "Settle", + "costs.balances": "Balances", + "costs.byCategory": "By category", + "costs.noCategories": "No expenses yet.", + "costs.settleHistory": "Settle history", + "costs.noSettlements": "No settled payments yet.", + "costs.paymentsSettled": "{count} payments settled", + "costs.paid": "paid", + "costs.undo": "Undo", + "costs.whatFor": "What was it for?", + "costs.namePlaceholder": "e.g. Dinner, souvenirs, gas…", + "costs.totalAmount": "Total amount", + "costs.currency": "Currency", + "costs.day": "Day", + "costs.rateLabel": "1 {from} in {to}", + "costs.category": "Category", + "costs.whoPaid": "Who paid?", + "costs.splitBetween": "Split equally between", + "costs.pickSomeone": "Pick at least one person to split with.", + "costs.splitSummary": "Split {count} ways · {amount} each", + "costs.cat.accommodation": "Accommodation", + "costs.cat.food": "Food & drink", + "costs.cat.groceries": "Groceries", + "costs.cat.transport": "Transport", + "costs.cat.flights": "Flights", + "costs.cat.activities": "Activities", + "costs.cat.sightseeing": "Sightseeing", + "costs.cat.shopping": "Shopping", + "costs.cat.fees": "Fees & tickets", + "costs.cat.health": "Health", + "costs.cat.tips": "Tips", + "costs.cat.other": "Other", + "costs.daysCount": "{count} days", + "costs.travelers": "{count} travelers", + "costs.liveRate": "live rate", + "costs.settleAll": "Settle all", }; + export default budget; diff --git a/shared/src/i18n/es/settings.ts b/shared/src/i18n/es/settings.ts index 23ead94d..cfb3edd5 100644 --- a/shared/src/i18n/es/settings.ts +++ b/shared/src/i18n/es/settings.ts @@ -295,5 +295,8 @@ const settings: TranslationStrings = { 'settings.notificationPreferences.webhook': 'Webhook', 'settings.notificationPreferences.email': 'Email', 'settings.notificationPreferences.ntfy': 'Ntfy', + "settings.currency": "Currency", + "settings.currencyHint": "All amounts in Costs are converted to and shown in this currency.", }; + export default settings; diff --git a/shared/src/i18n/es/trip.ts b/shared/src/i18n/es/trip.ts index d21cccb4..5298b3e6 100644 --- a/shared/src/i18n/es/trip.ts +++ b/shared/src/i18n/es/trip.ts @@ -9,7 +9,7 @@ const trip: TranslationStrings = { 'trip.tabs.packingShort': 'Equipaje', 'trip.tabs.lists': 'Listas', 'trip.tabs.listsShort': 'Listas', - 'trip.tabs.budget': 'Presupuesto', + 'trip.tabs.budget': "Costs", 'trip.tabs.files': 'Archivos', 'trip.loading': 'Cargando viaje...', 'trip.loadingPhotos': 'Cargando fotos de los lugares...', diff --git a/shared/src/i18n/fr/budget.ts b/shared/src/i18n/fr/budget.ts index 1c4e474b..8e7949c7 100644 --- a/shared/src/i18n/fr/budget.ts +++ b/shared/src/i18n/fr/budget.ts @@ -40,5 +40,78 @@ const budget: TranslationStrings = { "Cliquez sur l'avatar d'un membre sur un poste budgétaire pour le marquer en vert — cela signifie qu'il a payé. Le règlement indique ensuite qui doit combien à qui.", 'budget.netBalances': 'Soldes nets', 'budget.categoriesLabel': 'catégories', + "costs.you": "You", + "costs.youShort": "Y", + "costs.youLower": "you", + "costs.youOwe": "You owe", + "costs.youOweSub": "You should pay others", + "costs.youreOwed": "You're owed", + "costs.youreOwedSub": "Others should pay you", + "costs.totalSpend": "Total trip spend", + "costs.totalSpendSub": "Across all travelers", + "costs.to": "To", + "costs.from": "From", + "costs.allSettled": "You're all settled up", + "costs.nothingOwed": "Nothing owed to you", + "costs.yourShare": "Your share", + "costs.youPaid": "You paid", + "costs.expenses": "Expenses", + "costs.entries": "{count} entries", + "costs.searchPlaceholder": "Search expenses…", + "costs.filter.all": "All", + "costs.filter.mine": "Paid by me", + "costs.filter.owed": "I'm owed", + "costs.addExpense": "Add expense", + "costs.editExpense": "Edit expense", + "costs.noMatch": "No expenses match your search.", + "costs.emptyText": "No expenses yet. Add your first one.", + "costs.spent": "{amount} spent", + "costs.noDate": "No date", + "costs.noOnePaid": "No one paid yet", + "costs.youLent": "you lent {amount}", + "costs.youBorrowed": "you borrowed {amount}", + "costs.settleUp": "Settle up", + "costs.history": "History", + "costs.everyoneSquare": "Everyone's square", + "costs.nothingOutstanding": "No payments outstanding right now.", + "costs.pay": "pay", + "costs.pays": "pays", + "costs.settle": "Settle", + "costs.balances": "Balances", + "costs.byCategory": "By category", + "costs.noCategories": "No expenses yet.", + "costs.settleHistory": "Settle history", + "costs.noSettlements": "No settled payments yet.", + "costs.paymentsSettled": "{count} payments settled", + "costs.paid": "paid", + "costs.undo": "Undo", + "costs.whatFor": "What was it for?", + "costs.namePlaceholder": "e.g. Dinner, souvenirs, gas…", + "costs.totalAmount": "Total amount", + "costs.currency": "Currency", + "costs.day": "Day", + "costs.rateLabel": "1 {from} in {to}", + "costs.category": "Category", + "costs.whoPaid": "Who paid?", + "costs.splitBetween": "Split equally between", + "costs.pickSomeone": "Pick at least one person to split with.", + "costs.splitSummary": "Split {count} ways · {amount} each", + "costs.cat.accommodation": "Accommodation", + "costs.cat.food": "Food & drink", + "costs.cat.groceries": "Groceries", + "costs.cat.transport": "Transport", + "costs.cat.flights": "Flights", + "costs.cat.activities": "Activities", + "costs.cat.sightseeing": "Sightseeing", + "costs.cat.shopping": "Shopping", + "costs.cat.fees": "Fees & tickets", + "costs.cat.health": "Health", + "costs.cat.tips": "Tips", + "costs.cat.other": "Other", + "costs.daysCount": "{count} days", + "costs.travelers": "{count} travelers", + "costs.liveRate": "live rate", + "costs.settleAll": "Settle all", }; + export default budget; diff --git a/shared/src/i18n/fr/settings.ts b/shared/src/i18n/fr/settings.ts index 4042da78..d4f2dfdc 100644 --- a/shared/src/i18n/fr/settings.ts +++ b/shared/src/i18n/fr/settings.ts @@ -299,5 +299,8 @@ const settings: TranslationStrings = { 'settings.notificationPreferences.webhook': 'Webhook', 'settings.notificationPreferences.email': 'Email', 'settings.notificationPreferences.ntfy': 'Ntfy', + "settings.currency": "Currency", + "settings.currencyHint": "All amounts in Costs are converted to and shown in this currency.", }; + export default settings; diff --git a/shared/src/i18n/fr/trip.ts b/shared/src/i18n/fr/trip.ts index 4f2e572c..fc97df5f 100644 --- a/shared/src/i18n/fr/trip.ts +++ b/shared/src/i18n/fr/trip.ts @@ -9,7 +9,7 @@ const trip: TranslationStrings = { 'trip.tabs.packingShort': 'Bagages', 'trip.tabs.lists': 'Listes', 'trip.tabs.listsShort': 'Listes', - 'trip.tabs.budget': 'Budget', + 'trip.tabs.budget': "Costs", 'trip.tabs.files': 'Fichiers', 'trip.loading': 'Chargement du voyage…', 'trip.loadingPhotos': 'Chargement des photos des lieux...', diff --git a/shared/src/i18n/gr/budget.ts b/shared/src/i18n/gr/budget.ts index 7e2a1de3..e9f41c10 100644 --- a/shared/src/i18n/gr/budget.ts +++ b/shared/src/i18n/gr/budget.ts @@ -40,5 +40,78 @@ const budget: TranslationStrings = { 'Κάντε κλικ στο avatar ενός μέλους σε μια εγγραφή προϋπολογισμού για να το επισημάνετε πράσινο — αυτό σημαίνει ότι πλήρωσε. Η εκκαθάριση δείχνει στη συνέχεια ποιος χρωστάει σε ποιον και πόσα.', 'budget.netBalances': 'Καθαρά Υπόλοιπα', 'budget.categoriesLabel': 'κατηγορίες', + "costs.you": "You", + "costs.youShort": "Y", + "costs.youLower": "you", + "costs.youOwe": "You owe", + "costs.youOweSub": "You should pay others", + "costs.youreOwed": "You're owed", + "costs.youreOwedSub": "Others should pay you", + "costs.totalSpend": "Total trip spend", + "costs.totalSpendSub": "Across all travelers", + "costs.to": "To", + "costs.from": "From", + "costs.allSettled": "You're all settled up", + "costs.nothingOwed": "Nothing owed to you", + "costs.yourShare": "Your share", + "costs.youPaid": "You paid", + "costs.expenses": "Expenses", + "costs.entries": "{count} entries", + "costs.searchPlaceholder": "Search expenses…", + "costs.filter.all": "All", + "costs.filter.mine": "Paid by me", + "costs.filter.owed": "I'm owed", + "costs.addExpense": "Add expense", + "costs.editExpense": "Edit expense", + "costs.noMatch": "No expenses match your search.", + "costs.emptyText": "No expenses yet. Add your first one.", + "costs.spent": "{amount} spent", + "costs.noDate": "No date", + "costs.noOnePaid": "No one paid yet", + "costs.youLent": "you lent {amount}", + "costs.youBorrowed": "you borrowed {amount}", + "costs.settleUp": "Settle up", + "costs.history": "History", + "costs.everyoneSquare": "Everyone's square", + "costs.nothingOutstanding": "No payments outstanding right now.", + "costs.pay": "pay", + "costs.pays": "pays", + "costs.settle": "Settle", + "costs.balances": "Balances", + "costs.byCategory": "By category", + "costs.noCategories": "No expenses yet.", + "costs.settleHistory": "Settle history", + "costs.noSettlements": "No settled payments yet.", + "costs.paymentsSettled": "{count} payments settled", + "costs.paid": "paid", + "costs.undo": "Undo", + "costs.whatFor": "What was it for?", + "costs.namePlaceholder": "e.g. Dinner, souvenirs, gas…", + "costs.totalAmount": "Total amount", + "costs.currency": "Currency", + "costs.day": "Day", + "costs.rateLabel": "1 {from} in {to}", + "costs.category": "Category", + "costs.whoPaid": "Who paid?", + "costs.splitBetween": "Split equally between", + "costs.pickSomeone": "Pick at least one person to split with.", + "costs.splitSummary": "Split {count} ways · {amount} each", + "costs.cat.accommodation": "Accommodation", + "costs.cat.food": "Food & drink", + "costs.cat.groceries": "Groceries", + "costs.cat.transport": "Transport", + "costs.cat.flights": "Flights", + "costs.cat.activities": "Activities", + "costs.cat.sightseeing": "Sightseeing", + "costs.cat.shopping": "Shopping", + "costs.cat.fees": "Fees & tickets", + "costs.cat.health": "Health", + "costs.cat.tips": "Tips", + "costs.cat.other": "Other", + "costs.daysCount": "{count} days", + "costs.travelers": "{count} travelers", + "costs.liveRate": "live rate", + "costs.settleAll": "Settle all", }; + export default budget; diff --git a/shared/src/i18n/gr/settings.ts b/shared/src/i18n/gr/settings.ts index 04830be2..ea9dd1a5 100644 --- a/shared/src/i18n/gr/settings.ts +++ b/shared/src/i18n/gr/settings.ts @@ -301,5 +301,8 @@ const settings: TranslationStrings = { 'settings.mfa.toastDisabled': 'Ο έλεγχος ταυτότητας δύο παραγόντων απενεργοποιήθηκε', 'settings.mfa.demoBlocked': 'Δεν είναι διαθέσιμο σε λειτουργία demo', + "settings.currency": "Currency", + "settings.currencyHint": "All amounts in Costs are converted to and shown in this currency.", }; + export default settings; diff --git a/shared/src/i18n/gr/trip.ts b/shared/src/i18n/gr/trip.ts index 0e69d8ea..96f44bce 100644 --- a/shared/src/i18n/gr/trip.ts +++ b/shared/src/i18n/gr/trip.ts @@ -9,7 +9,7 @@ const trip: TranslationStrings = { 'trip.tabs.packingShort': 'Αποσκευές', 'trip.tabs.lists': 'Λίστες', 'trip.tabs.listsShort': 'Λίστες', - 'trip.tabs.budget': 'Προϋπολογισμός', + 'trip.tabs.budget': "Costs", 'trip.tabs.files': 'Αρχεία', 'trip.loading': 'Φόρτωση ταξιδιού...', 'trip.loadingPhotos': 'Φόρτωση φωτογραφιών μέρους...', diff --git a/shared/src/i18n/hu/budget.ts b/shared/src/i18n/hu/budget.ts index 87a21a4f..70f7ec59 100644 --- a/shared/src/i18n/hu/budget.ts +++ b/shared/src/i18n/hu/budget.ts @@ -40,5 +40,78 @@ const budget: TranslationStrings = { 'Kattints egy tag avatárjára egy költségvetési tételen a zöld jelöléshez — ez azt jelenti, hogy fizetett. Az elszámolás ezután mutatja, ki kinek mennyivel tartozik.', 'budget.netBalances': 'Nettó egyenlegek', 'budget.categoriesLabel': 'kategóriák', + "costs.you": "You", + "costs.youShort": "Y", + "costs.youLower": "you", + "costs.youOwe": "You owe", + "costs.youOweSub": "You should pay others", + "costs.youreOwed": "You're owed", + "costs.youreOwedSub": "Others should pay you", + "costs.totalSpend": "Total trip spend", + "costs.totalSpendSub": "Across all travelers", + "costs.to": "To", + "costs.from": "From", + "costs.allSettled": "You're all settled up", + "costs.nothingOwed": "Nothing owed to you", + "costs.yourShare": "Your share", + "costs.youPaid": "You paid", + "costs.expenses": "Expenses", + "costs.entries": "{count} entries", + "costs.searchPlaceholder": "Search expenses…", + "costs.filter.all": "All", + "costs.filter.mine": "Paid by me", + "costs.filter.owed": "I'm owed", + "costs.addExpense": "Add expense", + "costs.editExpense": "Edit expense", + "costs.noMatch": "No expenses match your search.", + "costs.emptyText": "No expenses yet. Add your first one.", + "costs.spent": "{amount} spent", + "costs.noDate": "No date", + "costs.noOnePaid": "No one paid yet", + "costs.youLent": "you lent {amount}", + "costs.youBorrowed": "you borrowed {amount}", + "costs.settleUp": "Settle up", + "costs.history": "History", + "costs.everyoneSquare": "Everyone's square", + "costs.nothingOutstanding": "No payments outstanding right now.", + "costs.pay": "pay", + "costs.pays": "pays", + "costs.settle": "Settle", + "costs.balances": "Balances", + "costs.byCategory": "By category", + "costs.noCategories": "No expenses yet.", + "costs.settleHistory": "Settle history", + "costs.noSettlements": "No settled payments yet.", + "costs.paymentsSettled": "{count} payments settled", + "costs.paid": "paid", + "costs.undo": "Undo", + "costs.whatFor": "What was it for?", + "costs.namePlaceholder": "e.g. Dinner, souvenirs, gas…", + "costs.totalAmount": "Total amount", + "costs.currency": "Currency", + "costs.day": "Day", + "costs.rateLabel": "1 {from} in {to}", + "costs.category": "Category", + "costs.whoPaid": "Who paid?", + "costs.splitBetween": "Split equally between", + "costs.pickSomeone": "Pick at least one person to split with.", + "costs.splitSummary": "Split {count} ways · {amount} each", + "costs.cat.accommodation": "Accommodation", + "costs.cat.food": "Food & drink", + "costs.cat.groceries": "Groceries", + "costs.cat.transport": "Transport", + "costs.cat.flights": "Flights", + "costs.cat.activities": "Activities", + "costs.cat.sightseeing": "Sightseeing", + "costs.cat.shopping": "Shopping", + "costs.cat.fees": "Fees & tickets", + "costs.cat.health": "Health", + "costs.cat.tips": "Tips", + "costs.cat.other": "Other", + "costs.daysCount": "{count} days", + "costs.travelers": "{count} travelers", + "costs.liveRate": "live rate", + "costs.settleAll": "Settle all", }; + export default budget; diff --git a/shared/src/i18n/hu/settings.ts b/shared/src/i18n/hu/settings.ts index 8a33689f..4ee9c8a3 100644 --- a/shared/src/i18n/hu/settings.ts +++ b/shared/src/i18n/hu/settings.ts @@ -297,5 +297,8 @@ const settings: TranslationStrings = { 'settings.notificationPreferences.webhook': 'Webhook', 'settings.notificationPreferences.email': 'Email', 'settings.notificationPreferences.ntfy': 'Ntfy', + "settings.currency": "Currency", + "settings.currencyHint": "All amounts in Costs are converted to and shown in this currency.", }; + export default settings; diff --git a/shared/src/i18n/hu/trip.ts b/shared/src/i18n/hu/trip.ts index b823ce9a..d0235571 100644 --- a/shared/src/i18n/hu/trip.ts +++ b/shared/src/i18n/hu/trip.ts @@ -9,7 +9,7 @@ const trip: TranslationStrings = { 'trip.tabs.packingShort': 'Csomag', 'trip.tabs.lists': 'Listák', 'trip.tabs.listsShort': 'Listák', - 'trip.tabs.budget': 'Költségvetés', + 'trip.tabs.budget': "Costs", 'trip.tabs.files': 'Fájlok', 'trip.loading': 'Utazás betöltése...', 'trip.mobilePlan': 'Tervezés', diff --git a/shared/src/i18n/id/budget.ts b/shared/src/i18n/id/budget.ts index 642f91ec..ed35f589 100644 --- a/shared/src/i18n/id/budget.ts +++ b/shared/src/i18n/id/budget.ts @@ -39,5 +39,78 @@ const budget: TranslationStrings = { 'Klik foto anggota di item anggaran untuk menandainya hijau — artinya mereka sudah bayar. Penyelesaian lalu menunjukkan siapa berhutang ke siapa dan berapa.', 'budget.netBalances': 'Saldo Bersih', 'budget.categoriesLabel': 'kategori', + "costs.you": "You", + "costs.youShort": "Y", + "costs.youLower": "you", + "costs.youOwe": "You owe", + "costs.youOweSub": "You should pay others", + "costs.youreOwed": "You're owed", + "costs.youreOwedSub": "Others should pay you", + "costs.totalSpend": "Total trip spend", + "costs.totalSpendSub": "Across all travelers", + "costs.to": "To", + "costs.from": "From", + "costs.allSettled": "You're all settled up", + "costs.nothingOwed": "Nothing owed to you", + "costs.yourShare": "Your share", + "costs.youPaid": "You paid", + "costs.expenses": "Expenses", + "costs.entries": "{count} entries", + "costs.searchPlaceholder": "Search expenses…", + "costs.filter.all": "All", + "costs.filter.mine": "Paid by me", + "costs.filter.owed": "I'm owed", + "costs.addExpense": "Add expense", + "costs.editExpense": "Edit expense", + "costs.noMatch": "No expenses match your search.", + "costs.emptyText": "No expenses yet. Add your first one.", + "costs.spent": "{amount} spent", + "costs.noDate": "No date", + "costs.noOnePaid": "No one paid yet", + "costs.youLent": "you lent {amount}", + "costs.youBorrowed": "you borrowed {amount}", + "costs.settleUp": "Settle up", + "costs.history": "History", + "costs.everyoneSquare": "Everyone's square", + "costs.nothingOutstanding": "No payments outstanding right now.", + "costs.pay": "pay", + "costs.pays": "pays", + "costs.settle": "Settle", + "costs.balances": "Balances", + "costs.byCategory": "By category", + "costs.noCategories": "No expenses yet.", + "costs.settleHistory": "Settle history", + "costs.noSettlements": "No settled payments yet.", + "costs.paymentsSettled": "{count} payments settled", + "costs.paid": "paid", + "costs.undo": "Undo", + "costs.whatFor": "What was it for?", + "costs.namePlaceholder": "e.g. Dinner, souvenirs, gas…", + "costs.totalAmount": "Total amount", + "costs.currency": "Currency", + "costs.day": "Day", + "costs.rateLabel": "1 {from} in {to}", + "costs.category": "Category", + "costs.whoPaid": "Who paid?", + "costs.splitBetween": "Split equally between", + "costs.pickSomeone": "Pick at least one person to split with.", + "costs.splitSummary": "Split {count} ways · {amount} each", + "costs.cat.accommodation": "Accommodation", + "costs.cat.food": "Food & drink", + "costs.cat.groceries": "Groceries", + "costs.cat.transport": "Transport", + "costs.cat.flights": "Flights", + "costs.cat.activities": "Activities", + "costs.cat.sightseeing": "Sightseeing", + "costs.cat.shopping": "Shopping", + "costs.cat.fees": "Fees & tickets", + "costs.cat.health": "Health", + "costs.cat.tips": "Tips", + "costs.cat.other": "Other", + "costs.daysCount": "{count} days", + "costs.travelers": "{count} travelers", + "costs.liveRate": "live rate", + "costs.settleAll": "Settle all", }; + export default budget; diff --git a/shared/src/i18n/id/settings.ts b/shared/src/i18n/id/settings.ts index 91ccc6b1..a97a27c8 100644 --- a/shared/src/i18n/id/settings.ts +++ b/shared/src/i18n/id/settings.ts @@ -295,5 +295,8 @@ const settings: TranslationStrings = { 'settings.bookingLabels': 'Label rute pemesanan', 'settings.bookingLabelsHint': 'Menampilkan nama stasiun / bandara di peta. Jika mati, hanya ikon ditampilkan.', + "settings.currency": "Currency", + "settings.currencyHint": "All amounts in Costs are converted to and shown in this currency.", }; + export default settings; diff --git a/shared/src/i18n/id/trip.ts b/shared/src/i18n/id/trip.ts index a42a48a1..07378d19 100644 --- a/shared/src/i18n/id/trip.ts +++ b/shared/src/i18n/id/trip.ts @@ -9,7 +9,7 @@ const trip: TranslationStrings = { 'trip.tabs.packingShort': 'Perlengkapan', 'trip.tabs.lists': 'Daftar', 'trip.tabs.listsShort': 'Daftar', - 'trip.tabs.budget': 'Anggaran', + 'trip.tabs.budget': "Costs", 'trip.tabs.files': 'File', 'trip.loading': 'Memuat perjalanan...', 'trip.loadingPhotos': 'Memuat foto tempat...', diff --git a/shared/src/i18n/it/budget.ts b/shared/src/i18n/it/budget.ts index ed9d8063..bb65a324 100644 --- a/shared/src/i18n/it/budget.ts +++ b/shared/src/i18n/it/budget.ts @@ -40,5 +40,78 @@ const budget: TranslationStrings = { "Clicca sull'avatar di un membro su una voce di budget per contrassegnarlo in verde — significa che ha pagato. Il regolamento mostra poi chi deve quanto a chi.", 'budget.netBalances': 'Saldi netti', 'budget.categoriesLabel': 'categorie', + "costs.you": "You", + "costs.youShort": "Y", + "costs.youLower": "you", + "costs.youOwe": "You owe", + "costs.youOweSub": "You should pay others", + "costs.youreOwed": "You're owed", + "costs.youreOwedSub": "Others should pay you", + "costs.totalSpend": "Total trip spend", + "costs.totalSpendSub": "Across all travelers", + "costs.to": "To", + "costs.from": "From", + "costs.allSettled": "You're all settled up", + "costs.nothingOwed": "Nothing owed to you", + "costs.yourShare": "Your share", + "costs.youPaid": "You paid", + "costs.expenses": "Expenses", + "costs.entries": "{count} entries", + "costs.searchPlaceholder": "Search expenses…", + "costs.filter.all": "All", + "costs.filter.mine": "Paid by me", + "costs.filter.owed": "I'm owed", + "costs.addExpense": "Add expense", + "costs.editExpense": "Edit expense", + "costs.noMatch": "No expenses match your search.", + "costs.emptyText": "No expenses yet. Add your first one.", + "costs.spent": "{amount} spent", + "costs.noDate": "No date", + "costs.noOnePaid": "No one paid yet", + "costs.youLent": "you lent {amount}", + "costs.youBorrowed": "you borrowed {amount}", + "costs.settleUp": "Settle up", + "costs.history": "History", + "costs.everyoneSquare": "Everyone's square", + "costs.nothingOutstanding": "No payments outstanding right now.", + "costs.pay": "pay", + "costs.pays": "pays", + "costs.settle": "Settle", + "costs.balances": "Balances", + "costs.byCategory": "By category", + "costs.noCategories": "No expenses yet.", + "costs.settleHistory": "Settle history", + "costs.noSettlements": "No settled payments yet.", + "costs.paymentsSettled": "{count} payments settled", + "costs.paid": "paid", + "costs.undo": "Undo", + "costs.whatFor": "What was it for?", + "costs.namePlaceholder": "e.g. Dinner, souvenirs, gas…", + "costs.totalAmount": "Total amount", + "costs.currency": "Currency", + "costs.day": "Day", + "costs.rateLabel": "1 {from} in {to}", + "costs.category": "Category", + "costs.whoPaid": "Who paid?", + "costs.splitBetween": "Split equally between", + "costs.pickSomeone": "Pick at least one person to split with.", + "costs.splitSummary": "Split {count} ways · {amount} each", + "costs.cat.accommodation": "Accommodation", + "costs.cat.food": "Food & drink", + "costs.cat.groceries": "Groceries", + "costs.cat.transport": "Transport", + "costs.cat.flights": "Flights", + "costs.cat.activities": "Activities", + "costs.cat.sightseeing": "Sightseeing", + "costs.cat.shopping": "Shopping", + "costs.cat.fees": "Fees & tickets", + "costs.cat.health": "Health", + "costs.cat.tips": "Tips", + "costs.cat.other": "Other", + "costs.daysCount": "{count} days", + "costs.travelers": "{count} travelers", + "costs.liveRate": "live rate", + "costs.settleAll": "Settle all", }; + export default budget; diff --git a/shared/src/i18n/it/settings.ts b/shared/src/i18n/it/settings.ts index c166cd9b..42d4d4b9 100644 --- a/shared/src/i18n/it/settings.ts +++ b/shared/src/i18n/it/settings.ts @@ -294,5 +294,8 @@ const settings: TranslationStrings = { 'settings.notificationPreferences.webhook': 'Webhook', 'settings.notificationPreferences.email': 'Email', 'settings.notificationPreferences.ntfy': 'Ntfy', + "settings.currency": "Currency", + "settings.currencyHint": "All amounts in Costs are converted to and shown in this currency.", }; + export default settings; diff --git a/shared/src/i18n/it/trip.ts b/shared/src/i18n/it/trip.ts index 42820e82..eb202448 100644 --- a/shared/src/i18n/it/trip.ts +++ b/shared/src/i18n/it/trip.ts @@ -9,7 +9,7 @@ const trip: TranslationStrings = { 'trip.tabs.packingShort': 'Valigia', 'trip.tabs.lists': 'Liste', 'trip.tabs.listsShort': 'Liste', - 'trip.tabs.budget': 'Budget', + 'trip.tabs.budget': "Costs", 'trip.tabs.files': 'File', 'trip.loading': 'Caricamento viaggio...', 'trip.mobilePlan': 'Programma', diff --git a/shared/src/i18n/ja/budget.ts b/shared/src/i18n/ja/budget.ts index 33466e55..1c676d0c 100644 --- a/shared/src/i18n/ja/budget.ts +++ b/shared/src/i18n/ja/budget.ts @@ -38,5 +38,78 @@ const budget: TranslationStrings = { '予算項目のメンバーアイコンをクリックして緑にすると、支払い済みを示します。精算では、誰が誰にいくら支払うべきかを表示します。', 'budget.netBalances': '差引残高', 'budget.categoriesLabel': 'カテゴリ', + "costs.you": "You", + "costs.youShort": "Y", + "costs.youLower": "you", + "costs.youOwe": "You owe", + "costs.youOweSub": "You should pay others", + "costs.youreOwed": "You're owed", + "costs.youreOwedSub": "Others should pay you", + "costs.totalSpend": "Total trip spend", + "costs.totalSpendSub": "Across all travelers", + "costs.to": "To", + "costs.from": "From", + "costs.allSettled": "You're all settled up", + "costs.nothingOwed": "Nothing owed to you", + "costs.yourShare": "Your share", + "costs.youPaid": "You paid", + "costs.expenses": "Expenses", + "costs.entries": "{count} entries", + "costs.searchPlaceholder": "Search expenses…", + "costs.filter.all": "All", + "costs.filter.mine": "Paid by me", + "costs.filter.owed": "I'm owed", + "costs.addExpense": "Add expense", + "costs.editExpense": "Edit expense", + "costs.noMatch": "No expenses match your search.", + "costs.emptyText": "No expenses yet. Add your first one.", + "costs.spent": "{amount} spent", + "costs.noDate": "No date", + "costs.noOnePaid": "No one paid yet", + "costs.youLent": "you lent {amount}", + "costs.youBorrowed": "you borrowed {amount}", + "costs.settleUp": "Settle up", + "costs.history": "History", + "costs.everyoneSquare": "Everyone's square", + "costs.nothingOutstanding": "No payments outstanding right now.", + "costs.pay": "pay", + "costs.pays": "pays", + "costs.settle": "Settle", + "costs.balances": "Balances", + "costs.byCategory": "By category", + "costs.noCategories": "No expenses yet.", + "costs.settleHistory": "Settle history", + "costs.noSettlements": "No settled payments yet.", + "costs.paymentsSettled": "{count} payments settled", + "costs.paid": "paid", + "costs.undo": "Undo", + "costs.whatFor": "What was it for?", + "costs.namePlaceholder": "e.g. Dinner, souvenirs, gas…", + "costs.totalAmount": "Total amount", + "costs.currency": "Currency", + "costs.day": "Day", + "costs.rateLabel": "1 {from} in {to}", + "costs.category": "Category", + "costs.whoPaid": "Who paid?", + "costs.splitBetween": "Split equally between", + "costs.pickSomeone": "Pick at least one person to split with.", + "costs.splitSummary": "Split {count} ways · {amount} each", + "costs.cat.accommodation": "Accommodation", + "costs.cat.food": "Food & drink", + "costs.cat.groceries": "Groceries", + "costs.cat.transport": "Transport", + "costs.cat.flights": "Flights", + "costs.cat.activities": "Activities", + "costs.cat.sightseeing": "Sightseeing", + "costs.cat.shopping": "Shopping", + "costs.cat.fees": "Fees & tickets", + "costs.cat.health": "Health", + "costs.cat.tips": "Tips", + "costs.cat.other": "Other", + "costs.daysCount": "{count} days", + "costs.travelers": "{count} travelers", + "costs.liveRate": "live rate", + "costs.settleAll": "Settle all", }; + export default budget; diff --git a/shared/src/i18n/ja/settings.ts b/shared/src/i18n/ja/settings.ts index b9e08230..50209a75 100644 --- a/shared/src/i18n/ja/settings.ts +++ b/shared/src/i18n/ja/settings.ts @@ -274,5 +274,8 @@ const settings: TranslationStrings = { 'settings.oauth.modal.machineClientUsage': 'トークンを取得するには、grant_type=client_credentials、client_id、client_secret を指定して POST /oauth/token を呼び出します。ブラウザもリフレッシュトークンも不要です。', 'settings.oauth.badge.machine': 'マシン', + "settings.currency": "Currency", + "settings.currencyHint": "All amounts in Costs are converted to and shown in this currency.", }; + export default settings; diff --git a/shared/src/i18n/ja/trip.ts b/shared/src/i18n/ja/trip.ts index 8cd2547c..066f6624 100644 --- a/shared/src/i18n/ja/trip.ts +++ b/shared/src/i18n/ja/trip.ts @@ -9,7 +9,7 @@ const trip: TranslationStrings = { 'trip.tabs.packingShort': '持ち物', 'trip.tabs.lists': 'リスト', 'trip.tabs.listsShort': 'リスト', - 'trip.tabs.budget': '予算', + 'trip.tabs.budget': "Costs", 'trip.tabs.files': 'ファイル', 'trip.loading': '旅行を読み込み中...', 'trip.loadingPhotos': '場所の写真を読み込み中...', diff --git a/shared/src/i18n/ko/budget.ts b/shared/src/i18n/ko/budget.ts index 27b17093..1bdd2e55 100644 --- a/shared/src/i18n/ko/budget.ts +++ b/shared/src/i18n/ko/budget.ts @@ -38,5 +38,78 @@ const budget: TranslationStrings = { '예산 항목의 멤버 아바타를 클릭하면 녹색으로 표시됩니다 — 해당 멤버가 지불했음을 의미합니다. 그러면 정산에서 누가 누구에게 얼마를 지불해야 하는지 보여줍니다.', 'budget.netBalances': '순 잔액', 'budget.categoriesLabel': '카테고리', + "costs.you": "You", + "costs.youShort": "Y", + "costs.youLower": "you", + "costs.youOwe": "You owe", + "costs.youOweSub": "You should pay others", + "costs.youreOwed": "You're owed", + "costs.youreOwedSub": "Others should pay you", + "costs.totalSpend": "Total trip spend", + "costs.totalSpendSub": "Across all travelers", + "costs.to": "To", + "costs.from": "From", + "costs.allSettled": "You're all settled up", + "costs.nothingOwed": "Nothing owed to you", + "costs.yourShare": "Your share", + "costs.youPaid": "You paid", + "costs.expenses": "Expenses", + "costs.entries": "{count} entries", + "costs.searchPlaceholder": "Search expenses…", + "costs.filter.all": "All", + "costs.filter.mine": "Paid by me", + "costs.filter.owed": "I'm owed", + "costs.addExpense": "Add expense", + "costs.editExpense": "Edit expense", + "costs.noMatch": "No expenses match your search.", + "costs.emptyText": "No expenses yet. Add your first one.", + "costs.spent": "{amount} spent", + "costs.noDate": "No date", + "costs.noOnePaid": "No one paid yet", + "costs.youLent": "you lent {amount}", + "costs.youBorrowed": "you borrowed {amount}", + "costs.settleUp": "Settle up", + "costs.history": "History", + "costs.everyoneSquare": "Everyone's square", + "costs.nothingOutstanding": "No payments outstanding right now.", + "costs.pay": "pay", + "costs.pays": "pays", + "costs.settle": "Settle", + "costs.balances": "Balances", + "costs.byCategory": "By category", + "costs.noCategories": "No expenses yet.", + "costs.settleHistory": "Settle history", + "costs.noSettlements": "No settled payments yet.", + "costs.paymentsSettled": "{count} payments settled", + "costs.paid": "paid", + "costs.undo": "Undo", + "costs.whatFor": "What was it for?", + "costs.namePlaceholder": "e.g. Dinner, souvenirs, gas…", + "costs.totalAmount": "Total amount", + "costs.currency": "Currency", + "costs.day": "Day", + "costs.rateLabel": "1 {from} in {to}", + "costs.category": "Category", + "costs.whoPaid": "Who paid?", + "costs.splitBetween": "Split equally between", + "costs.pickSomeone": "Pick at least one person to split with.", + "costs.splitSummary": "Split {count} ways · {amount} each", + "costs.cat.accommodation": "Accommodation", + "costs.cat.food": "Food & drink", + "costs.cat.groceries": "Groceries", + "costs.cat.transport": "Transport", + "costs.cat.flights": "Flights", + "costs.cat.activities": "Activities", + "costs.cat.sightseeing": "Sightseeing", + "costs.cat.shopping": "Shopping", + "costs.cat.fees": "Fees & tickets", + "costs.cat.health": "Health", + "costs.cat.tips": "Tips", + "costs.cat.other": "Other", + "costs.daysCount": "{count} days", + "costs.travelers": "{count} travelers", + "costs.liveRate": "live rate", + "costs.settleAll": "Settle all", }; + export default budget; diff --git a/shared/src/i18n/ko/settings.ts b/shared/src/i18n/ko/settings.ts index 3ac19ef1..e7bd2ca0 100644 --- a/shared/src/i18n/ko/settings.ts +++ b/shared/src/i18n/ko/settings.ts @@ -291,5 +291,8 @@ const settings: TranslationStrings = { 'settings.oauth.modal.machineClientUsage': '토큰 받기: grant_type=client_credentials, client_id, client_secret으로 POST /oauth/token을 호출하세요. 브라우저도 새로 고침 토큰도 필요 없습니다.', 'settings.oauth.badge.machine': '머신', + "settings.currency": "Currency", + "settings.currencyHint": "All amounts in Costs are converted to and shown in this currency.", }; + export default settings; diff --git a/shared/src/i18n/ko/trip.ts b/shared/src/i18n/ko/trip.ts index 2c0000c0..6f20b269 100644 --- a/shared/src/i18n/ko/trip.ts +++ b/shared/src/i18n/ko/trip.ts @@ -9,7 +9,7 @@ const trip: TranslationStrings = { 'trip.tabs.packingShort': '짐', 'trip.tabs.lists': '목록', 'trip.tabs.listsShort': '목록', - 'trip.tabs.budget': '예산', + 'trip.tabs.budget': "Costs", 'trip.tabs.files': '파일', 'trip.loading': '여행 불러오는 중...', 'trip.loadingPhotos': '장소 사진 불러오는 중...', diff --git a/shared/src/i18n/nl/budget.ts b/shared/src/i18n/nl/budget.ts index d8b47c8d..fb02fc33 100644 --- a/shared/src/i18n/nl/budget.ts +++ b/shared/src/i18n/nl/budget.ts @@ -40,5 +40,78 @@ const budget: TranslationStrings = { 'Klik op de avatar van een lid bij een budgetpost om deze groen te markeren — dit betekent dat diegene heeft betaald. De afrekening toont vervolgens wie wie hoeveel verschuldigd is.', 'budget.netBalances': 'Nettosaldi', 'budget.categoriesLabel': 'categorieën', + "costs.you": "You", + "costs.youShort": "Y", + "costs.youLower": "you", + "costs.youOwe": "You owe", + "costs.youOweSub": "You should pay others", + "costs.youreOwed": "You're owed", + "costs.youreOwedSub": "Others should pay you", + "costs.totalSpend": "Total trip spend", + "costs.totalSpendSub": "Across all travelers", + "costs.to": "To", + "costs.from": "From", + "costs.allSettled": "You're all settled up", + "costs.nothingOwed": "Nothing owed to you", + "costs.yourShare": "Your share", + "costs.youPaid": "You paid", + "costs.expenses": "Expenses", + "costs.entries": "{count} entries", + "costs.searchPlaceholder": "Search expenses…", + "costs.filter.all": "All", + "costs.filter.mine": "Paid by me", + "costs.filter.owed": "I'm owed", + "costs.addExpense": "Add expense", + "costs.editExpense": "Edit expense", + "costs.noMatch": "No expenses match your search.", + "costs.emptyText": "No expenses yet. Add your first one.", + "costs.spent": "{amount} spent", + "costs.noDate": "No date", + "costs.noOnePaid": "No one paid yet", + "costs.youLent": "you lent {amount}", + "costs.youBorrowed": "you borrowed {amount}", + "costs.settleUp": "Settle up", + "costs.history": "History", + "costs.everyoneSquare": "Everyone's square", + "costs.nothingOutstanding": "No payments outstanding right now.", + "costs.pay": "pay", + "costs.pays": "pays", + "costs.settle": "Settle", + "costs.balances": "Balances", + "costs.byCategory": "By category", + "costs.noCategories": "No expenses yet.", + "costs.settleHistory": "Settle history", + "costs.noSettlements": "No settled payments yet.", + "costs.paymentsSettled": "{count} payments settled", + "costs.paid": "paid", + "costs.undo": "Undo", + "costs.whatFor": "What was it for?", + "costs.namePlaceholder": "e.g. Dinner, souvenirs, gas…", + "costs.totalAmount": "Total amount", + "costs.currency": "Currency", + "costs.day": "Day", + "costs.rateLabel": "1 {from} in {to}", + "costs.category": "Category", + "costs.whoPaid": "Who paid?", + "costs.splitBetween": "Split equally between", + "costs.pickSomeone": "Pick at least one person to split with.", + "costs.splitSummary": "Split {count} ways · {amount} each", + "costs.cat.accommodation": "Accommodation", + "costs.cat.food": "Food & drink", + "costs.cat.groceries": "Groceries", + "costs.cat.transport": "Transport", + "costs.cat.flights": "Flights", + "costs.cat.activities": "Activities", + "costs.cat.sightseeing": "Sightseeing", + "costs.cat.shopping": "Shopping", + "costs.cat.fees": "Fees & tickets", + "costs.cat.health": "Health", + "costs.cat.tips": "Tips", + "costs.cat.other": "Other", + "costs.daysCount": "{count} days", + "costs.travelers": "{count} travelers", + "costs.liveRate": "live rate", + "costs.settleAll": "Settle all", }; + export default budget; diff --git a/shared/src/i18n/nl/settings.ts b/shared/src/i18n/nl/settings.ts index b23e5098..2e9572b8 100644 --- a/shared/src/i18n/nl/settings.ts +++ b/shared/src/i18n/nl/settings.ts @@ -294,5 +294,8 @@ const settings: TranslationStrings = { 'settings.notificationPreferences.webhook': 'Webhook', 'settings.notificationPreferences.email': 'Email', 'settings.notificationPreferences.ntfy': 'Ntfy', + "settings.currency": "Currency", + "settings.currencyHint": "All amounts in Costs are converted to and shown in this currency.", }; + export default settings; diff --git a/shared/src/i18n/nl/trip.ts b/shared/src/i18n/nl/trip.ts index 42930739..b3ced443 100644 --- a/shared/src/i18n/nl/trip.ts +++ b/shared/src/i18n/nl/trip.ts @@ -9,7 +9,7 @@ const trip: TranslationStrings = { 'trip.tabs.packingShort': 'Inpakken', 'trip.tabs.lists': 'Lijsten', 'trip.tabs.listsShort': 'Lijsten', - 'trip.tabs.budget': 'Budget', + 'trip.tabs.budget': "Costs", 'trip.tabs.files': 'Bestanden', 'trip.loading': 'Reis laden...', 'trip.loadingPhotos': 'Plaatsfoto laden...', diff --git a/shared/src/i18n/pl/budget.ts b/shared/src/i18n/pl/budget.ts index 00fd7e44..752acbf1 100644 --- a/shared/src/i18n/pl/budget.ts +++ b/shared/src/i18n/pl/budget.ts @@ -38,5 +38,78 @@ const budget: TranslationStrings = { 'budget.exportCsv': 'Eksportuj CSV', 'budget.table.date': 'Data', 'budget.categoriesLabel': 'kategorie', + "costs.you": "You", + "costs.youShort": "Y", + "costs.youLower": "you", + "costs.youOwe": "You owe", + "costs.youOweSub": "You should pay others", + "costs.youreOwed": "You're owed", + "costs.youreOwedSub": "Others should pay you", + "costs.totalSpend": "Total trip spend", + "costs.totalSpendSub": "Across all travelers", + "costs.to": "To", + "costs.from": "From", + "costs.allSettled": "You're all settled up", + "costs.nothingOwed": "Nothing owed to you", + "costs.yourShare": "Your share", + "costs.youPaid": "You paid", + "costs.expenses": "Expenses", + "costs.entries": "{count} entries", + "costs.searchPlaceholder": "Search expenses…", + "costs.filter.all": "All", + "costs.filter.mine": "Paid by me", + "costs.filter.owed": "I'm owed", + "costs.addExpense": "Add expense", + "costs.editExpense": "Edit expense", + "costs.noMatch": "No expenses match your search.", + "costs.emptyText": "No expenses yet. Add your first one.", + "costs.spent": "{amount} spent", + "costs.noDate": "No date", + "costs.noOnePaid": "No one paid yet", + "costs.youLent": "you lent {amount}", + "costs.youBorrowed": "you borrowed {amount}", + "costs.settleUp": "Settle up", + "costs.history": "History", + "costs.everyoneSquare": "Everyone's square", + "costs.nothingOutstanding": "No payments outstanding right now.", + "costs.pay": "pay", + "costs.pays": "pays", + "costs.settle": "Settle", + "costs.balances": "Balances", + "costs.byCategory": "By category", + "costs.noCategories": "No expenses yet.", + "costs.settleHistory": "Settle history", + "costs.noSettlements": "No settled payments yet.", + "costs.paymentsSettled": "{count} payments settled", + "costs.paid": "paid", + "costs.undo": "Undo", + "costs.whatFor": "What was it for?", + "costs.namePlaceholder": "e.g. Dinner, souvenirs, gas…", + "costs.totalAmount": "Total amount", + "costs.currency": "Currency", + "costs.day": "Day", + "costs.rateLabel": "1 {from} in {to}", + "costs.category": "Category", + "costs.whoPaid": "Who paid?", + "costs.splitBetween": "Split equally between", + "costs.pickSomeone": "Pick at least one person to split with.", + "costs.splitSummary": "Split {count} ways · {amount} each", + "costs.cat.accommodation": "Accommodation", + "costs.cat.food": "Food & drink", + "costs.cat.groceries": "Groceries", + "costs.cat.transport": "Transport", + "costs.cat.flights": "Flights", + "costs.cat.activities": "Activities", + "costs.cat.sightseeing": "Sightseeing", + "costs.cat.shopping": "Shopping", + "costs.cat.fees": "Fees & tickets", + "costs.cat.health": "Health", + "costs.cat.tips": "Tips", + "costs.cat.other": "Other", + "costs.daysCount": "{count} days", + "costs.travelers": "{count} travelers", + "costs.liveRate": "live rate", + "costs.settleAll": "Settle all", }; + export default budget; diff --git a/shared/src/i18n/pl/settings.ts b/shared/src/i18n/pl/settings.ts index 8b1c49b1..8daf8eb7 100644 --- a/shared/src/i18n/pl/settings.ts +++ b/shared/src/i18n/pl/settings.ts @@ -296,5 +296,8 @@ const settings: TranslationStrings = { 'settings.notificationsManagedByAdmin': 'Zdarzenia konfigurowane przez administratora.', 'settings.mustChangePassword': 'Musisz zmienić hasło przed kontynuowaniem.', + "settings.currency": "Currency", + "settings.currencyHint": "All amounts in Costs are converted to and shown in this currency.", }; + export default settings; diff --git a/shared/src/i18n/pl/trip.ts b/shared/src/i18n/pl/trip.ts index c8fc07c0..c2bd8e38 100644 --- a/shared/src/i18n/pl/trip.ts +++ b/shared/src/i18n/pl/trip.ts @@ -9,7 +9,7 @@ const trip: TranslationStrings = { 'trip.tabs.packingShort': 'Pakowanie', 'trip.tabs.lists': 'Listy', 'trip.tabs.listsShort': 'Listy', - 'trip.tabs.budget': 'Budżet', + 'trip.tabs.budget': "Costs", 'trip.tabs.files': 'Pliki', 'trip.loading': 'Ładowanie podróży...', 'trip.mobilePlan': 'Plan', diff --git a/shared/src/i18n/ru/budget.ts b/shared/src/i18n/ru/budget.ts index 7f93b297..03c559c5 100644 --- a/shared/src/i18n/ru/budget.ts +++ b/shared/src/i18n/ru/budget.ts @@ -40,5 +40,78 @@ const budget: TranslationStrings = { 'Нажмите на аватар участника в строке бюджета, чтобы отметить его зелёным — это значит, что он заплатил. Взаиморасчёт покажет, кто кому и сколько должен.', 'budget.netBalances': 'Чистые балансы', 'budget.categoriesLabel': 'категорий', + "costs.you": "You", + "costs.youShort": "Y", + "costs.youLower": "you", + "costs.youOwe": "You owe", + "costs.youOweSub": "You should pay others", + "costs.youreOwed": "You're owed", + "costs.youreOwedSub": "Others should pay you", + "costs.totalSpend": "Total trip spend", + "costs.totalSpendSub": "Across all travelers", + "costs.to": "To", + "costs.from": "From", + "costs.allSettled": "You're all settled up", + "costs.nothingOwed": "Nothing owed to you", + "costs.yourShare": "Your share", + "costs.youPaid": "You paid", + "costs.expenses": "Expenses", + "costs.entries": "{count} entries", + "costs.searchPlaceholder": "Search expenses…", + "costs.filter.all": "All", + "costs.filter.mine": "Paid by me", + "costs.filter.owed": "I'm owed", + "costs.addExpense": "Add expense", + "costs.editExpense": "Edit expense", + "costs.noMatch": "No expenses match your search.", + "costs.emptyText": "No expenses yet. Add your first one.", + "costs.spent": "{amount} spent", + "costs.noDate": "No date", + "costs.noOnePaid": "No one paid yet", + "costs.youLent": "you lent {amount}", + "costs.youBorrowed": "you borrowed {amount}", + "costs.settleUp": "Settle up", + "costs.history": "History", + "costs.everyoneSquare": "Everyone's square", + "costs.nothingOutstanding": "No payments outstanding right now.", + "costs.pay": "pay", + "costs.pays": "pays", + "costs.settle": "Settle", + "costs.balances": "Balances", + "costs.byCategory": "By category", + "costs.noCategories": "No expenses yet.", + "costs.settleHistory": "Settle history", + "costs.noSettlements": "No settled payments yet.", + "costs.paymentsSettled": "{count} payments settled", + "costs.paid": "paid", + "costs.undo": "Undo", + "costs.whatFor": "What was it for?", + "costs.namePlaceholder": "e.g. Dinner, souvenirs, gas…", + "costs.totalAmount": "Total amount", + "costs.currency": "Currency", + "costs.day": "Day", + "costs.rateLabel": "1 {from} in {to}", + "costs.category": "Category", + "costs.whoPaid": "Who paid?", + "costs.splitBetween": "Split equally between", + "costs.pickSomeone": "Pick at least one person to split with.", + "costs.splitSummary": "Split {count} ways · {amount} each", + "costs.cat.accommodation": "Accommodation", + "costs.cat.food": "Food & drink", + "costs.cat.groceries": "Groceries", + "costs.cat.transport": "Transport", + "costs.cat.flights": "Flights", + "costs.cat.activities": "Activities", + "costs.cat.sightseeing": "Sightseeing", + "costs.cat.shopping": "Shopping", + "costs.cat.fees": "Fees & tickets", + "costs.cat.health": "Health", + "costs.cat.tips": "Tips", + "costs.cat.other": "Other", + "costs.daysCount": "{count} days", + "costs.travelers": "{count} travelers", + "costs.liveRate": "live rate", + "costs.settleAll": "Settle all", }; + export default budget; diff --git a/shared/src/i18n/ru/settings.ts b/shared/src/i18n/ru/settings.ts index fb25b3e4..0f59d8c1 100644 --- a/shared/src/i18n/ru/settings.ts +++ b/shared/src/i18n/ru/settings.ts @@ -295,5 +295,8 @@ const settings: TranslationStrings = { 'settings.notificationPreferences.webhook': 'Webhook', 'settings.notificationPreferences.email': 'Email', 'settings.notificationPreferences.ntfy': 'Ntfy', + "settings.currency": "Currency", + "settings.currencyHint": "All amounts in Costs are converted to and shown in this currency.", }; + export default settings; diff --git a/shared/src/i18n/ru/trip.ts b/shared/src/i18n/ru/trip.ts index 335af736..a56ff1f1 100644 --- a/shared/src/i18n/ru/trip.ts +++ b/shared/src/i18n/ru/trip.ts @@ -9,7 +9,7 @@ const trip: TranslationStrings = { 'trip.tabs.packingShort': 'Вещи', 'trip.tabs.lists': 'Списки', 'trip.tabs.listsShort': 'Списки', - 'trip.tabs.budget': 'Бюджет', + 'trip.tabs.budget': "Costs", 'trip.tabs.files': 'Файлы', 'trip.loading': 'Загрузка поездки...', 'trip.loadingPhotos': 'Загрузка фото мест...', diff --git a/shared/src/i18n/tr/budget.ts b/shared/src/i18n/tr/budget.ts index 84d9c3f8..ab913e59 100644 --- a/shared/src/i18n/tr/budget.ts +++ b/shared/src/i18n/tr/budget.ts @@ -39,5 +39,78 @@ const budget: TranslationStrings = { 'Bir bütçe kalemindeki üye avatarına tıklayarak yeşil işaretleyin — bu ödedikleri anlamına gelir. Hesaplaşma kimin kime ne kadar borçlu olduğunu gösterir.', 'budget.netBalances': 'Net Bakiyeler', 'budget.categoriesLabel': 'kategoriler', + "costs.you": "You", + "costs.youShort": "Y", + "costs.youLower": "you", + "costs.youOwe": "You owe", + "costs.youOweSub": "You should pay others", + "costs.youreOwed": "You're owed", + "costs.youreOwedSub": "Others should pay you", + "costs.totalSpend": "Total trip spend", + "costs.totalSpendSub": "Across all travelers", + "costs.to": "To", + "costs.from": "From", + "costs.allSettled": "You're all settled up", + "costs.nothingOwed": "Nothing owed to you", + "costs.yourShare": "Your share", + "costs.youPaid": "You paid", + "costs.expenses": "Expenses", + "costs.entries": "{count} entries", + "costs.searchPlaceholder": "Search expenses…", + "costs.filter.all": "All", + "costs.filter.mine": "Paid by me", + "costs.filter.owed": "I'm owed", + "costs.addExpense": "Add expense", + "costs.editExpense": "Edit expense", + "costs.noMatch": "No expenses match your search.", + "costs.emptyText": "No expenses yet. Add your first one.", + "costs.spent": "{amount} spent", + "costs.noDate": "No date", + "costs.noOnePaid": "No one paid yet", + "costs.youLent": "you lent {amount}", + "costs.youBorrowed": "you borrowed {amount}", + "costs.settleUp": "Settle up", + "costs.history": "History", + "costs.everyoneSquare": "Everyone's square", + "costs.nothingOutstanding": "No payments outstanding right now.", + "costs.pay": "pay", + "costs.pays": "pays", + "costs.settle": "Settle", + "costs.balances": "Balances", + "costs.byCategory": "By category", + "costs.noCategories": "No expenses yet.", + "costs.settleHistory": "Settle history", + "costs.noSettlements": "No settled payments yet.", + "costs.paymentsSettled": "{count} payments settled", + "costs.paid": "paid", + "costs.undo": "Undo", + "costs.whatFor": "What was it for?", + "costs.namePlaceholder": "e.g. Dinner, souvenirs, gas…", + "costs.totalAmount": "Total amount", + "costs.currency": "Currency", + "costs.day": "Day", + "costs.rateLabel": "1 {from} in {to}", + "costs.category": "Category", + "costs.whoPaid": "Who paid?", + "costs.splitBetween": "Split equally between", + "costs.pickSomeone": "Pick at least one person to split with.", + "costs.splitSummary": "Split {count} ways · {amount} each", + "costs.cat.accommodation": "Accommodation", + "costs.cat.food": "Food & drink", + "costs.cat.groceries": "Groceries", + "costs.cat.transport": "Transport", + "costs.cat.flights": "Flights", + "costs.cat.activities": "Activities", + "costs.cat.sightseeing": "Sightseeing", + "costs.cat.shopping": "Shopping", + "costs.cat.fees": "Fees & tickets", + "costs.cat.health": "Health", + "costs.cat.tips": "Tips", + "costs.cat.other": "Other", + "costs.daysCount": "{count} days", + "costs.travelers": "{count} travelers", + "costs.liveRate": "live rate", + "costs.settleAll": "Settle all", }; + export default budget; diff --git a/shared/src/i18n/tr/settings.ts b/shared/src/i18n/tr/settings.ts index 801ba37c..7f2d6673 100644 --- a/shared/src/i18n/tr/settings.ts +++ b/shared/src/i18n/tr/settings.ts @@ -295,5 +295,8 @@ const settings: TranslationStrings = { 'settings.oauth.modal.machineClientUsage': 'Bir jeton alın: grant_type=client_credentials, client_id ve client_secret ile POST /oauth/token. Tarayıcı yok, yenileme belirteci yok.', 'settings.oauth.badge.machine': 'makine', + "settings.currency": "Currency", + "settings.currencyHint": "All amounts in Costs are converted to and shown in this currency.", }; + export default settings; diff --git a/shared/src/i18n/tr/trip.ts b/shared/src/i18n/tr/trip.ts index 53c71343..11bbb5e6 100644 --- a/shared/src/i18n/tr/trip.ts +++ b/shared/src/i18n/tr/trip.ts @@ -9,7 +9,7 @@ const trip: TranslationStrings = { 'trip.tabs.packingShort': 'Paket', 'trip.tabs.lists': 'Listeler', 'trip.tabs.listsShort': 'Liste', - 'trip.tabs.budget': 'Bütçe', + 'trip.tabs.budget': "Costs", 'trip.tabs.files': 'Dosyalar', 'trip.loading': 'Seyahat yükleniyor...', 'trip.loadingPhotos': 'Yer fotoğrafları yükleniyor...', diff --git a/shared/src/i18n/uk/budget.ts b/shared/src/i18n/uk/budget.ts index 22a9a603..15e50057 100644 --- a/shared/src/i18n/uk/budget.ts +++ b/shared/src/i18n/uk/budget.ts @@ -39,5 +39,78 @@ const budget: TranslationStrings = { 'Натисніть на аватар учасника в рядку бюджету, щоб відзначити його зеленим — це означає, що він заплатив. Взаєморозрахунок покаже, хто кому і скільки винен.', 'budget.netBalances': 'Чисті баланси', 'budget.categoriesLabel': 'категорії', + "costs.you": "You", + "costs.youShort": "Y", + "costs.youLower": "you", + "costs.youOwe": "You owe", + "costs.youOweSub": "You should pay others", + "costs.youreOwed": "You're owed", + "costs.youreOwedSub": "Others should pay you", + "costs.totalSpend": "Total trip spend", + "costs.totalSpendSub": "Across all travelers", + "costs.to": "To", + "costs.from": "From", + "costs.allSettled": "You're all settled up", + "costs.nothingOwed": "Nothing owed to you", + "costs.yourShare": "Your share", + "costs.youPaid": "You paid", + "costs.expenses": "Expenses", + "costs.entries": "{count} entries", + "costs.searchPlaceholder": "Search expenses…", + "costs.filter.all": "All", + "costs.filter.mine": "Paid by me", + "costs.filter.owed": "I'm owed", + "costs.addExpense": "Add expense", + "costs.editExpense": "Edit expense", + "costs.noMatch": "No expenses match your search.", + "costs.emptyText": "No expenses yet. Add your first one.", + "costs.spent": "{amount} spent", + "costs.noDate": "No date", + "costs.noOnePaid": "No one paid yet", + "costs.youLent": "you lent {amount}", + "costs.youBorrowed": "you borrowed {amount}", + "costs.settleUp": "Settle up", + "costs.history": "History", + "costs.everyoneSquare": "Everyone's square", + "costs.nothingOutstanding": "No payments outstanding right now.", + "costs.pay": "pay", + "costs.pays": "pays", + "costs.settle": "Settle", + "costs.balances": "Balances", + "costs.byCategory": "By category", + "costs.noCategories": "No expenses yet.", + "costs.settleHistory": "Settle history", + "costs.noSettlements": "No settled payments yet.", + "costs.paymentsSettled": "{count} payments settled", + "costs.paid": "paid", + "costs.undo": "Undo", + "costs.whatFor": "What was it for?", + "costs.namePlaceholder": "e.g. Dinner, souvenirs, gas…", + "costs.totalAmount": "Total amount", + "costs.currency": "Currency", + "costs.day": "Day", + "costs.rateLabel": "1 {from} in {to}", + "costs.category": "Category", + "costs.whoPaid": "Who paid?", + "costs.splitBetween": "Split equally between", + "costs.pickSomeone": "Pick at least one person to split with.", + "costs.splitSummary": "Split {count} ways · {amount} each", + "costs.cat.accommodation": "Accommodation", + "costs.cat.food": "Food & drink", + "costs.cat.groceries": "Groceries", + "costs.cat.transport": "Transport", + "costs.cat.flights": "Flights", + "costs.cat.activities": "Activities", + "costs.cat.sightseeing": "Sightseeing", + "costs.cat.shopping": "Shopping", + "costs.cat.fees": "Fees & tickets", + "costs.cat.health": "Health", + "costs.cat.tips": "Tips", + "costs.cat.other": "Other", + "costs.daysCount": "{count} days", + "costs.travelers": "{count} travelers", + "costs.liveRate": "live rate", + "costs.settleAll": "Settle all", }; + export default budget; diff --git a/shared/src/i18n/uk/settings.ts b/shared/src/i18n/uk/settings.ts index 29d9569a..360adf5d 100644 --- a/shared/src/i18n/uk/settings.ts +++ b/shared/src/i18n/uk/settings.ts @@ -293,5 +293,8 @@ const settings: TranslationStrings = { 'settings.oauth.modal.machineClientUsage': 'Отримати токен: POST /oauth/token з grant_type=client_credentials, client_id і client_secret. Без браузера, без токена оновлення.', 'settings.oauth.badge.machine': 'машина', + "settings.currency": "Currency", + "settings.currencyHint": "All amounts in Costs are converted to and shown in this currency.", }; + export default settings; diff --git a/shared/src/i18n/uk/trip.ts b/shared/src/i18n/uk/trip.ts index 44f3de6a..37f0cd8c 100644 --- a/shared/src/i18n/uk/trip.ts +++ b/shared/src/i18n/uk/trip.ts @@ -9,7 +9,7 @@ const trip: TranslationStrings = { 'trip.tabs.packingShort': 'Речі', 'trip.tabs.lists': 'Списки', 'trip.tabs.listsShort': 'Списки', - 'trip.tabs.budget': 'Бюджет', + 'trip.tabs.budget': "Costs", 'trip.tabs.files': 'Файли', 'trip.loading': 'Завантаження поїздки...', 'trip.loadingPhotos': 'Завантаження фото місць...', diff --git a/shared/src/i18n/zh-TW/budget.ts b/shared/src/i18n/zh-TW/budget.ts index 8a4d0956..a56e9a57 100644 --- a/shared/src/i18n/zh-TW/budget.ts +++ b/shared/src/i18n/zh-TW/budget.ts @@ -38,5 +38,78 @@ const budget: TranslationStrings = { '點選預算專案上的成員頭像將其標記為綠色——表示該成員已付款。結算會顯示誰欠誰多少。', 'budget.netBalances': '淨餘額', 'budget.categoriesLabel': '類別', + "costs.you": "You", + "costs.youShort": "Y", + "costs.youLower": "you", + "costs.youOwe": "You owe", + "costs.youOweSub": "You should pay others", + "costs.youreOwed": "You're owed", + "costs.youreOwedSub": "Others should pay you", + "costs.totalSpend": "Total trip spend", + "costs.totalSpendSub": "Across all travelers", + "costs.to": "To", + "costs.from": "From", + "costs.allSettled": "You're all settled up", + "costs.nothingOwed": "Nothing owed to you", + "costs.yourShare": "Your share", + "costs.youPaid": "You paid", + "costs.expenses": "Expenses", + "costs.entries": "{count} entries", + "costs.searchPlaceholder": "Search expenses…", + "costs.filter.all": "All", + "costs.filter.mine": "Paid by me", + "costs.filter.owed": "I'm owed", + "costs.addExpense": "Add expense", + "costs.editExpense": "Edit expense", + "costs.noMatch": "No expenses match your search.", + "costs.emptyText": "No expenses yet. Add your first one.", + "costs.spent": "{amount} spent", + "costs.noDate": "No date", + "costs.noOnePaid": "No one paid yet", + "costs.youLent": "you lent {amount}", + "costs.youBorrowed": "you borrowed {amount}", + "costs.settleUp": "Settle up", + "costs.history": "History", + "costs.everyoneSquare": "Everyone's square", + "costs.nothingOutstanding": "No payments outstanding right now.", + "costs.pay": "pay", + "costs.pays": "pays", + "costs.settle": "Settle", + "costs.balances": "Balances", + "costs.byCategory": "By category", + "costs.noCategories": "No expenses yet.", + "costs.settleHistory": "Settle history", + "costs.noSettlements": "No settled payments yet.", + "costs.paymentsSettled": "{count} payments settled", + "costs.paid": "paid", + "costs.undo": "Undo", + "costs.whatFor": "What was it for?", + "costs.namePlaceholder": "e.g. Dinner, souvenirs, gas…", + "costs.totalAmount": "Total amount", + "costs.currency": "Currency", + "costs.day": "Day", + "costs.rateLabel": "1 {from} in {to}", + "costs.category": "Category", + "costs.whoPaid": "Who paid?", + "costs.splitBetween": "Split equally between", + "costs.pickSomeone": "Pick at least one person to split with.", + "costs.splitSummary": "Split {count} ways · {amount} each", + "costs.cat.accommodation": "Accommodation", + "costs.cat.food": "Food & drink", + "costs.cat.groceries": "Groceries", + "costs.cat.transport": "Transport", + "costs.cat.flights": "Flights", + "costs.cat.activities": "Activities", + "costs.cat.sightseeing": "Sightseeing", + "costs.cat.shopping": "Shopping", + "costs.cat.fees": "Fees & tickets", + "costs.cat.health": "Health", + "costs.cat.tips": "Tips", + "costs.cat.other": "Other", + "costs.daysCount": "{count} days", + "costs.travelers": "{count} travelers", + "costs.liveRate": "live rate", + "costs.settleAll": "Settle all", }; + export default budget; diff --git a/shared/src/i18n/zh-TW/settings.ts b/shared/src/i18n/zh-TW/settings.ts index 2cbad306..fa9dd650 100644 --- a/shared/src/i18n/zh-TW/settings.ts +++ b/shared/src/i18n/zh-TW/settings.ts @@ -282,5 +282,8 @@ const settings: TranslationStrings = { 'settings.bookingLabels': '預訂路線標籤', 'settings.bookingLabelsHint': '在地圖上顯示車站 / 機場名稱。關閉時僅顯示圖示。', + "settings.currency": "Currency", + "settings.currencyHint": "All amounts in Costs are converted to and shown in this currency.", }; + export default settings; diff --git a/shared/src/i18n/zh-TW/trip.ts b/shared/src/i18n/zh-TW/trip.ts index 5a3cb76f..2409a157 100644 --- a/shared/src/i18n/zh-TW/trip.ts +++ b/shared/src/i18n/zh-TW/trip.ts @@ -9,7 +9,7 @@ const trip: TranslationStrings = { 'trip.tabs.packingShort': '行李', 'trip.tabs.lists': '清單', 'trip.tabs.listsShort': '清單', - 'trip.tabs.budget': '預算', + 'trip.tabs.budget': "Costs", 'trip.tabs.files': '檔案', 'trip.loading': '載入旅行中...', 'trip.loadingPhotos': '正在載入地點照片...', diff --git a/shared/src/i18n/zh/budget.ts b/shared/src/i18n/zh/budget.ts index 884c29d6..c7a57c72 100644 --- a/shared/src/i18n/zh/budget.ts +++ b/shared/src/i18n/zh/budget.ts @@ -38,5 +38,78 @@ const budget: TranslationStrings = { '点击预算项目上的成员头像将其标记为绿色——表示该成员已付款。结算会显示谁欠谁多少。', 'budget.netBalances': '净余额', 'budget.categoriesLabel': '类别', + "costs.you": "You", + "costs.youShort": "Y", + "costs.youLower": "you", + "costs.youOwe": "You owe", + "costs.youOweSub": "You should pay others", + "costs.youreOwed": "You're owed", + "costs.youreOwedSub": "Others should pay you", + "costs.totalSpend": "Total trip spend", + "costs.totalSpendSub": "Across all travelers", + "costs.to": "To", + "costs.from": "From", + "costs.allSettled": "You're all settled up", + "costs.nothingOwed": "Nothing owed to you", + "costs.yourShare": "Your share", + "costs.youPaid": "You paid", + "costs.expenses": "Expenses", + "costs.entries": "{count} entries", + "costs.searchPlaceholder": "Search expenses…", + "costs.filter.all": "All", + "costs.filter.mine": "Paid by me", + "costs.filter.owed": "I'm owed", + "costs.addExpense": "Add expense", + "costs.editExpense": "Edit expense", + "costs.noMatch": "No expenses match your search.", + "costs.emptyText": "No expenses yet. Add your first one.", + "costs.spent": "{amount} spent", + "costs.noDate": "No date", + "costs.noOnePaid": "No one paid yet", + "costs.youLent": "you lent {amount}", + "costs.youBorrowed": "you borrowed {amount}", + "costs.settleUp": "Settle up", + "costs.history": "History", + "costs.everyoneSquare": "Everyone's square", + "costs.nothingOutstanding": "No payments outstanding right now.", + "costs.pay": "pay", + "costs.pays": "pays", + "costs.settle": "Settle", + "costs.balances": "Balances", + "costs.byCategory": "By category", + "costs.noCategories": "No expenses yet.", + "costs.settleHistory": "Settle history", + "costs.noSettlements": "No settled payments yet.", + "costs.paymentsSettled": "{count} payments settled", + "costs.paid": "paid", + "costs.undo": "Undo", + "costs.whatFor": "What was it for?", + "costs.namePlaceholder": "e.g. Dinner, souvenirs, gas…", + "costs.totalAmount": "Total amount", + "costs.currency": "Currency", + "costs.day": "Day", + "costs.rateLabel": "1 {from} in {to}", + "costs.category": "Category", + "costs.whoPaid": "Who paid?", + "costs.splitBetween": "Split equally between", + "costs.pickSomeone": "Pick at least one person to split with.", + "costs.splitSummary": "Split {count} ways · {amount} each", + "costs.cat.accommodation": "Accommodation", + "costs.cat.food": "Food & drink", + "costs.cat.groceries": "Groceries", + "costs.cat.transport": "Transport", + "costs.cat.flights": "Flights", + "costs.cat.activities": "Activities", + "costs.cat.sightseeing": "Sightseeing", + "costs.cat.shopping": "Shopping", + "costs.cat.fees": "Fees & tickets", + "costs.cat.health": "Health", + "costs.cat.tips": "Tips", + "costs.cat.other": "Other", + "costs.daysCount": "{count} days", + "costs.travelers": "{count} travelers", + "costs.liveRate": "live rate", + "costs.settleAll": "Settle all", }; + export default budget; diff --git a/shared/src/i18n/zh/settings.ts b/shared/src/i18n/zh/settings.ts index fc7f60a8..8b45fc57 100644 --- a/shared/src/i18n/zh/settings.ts +++ b/shared/src/i18n/zh/settings.ts @@ -281,5 +281,8 @@ const settings: TranslationStrings = { 'settings.notificationPreferences.webhook': 'Webhook', 'settings.notificationPreferences.email': 'Email', 'settings.notificationPreferences.ntfy': 'Ntfy', + "settings.currency": "Currency", + "settings.currencyHint": "All amounts in Costs are converted to and shown in this currency.", }; + export default settings; diff --git a/shared/src/i18n/zh/trip.ts b/shared/src/i18n/zh/trip.ts index 1a2eee7d..9df70d17 100644 --- a/shared/src/i18n/zh/trip.ts +++ b/shared/src/i18n/zh/trip.ts @@ -9,7 +9,7 @@ const trip: TranslationStrings = { 'trip.tabs.packingShort': '行李', 'trip.tabs.lists': '列表', 'trip.tabs.listsShort': '列表', - 'trip.tabs.budget': '预算', + 'trip.tabs.budget': "Costs", 'trip.tabs.files': '文件', 'trip.loading': '加载旅行中...', 'trip.loadingPhotos': '正在加载地点照片...', diff --git a/wiki/Environment-Variables.md b/wiki/Environment-Variables.md index 80282b81..2935f86f 100644 --- a/wiki/Environment-Variables.md +++ b/wiki/Environment-Variables.md @@ -22,6 +22,7 @@ Complete reference for all environment variables TREK reads. | `TZ` | Timezone for logs, reminders, and cron jobs (e.g. `Europe/Berlin`) | `UTC` | | `LOG_LEVEL` | `info` = concise user actions; `debug` = verbose details | `info` | | `DEFAULT_LANGUAGE` | Default language on the login page — see supported codes below | `en` | +| `SESSION_DURATION` | How long a login session stays valid before re-login is required. Applies to both the `trek_session` JWT `exp` claim and the cookie `maxAge`, so they never drift apart. Accepts `ms`-style strings: `1h`, `12h`, `7d`, `30d`, `90d`. Invalid values warn at startup and fall back to the default. Does not affect the short-lived MFA challenge token or MCP OAuth tokens (those keep their own TTL). | `24h` | | `ALLOWED_ORIGINS` | Comma-separated origins for CORS and email notification links | same-origin | | `ALLOW_INTERNAL_NETWORK` | Allow outbound requests to private/RFC-1918 IPs. Set `true` if Immich or other integrated services are on your local network. Loopback (`127.x`) and link-local (`169.254.x`) addresses remain blocked regardless. | `false` | | `APP_URL` | Public base URL (e.g. `https://trek.example.com`). Required when OIDC is enabled — must match the redirect URI registered with your IdP. Also used as the base URL for email notification links. | — |