mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-22 23:16:46 +00:00
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:
@@ -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 })
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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 }
|
||||
}
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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 })
|
||||
})
|
||||
})
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user