File size: 5,927 Bytes
cd99321
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
import type { AssignmentsMap } from '../types'

// Collapses verbose Nominatim display_name strings (e.g. "Place, 1, Road, Neighbourhood,
// City, County, State, Country, Postcode, Country") into "Place, Postcode, Country".
// Clean short names (≤3 parts) pass through untouched.
export function formatLocationName(raw: string | null | undefined): string {
  if (!raw) return ''
  const parts = raw.split(',').map(p => p.trim()).filter(Boolean)
  if (parts.length <= 3) return raw.trim()

  // Dedup preserving insertion order
  const seen = new Set<string>()
  const unique: string[] = []
  for (const p of parts) {
    if (!seen.has(p.toLowerCase())) { seen.add(p.toLowerCase()); unique.push(p) }
  }
  if (unique.length <= 3) return unique.join(', ')

  const name = unique[0]
  const last = unique[unique.length - 1]
  const secondLast = unique.length >= 2 ? unique[unique.length - 2] : null

  // Detect postcode at tail: short alphanumeric with at least one digit, ≤10 chars
  const postalRe = /^[A-Z0-9][A-Z0-9\s\-]{1,8}$/i
  const isLastPostal = postalRe.test(last) && /\d/.test(last) && last.length <= 10
  const postcode = isLastPostal ? last : null
  const country = isLastPostal ? secondLast : last

  const result: string[] = [name]
  if (postcode && postcode !== name) result.push(postcode)
  if (country && country !== name && country !== postcode) result.push(country)

  return result.join(', ')
}

const ZERO_DECIMAL_CURRENCIES = new Set(['JPY', 'KRW', 'VND', 'CLP', 'ISK', 'HUF'])

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<string, string> = {
  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 "<number> 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 = {
    weekday: 'short', day: 'numeric', month: 'short',
    timeZone: timeZone || 'UTC',
  }
  return new Date(dateStr + 'T00:00:00Z').toLocaleDateString(locale, opts)
}

export function formatTime(timeStr: string | null | undefined, locale: string, timeFormat: string): string {
  if (!timeStr) return ''
  try {
    const parts = timeStr.split(':')
    const h = Number(parts[0]) || 0
    const m = Number(parts[1]) || 0
    if (isNaN(h)) return timeStr
    if (timeFormat === '12h') {
      const period = h >= 12 ? 'PM' : 'AM'
      const h12 = h === 0 ? 12 : h > 12 ? h - 12 : h
      return `${h12}:${String(m).padStart(2, '0')} ${period}`
    }
    const str = `${String(h).padStart(2, '0')}:${String(m).padStart(2, '0')}`
    return locale?.startsWith('de') ? `${str} Uhr` : str
  } catch { return timeStr }
}

export function splitReservationDateTime(value?: string | null): { date: string | null; time: string | null } {
  if (!value) return { date: null, time: null }
  const isoDate = /^\d{4}-\d{2}-\d{2}$/
  if (value.includes('T')) {
    const [d, t] = value.split('T')
    return { date: isoDate.test(d) ? d : null, time: t ? t.slice(0, 5) : null }
  }
  if (isoDate.test(value)) return { date: value, time: null }
  if (/^\d{1,2}:\d{2}/.test(value)) return { date: null, time: value.slice(0, 5) }
  return { date: null, time: null }
}

export function dayTotalCost(dayId: number, assignments: AssignmentsMap, currency: string): string | null {
  const da = assignments[String(dayId)] || []
  const total = da.reduce((s, a) => s + (parseFloat(String(a.place?.price ?? '')) || 0), 0)
  return total > 0 ? `${total.toFixed(0)} ${currency}` : null
}