mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-24 16:03:02 +00:00
3bd17ce1f9
Day headers, the plan sidebar footer and the PDF day/cover totals summed
raw place prices across currencies and labeled the result with a single
currency — a $2,730.27 hotel on a NOK trip read as "2730 NOK".
Totals now convert every amount into a base currency via the existing
frankfurter rates (sidebar: the user's display currency, falling back to
the trip's; PDF: the trip currency, resolved once before rendering so the
document is consistent), marked with "≈" when a conversion happened. When
a rate is unavailable (offline, blocked egress, unknown code) they fall
back to an honest per-currency breakdown ("2 500 kr + $2,730.27") instead
of folding foreign amounts into a mislabeled number. All-same-currency
trips make no FX request, so offline PDF export keeps working.
Also swaps the place inspector's hardcoded € chip icon for a neutral one
and formats the price via formatMoney in the place's own currency.
Note: day-header totals move from "50 EUR" to Intl formatting ("50 €").
92 lines
3.4 KiB
TypeScript
92 lines
3.4 KiB
TypeScript
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 api.frankfurter.dev (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<string, { rates: Record<string, number>; ts: number }>()
|
|
|
|
function readCache(base: string): { rates: Record<string, number>; 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<string, number>; ts: number }
|
|
if (parsed?.rates) { mem.set(base, parsed); return parsed }
|
|
}
|
|
} catch { /* ignore */ }
|
|
return null
|
|
}
|
|
|
|
/**
|
|
* Plain-function twin of the hook, for non-React callers (PDF export). Never
|
|
* rejects: a fresh cache short-circuits, otherwise it fetches and caches; on
|
|
* any failure it returns the stale cache if one exists, else null ("no rates" —
|
|
* callers fall back to per-currency breakdowns rather than converting).
|
|
*/
|
|
export async function fetchExchangeRates(base: string): Promise<Record<string, number> | null> {
|
|
const upper = (base || 'EUR').toUpperCase()
|
|
const cached = readCache(upper)
|
|
if (cached && Date.now() - cached.ts < TTL_MS) return cached.rates
|
|
try {
|
|
const d = await fetch(`https://api.frankfurter.dev/v2/rates?base=${encodeURIComponent(upper)}`)
|
|
.then(r => r.json()) as Array<{ quote?: string; rate?: number }>
|
|
if (!Array.isArray(d)) return cached?.rates ?? null
|
|
// Frankfurter omits the base's own self-rate, so seed it with `base = 1`.
|
|
const rates: Record<string, number> = { [upper]: 1 }
|
|
for (const r of d) {
|
|
if (r && typeof r.quote === 'string' && typeof r.rate === 'number') rates[r.quote] = r.rate
|
|
}
|
|
const entry = { rates, ts: Date.now() }
|
|
mem.set(upper, entry)
|
|
try { localStorage.setItem('trek_fx_' + upper, JSON.stringify(entry)) } catch { /* ignore */ }
|
|
return rates
|
|
} catch {
|
|
return cached?.rates ?? null // offline → stale beats nothing
|
|
}
|
|
}
|
|
|
|
/** Test-only: the module-level cache outlives a vitest file's individual tests. */
|
|
export function clearExchangeRateCache(): void {
|
|
mem.clear()
|
|
try {
|
|
for (const k of Object.keys(localStorage)) {
|
|
if (k.startsWith('trek_fx_')) localStorage.removeItem(k)
|
|
}
|
|
} catch { /* ignore */ }
|
|
}
|
|
|
|
export function useExchangeRates(base: string) {
|
|
const upper = (base || 'EUR').toUpperCase()
|
|
const [rates, setRates] = useState<Record<string, number> | 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
|
|
fetchExchangeRates(upper).then(r => {
|
|
if (!cancelled && r) setRates(r)
|
|
})
|
|
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 }
|
|
}
|