v3.0.22 Bug Fixes & Improvements (#1041)

Bundles the v3.0.22 bug fixes and improvements. See the release notes for the full list.
This commit is contained in:
Julien G.
2026-05-25 01:13:20 +02:00
committed by GitHub
parent 75772445a7
commit 86ee8044da
54 changed files with 1110 additions and 234 deletions
+17 -1
View File
@@ -57,11 +57,27 @@ describe('getTransportForDay', () => {
{ id: 3, day_number: 3 },
]
it('excludes non-transport types', () => {
it('excludes hotel (rendered via accommodation path)', () => {
const reservations = [{ id: 10, type: 'hotel', day_id: 1 }]
expect(getTransportForDay({ reservations, dayId: 1, dayAssignmentIds: [], days })).toHaveLength(0)
})
it('includes tour booking on the correct day', () => {
const reservations = [{ id: 20, type: 'tour', day_id: 1 }]
expect(getTransportForDay({ reservations, dayId: 1, dayAssignmentIds: [], days })).toHaveLength(1)
expect(getTransportForDay({ reservations, dayId: 2, dayAssignmentIds: [], days })).toHaveLength(0)
})
it('includes restaurant, event, and other bookings by day_id', () => {
const reservations = [
{ id: 30, type: 'restaurant', day_id: 2 },
{ id: 31, type: 'event', day_id: 2 },
{ id: 32, type: 'other', day_id: 2 },
]
expect(getTransportForDay({ reservations, dayId: 2, dayAssignmentIds: [], days })).toHaveLength(3)
expect(getTransportForDay({ reservations, dayId: 1, dayAssignmentIds: [], days })).toHaveLength(0)
})
it('includes single-day transport on the correct day', () => {
const reservations = [{ id: 10, type: 'flight', day_id: 1, end_day_id: 1 }]
expect(getTransportForDay({ reservations, dayId: 1, dayAssignmentIds: [], days })).toHaveLength(1)
+1 -1
View File
@@ -55,7 +55,7 @@ export function getTransportForDay(opts: {
const thisDayOrder = getDayOrder(dayId)
return reservations.filter(r => {
if (!TRANSPORT_TYPES.has(r.type)) return false
if (r.type === 'hotel') return false
if (r.assignment_id && dayAssignmentIds.includes(r.assignment_id)) return false
const startDayId = r.day_id
+50
View File
@@ -0,0 +1,50 @@
import { describe, it, expect } from 'vitest'
import { splitReservationDateTime } from './formatters'
describe('splitReservationDateTime', () => {
it('parses full ISO datetime', () => {
expect(splitReservationDateTime('2026-06-25T10:00')).toEqual({ date: '2026-06-25', time: '10:00' })
})
it('parses full datetime with seconds', () => {
expect(splitReservationDateTime('2026-06-25T10:00:30')).toEqual({ date: '2026-06-25', time: '10:00' })
})
it('parses date-only string', () => {
expect(splitReservationDateTime('2026-06-25')).toEqual({ date: '2026-06-25', time: null })
})
it('parses bare HH:MM (new dateless format)', () => {
expect(splitReservationDateTime('10:00')).toEqual({ date: null, time: '10:00' })
})
it('parses bare single-digit hour time', () => {
expect(splitReservationDateTime('9:30')).toEqual({ date: null, time: '9:30' })
})
it('handles legacy malformed T-prefixed time ("T10:00")', () => {
expect(splitReservationDateTime('T10:00')).toEqual({ date: null, time: '10:00' })
})
it('returns null date for T-prefixed without valid date', () => {
const result = splitReservationDateTime('T23:59')
expect(result.date).toBeNull()
expect(result.time).toBe('23:59')
})
it('returns nulls for null input', () => {
expect(splitReservationDateTime(null)).toEqual({ date: null, time: null })
})
it('returns nulls for undefined input', () => {
expect(splitReservationDateTime(undefined)).toEqual({ date: null, time: null })
})
it('returns nulls for empty string', () => {
expect(splitReservationDateTime('')).toEqual({ date: null, time: null })
})
it('returns nulls for unrecognized string', () => {
expect(splitReservationDateTime('garbage')).toEqual({ date: null, time: null })
})
})
+12
View File
@@ -65,6 +65,18 @@ export function formatTime(timeStr: string | null | undefined, locale: string, t
} 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(a.place?.price || '') || 0), 0)
+106
View File
@@ -0,0 +1,106 @@
import type { AxiosProgressEvent } from 'axios'
export interface UploadProgress {
done: number
total: number
failed: number
percent: number
}
export interface ResilientResult<T> {
succeeded: T[]
failed: File[]
}
export interface UploadOpts {
onUploadProgress: (e: AxiosProgressEvent) => void
idempotencyKey: string
}
const sleep = (ms: number) => new Promise<void>(r => setTimeout(r, ms))
function isRetryable(err: unknown): boolean {
if (err && typeof err === 'object' && 'response' in err) {
const status = (err as { response?: { status?: number } }).response?.status
if (status !== undefined && status >= 400 && status < 500) return false
}
return true
}
export async function uploadFilesResilient<T>(
files: File[],
uploadOne: (file: File, opts: UploadOpts) => Promise<T[]>,
cbs?: {
concurrency?: number
retries?: number
onProgress?: (p: UploadProgress) => void
onUploaded?: (items: T[]) => void
},
): Promise<ResilientResult<T>> {
const concurrency = cbs?.concurrency ?? 3
const maxRetries = cbs?.retries ?? 2
const totalBytes = files.reduce((s, f) => s + f.size, 0)
const loadedMap = new Map<number, number>()
let doneCount = 0
let failedCount = 0
const emitProgress = () => {
if (!cbs?.onProgress) return
const sumLoaded = Array.from(loadedMap.values()).reduce((a, b) => a + b, 0)
const percent = totalBytes > 0 ? Math.round((sumLoaded / totalBytes) * 100) : 0
cbs.onProgress({ done: doneCount, total: files.length, failed: failedCount, percent })
}
const succeeded: T[] = []
const failedFiles: File[] = []
let idx = 0
async function worker() {
while (true) {
const i = idx++
if (i >= files.length) break
const file = files[i]
const idempotencyKey = crypto.randomUUID()
loadedMap.set(i, 0)
let items: T[] | null = null
for (let attempt = 0; attempt <= maxRetries; attempt++) {
if (attempt > 0) await sleep(400 * attempt)
try {
items = await uploadOne(file, {
idempotencyKey,
onUploadProgress: (e) => {
loadedMap.set(i, e.loaded)
emitProgress()
},
})
break
} catch (err) {
if (!isRetryable(err) || attempt === maxRetries) {
items = null
break
}
}
}
if (items !== null) {
succeeded.push(...items)
cbs?.onUploaded?.(items)
loadedMap.set(i, file.size)
doneCount++
} else {
failedFiles.push(file)
loadedMap.set(i, 0)
failedCount++
}
emitProgress()
}
}
const workers = Array.from({ length: Math.min(concurrency, files.length) }, () => worker())
await Promise.all(workers)
return { succeeded, failed: failedFiles }
}