feat: quality of life sweep from the feature discussions

Small requests from the discussions, each built end to end: planner and
map (cycling mode, date first headings, clear a day, stay times per day,
off-route stops, map lock, compact unplanned markers, pending stays,
cable car, transit quick picks), places (location filter, category from
OSM, open in maps, geo: link, image from an attachment, enrichment on
file import, places near a pin, place name language, Amap first in
China), lists and costs (packed counter, inherited sharing, Markdown
to-dos, payment notes, bookings CSV, my plan PDF), sharing (unplanned
places, travel only, without photos), journey (photo zoom, drafts,
photo order, trip suggestion, day jump), atlas (region counter, region
bucket list, places per country), vacay half company holidays, studio
copy and paste plus the book's own layouts, and RESTORE_FROM_BACKUP for
a first start restore.
This commit is contained in:
Maurice
2026-09-30 23:00:12 +02:00
parent 91dfab3f4b
commit 8fd46c4c18
691 changed files with 9491 additions and 927 deletions
+16 -1
View File
@@ -1,6 +1,6 @@
// FE-UTIL-DAYLABEL-001 to FE-UTIL-DAYLABEL-003
import { describe, it, expect } from 'vitest'
import { dayLabel } from './dayLabel'
import { dayHeadingParts, dayLabel } from './dayLabel'
const t = (key: string, params?: Record<string, string | number>) => `${key}:${params?.n}`
@@ -20,3 +20,18 @@ describe('dayLabel', () => {
expect(dayLabel({ title: null, date: 'not-a-date' }, 4, t, 'en-US')).toBe('dayplan.dayN:5')
})
})
describe('dayHeadingParts', () => {
it('FE-UTIL-DAYLABEL-004: the name leads by default and the date follows', () => {
expect(dayHeadingParts('Day 2', 'Mon, Aug 17', false)).toEqual({ primary: 'Day 2', secondary: 'Mon, Aug 17' })
})
it('FE-UTIL-DAYLABEL-005: planning by the calendar puts the date first', () => {
expect(dayHeadingParts('Day 2', 'Mon, Aug 17', true)).toEqual({ primary: 'Mon, Aug 17', secondary: 'Day 2' })
})
it('FE-UTIL-DAYLABEL-006: a day without a date keeps its name in front either way', () => {
expect(dayHeadingParts('Day 2', null, true)).toEqual({ primary: 'Day 2', secondary: null })
expect(dayHeadingParts('Day 2', '', false)).toEqual({ primary: 'Day 2', secondary: null })
})
})
+10
View File
@@ -28,3 +28,13 @@ export function dayDate(iso: string, locale: string): string | null {
if (Number.isNaN(date.getTime())) return null
return date.toLocaleDateString(locale, { weekday: 'short', day: 'numeric', month: 'short' })
}
/**
* A day heading's two parts in reading order. The day's name ("Day 3" or its own
* title) leads, unless the traveller plans by the calendar and asked for the date
* first (#1953). A day without a date always leads with its name.
*/
export function dayHeadingParts(name: string, date: string | null | undefined, dateFirst: boolean): { primary: string; secondary: string | null } {
if (dateFirst && date) return { primary: date, secondary: name }
return { primary: name, secondary: date || null }
}
+1 -1
View File
@@ -5,7 +5,7 @@ import { rideSeatAfter, sameDayRide, type CarrierBooking, type SeatItem } from '
// the two drift.
import { orderedEndpoints } from './flightLegs'
export const TRANSPORT_TYPES = new Set(['flight', 'train', 'bus', 'car', 'taxi', 'bicycle', 'cruise', 'ferry', 'transit', 'transport_other'])
export const TRANSPORT_TYPES = new Set(['flight', 'train', 'bus', 'car', 'taxi', 'bicycle', 'cruise', 'ferry', 'cable_car', 'transit', 'transport_other'])
export interface MergedItem {
type: 'place' | 'note' | 'transport'
+33
View File
@@ -0,0 +1,33 @@
import type { Accommodation, Reservation } from '../types'
/**
* The stays whose booking is not confirmed yet (#2281), by accommodation id.
*
* Read the way every other booking pill reads it: confirmed is confirmed, anything
* else still waits. A stay without a booking at all is not pending, it is simply a
* place to sleep that nobody booked through TREK.
*/
export function pendingStayIds(reservations: Pick<Reservation, 'accommodation_id' | 'status'>[]): Set<number> {
const ids = new Set<number>()
for (const r of reservations) {
if (r.accommodation_id != null && r.status !== 'confirmed') ids.add(Number(r.accommodation_id))
}
return ids
}
/** The places of those stays, for the map to draw their markers as not yet certain. */
export function pendingStayPlaceIds(
accommodations: Pick<Accommodation, 'id' | 'place_id'>[],
reservations: Pick<Reservation, 'accommodation_id' | 'status'>[],
): Set<number> {
const pending = pendingStayIds(reservations)
const ids = new Set<number>()
for (const acc of accommodations) {
if (acc.place_id != null && pending.has(acc.id)) ids.add(acc.place_id)
}
// A place that also hosts a confirmed stay is certain enough to draw plainly.
for (const acc of accommodations) {
if (acc.place_id != null && !pending.has(acc.id) && reservations.some(r => Number(r.accommodation_id) === acc.id && r.status === 'confirmed')) ids.delete(acc.place_id)
}
return ids
}
+19
View File
@@ -0,0 +1,19 @@
// FE-UTIL-LOCALITY-001 to FE-UTIL-LOCALITY-003
import { describe, it, expect } from 'vitest'
import { placeLocality } from './placeLocality'
describe('placeLocality', () => {
it('FE-UTIL-LOCALITY-001: a resolved place is named in the reader language with its region', () => {
expect(placeLocality({ country_code: 'DE', region_name: 'Brandenburg' }, 'de')).toEqual({ country: 'Deutschland', region: 'Brandenburg' })
expect(placeLocality({ country_code: 'fr', region_name: 'Île-de-France' }, 'en')).toEqual({ country: 'France', region: 'Île-de-France' })
})
it('FE-UTIL-LOCALITY-002: an unresolved place falls back to the country its address ends on', () => {
expect(placeLocality({ address: 'Pariser Platz, 10117 Berlin, Germany' }, 'en')).toEqual({ country: 'Germany', region: null })
expect(placeLocality({ address: 'Mildred-Harnack-Straße 11, 10243 Berlin' }, 'en')).toEqual({ country: null, region: null })
})
it('FE-UTIL-LOCALITY-003: nothing to go on gives nulls', () => {
expect(placeLocality({ address: null }, 'en')).toEqual({ country: null, region: null })
})
})
+43
View File
@@ -0,0 +1,43 @@
export interface PlaceLocality {
/** The country's name in the reader's language, or as the address spells it. */
country: string | null
/** State, province or prefecture, as resolved from the position. */
region: string | null
}
interface LocatedPlace {
address?: string | null
country_code?: string | null
region_name?: string | null
}
const hasDigits = (part: string) => /\d/.test(part)
const hasLetters = (part: string) => /\p{L}/u.test(part)
/** The last part of a formatted address is the country, when it reads like one. */
function countryFromAddress(address: string | null | undefined): string | null {
const parts = (address ?? '').split(',').map(p => p.trim()).filter(Boolean)
if (parts.length < 2) return null
const last = parts[parts.length - 1]
return !hasDigits(last) && hasLetters(last) ? last : null
}
/**
* Where a place lies, for filtering the trip's places (#2537).
*
* Both halves come from the position the server resolved, which is exact: the country
* code, named in the reader's language, and the region. A city is not offered, since a
* formatted address cannot tell one apart reliably (it names the borough before Berlin
* and the county before Schönefeld). A place not resolved yet falls back to the country
* its address ends on.
*/
export function placeLocality(place: LocatedPlace, language: string): PlaceLocality {
let country: string | null = null
if (place.country_code) {
try { country = new Intl.DisplayNames([language], { type: 'region' }).of(place.country_code.toUpperCase()) ?? null } catch { country = null }
}
return {
country: country ?? countryFromAddress(place.address),
region: place.region_name || null,
}
}