fix: follow-ups from the review of the price, Immich, moving day and import fixes

Saved place price (#2471)
- A price above zero that would be left without a currency is refused
  with a 400, over REST and through update_collection_place alike. A list
  has no base currency, so such an amount was read in each viewer's own
  default currency and went into a trip as the trip's. A price sent alone
  keeps the stored currency, and a zero price or clearing both fields
  still goes through. The check sits in the service because the MCP tool
  spreads the contract's shape, where a refine would never arrive.
- An older row that holds an amount without a currency shows it as a
  bare number instead of in the viewer's own currency.
- The Hungarian hint uses the informal register of its file, which is
  now prettier clean.

Immich certificate switch (#2475)
- The switch trusts one server. Saving a different URL without sending
  it turns it off; saving the same URL keeps it as before. The wiki says
  so.

Moving day (#2476)
- Google Maps and CoMaps are left out only when there is nothing to hand
  over. A day with a single stop opens it as a pin again, on the desktop
  and on the phone, as it did before.
- Desktop: a moving day whose flight or train has no stations shows no
  route tools, since there is nothing left to draw. With stations they
  stay.
- Phone: the drive between two stays on a day without stops or bookings
  shows once at the top instead of again at the bottom.
- The export helpers take the carrier flags as one object instead of two
  booleans in a row.

Import card (#2477)
- The success and error marks use the theme tokens.
- The e2e test names its upload with a made-up booking number.

Tests: COLLECTIONS-SVC-132 and 133, COLLECTIONS-E2E-088,
update_collection_place with a price and no currency, FE-COMP-COLEXTRAS-013
(011 adjusted), IMMICH-TLS-010, IMMICH-103 extended,
FE-PLANNER-DAYPLAN-222 adjusted and 224, FE-MOB-PLTL-051, FE-MOB-PTLM-047,
FE-W4BGT-023 and 024 check the mark colours.
This commit is contained in:
Maurice
2026-09-23 17:39:25 +02:00
parent 0df82185bd
commit a910c2a837
22 changed files with 269 additions and 84 deletions
@@ -47,18 +47,18 @@ describe('dayExportStops', () => {
it('FE-MOBILE-DAYROUTE-006: with a carrier on the day, the not-yet-reached check-in hotel is not prepended (#2157)', () => {
// Arrival day: you fly in, so the hotel may end the list but must not start it.
const stops = dayExportStops(days[0], days, [homeNear], [hotel], true, true)
const stops = dayExportStops(days[0], days, [homeNear], [hotel], true, { located: true })
expect(stops.map(s => s.name)).toEqual(['Home', 'Hotel Lutetia'])
})
it('FE-MOBILE-DAYROUTE-007: with a carrier on the day, the already-left check-out hotel is not appended (#2157)', () => {
// Check-out day: the hotel starts the day, nothing routes back to it after the flight home.
const stops = dayExportStops(days[2], days, [homeNear], [hotel], true, true)
const stops = dayExportStops(days[2], days, [homeNear], [hotel], true, { located: true })
expect(stops.map(s => s.name)).toEqual(['Hotel Lutetia', 'Home'])
})
it('FE-MOBILE-DAYROUTE-008: without a carrier the no-time loop still closes (#2009 preserved)', () => {
const stops = dayExportStops(days[0], days, [louvre], [hotel], true, false)
const stops = dayExportStops(days[0], days, [louvre], [hotel], true, { located: false })
expect(stops.map(s => s.name)).toEqual(['Hotel Lutetia', 'Louvre', 'Hotel Lutetia'])
})
@@ -77,16 +77,16 @@ describe('dayExportStops', () => {
it('FE-MOBILE-DAYROUTE-010: a flight between the two stays leaves nothing to export (#2476)', () => {
// Located or not, the flight is how the day gets from one hotel to the other; a
// driving route between them is the stretch nobody drives.
expect(dayExportStops(days[1], days, [], [hotelA, hotelB], true, true, true)).toEqual([])
expect(dayExportStops(days[1], days, [], [hotelA, hotelB], true, false, true)).toEqual([])
expect(dayGoogleMapsUrl(days[1], days, [], [hotelA, hotelB], true, false, true)).toBeNull()
expect(dayCoMapsUrl(days[1], days, [], [hotelA, hotelB], true, 'driving', false, true)).toBeNull()
expect(dayExportStops(days[1], days, [], [hotelA, hotelB], true, { located: true, booked: true })).toEqual([])
expect(dayExportStops(days[1], days, [], [hotelA, hotelB], true, { booked: true })).toEqual([])
expect(dayGoogleMapsUrl(days[1], days, [], [hotelA, hotelB], true, { booked: true })).toBeNull()
expect(dayCoMapsUrl(days[1], days, [], [hotelA, hotelB], true, 'driving', { booked: true })).toBeNull()
})
it('FE-MOBILE-DAYROUTE-011: without a booking the move is still the drive from one stay to the next (#1297)', () => {
expect(dayExportStops(days[1], days, [], [hotelA, hotelB], true).map(s => s.name)).toEqual(['Hotel A', 'Hotel B'])
// A stop of its own keeps the day exportable even with the flight booked.
expect(dayExportStops(days[1], days, [louvre], [hotelA, hotelB], true, true, true).map(s => s.name)).toContain('Louvre')
expect(dayExportStops(days[1], days, [louvre], [hotelA, hotelB], true, { located: true, booked: true }).map(s => s.name)).toContain('Louvre')
})
})
+24 -18
View File
@@ -60,16 +60,25 @@ export function optimizeDayOrder(
return { order, usedHotel: Boolean(anchors.start || anchors.end) }
}
/**
* What the day's carrier bookings (flight, train, ferry, coach) tell the
* exports. `located`: one has an endpoint with coordinates on the day, which
* keeps the no-time bookend default from opening a leg out of a hotel only
* reached tonight or back into one already left (#2157), same as the drawn
* route. `booked`: one is booked on the day at all, located or not, which
* leaves a day without stops with nothing to export: the hotels at either end
* are joined by that booking, not by a road (#2476).
*/
export interface DayCarrier {
located?: boolean
booked?: boolean
}
/**
* The day's located stops in planned order, bookended by the morning/evening
* accommodation exactly like the drawn route. Names ride along for the deep
* links that can label a pin with one. `dayHasCarrier` — a carrier transport
* with a located endpoint on the day — keeps the no-time bookend default from
* opening a leg out of a hotel only reached tonight or back into one already
* left (#2157), same as the drawn route. `dayHasCarrierBooking`, any carrier
* booked on the day with or without coordinates, leaves a day without stops
* with nothing to export: the hotels at either end are joined by that booking,
* not by a road (#2476).
* links that can label a pin with one. See DayCarrier for what the day's
* carrier bookings change.
*/
export function dayExportStops(
day: Day,
@@ -77,11 +86,10 @@ export function dayExportStops(
dayAssignments: Assignment[],
accommodations: Accommodation[],
bookendFromAccommodation: boolean,
dayHasCarrier?: boolean,
dayHasCarrierBooking?: boolean,
carrier: DayCarrier = {},
): NamedWaypoint[] {
const located = dayAssignments.filter(a => a.place?.lat != null && a.place?.lng != null)
if (located.length === 0 && dayHasCarrierBooking) return []
if (located.length === 0 && carrier.booked) return []
const stops = located.map(a => ({ lat: a.place!.lat!, lng: a.place!.lng!, name: a.place!.name }))
const bookends = bookendFromAccommodation ? getDayBookendHotels(day, days, accommodations) : null
const firstStop = located[0]
@@ -91,10 +99,10 @@ export function dayExportStops(
const lastStop = last
? { isPlace: true, time: last.place?.place_time ?? null, lat: last.place!.lat!, lng: last.place!.lng! }
: undefined
const morning = bookends && shouldDrawMorningLeg(bookends, day, firstStop, dayHasCarrier)
const morning = bookends && shouldDrawMorningLeg(bookends, day, firstStop, carrier.located)
&& bookends.morning?.place_lat != null && bookends.morning?.place_lng != null
? { lat: bookends.morning.place_lat, lng: bookends.morning.place_lng, name: bookends.morning.place_name } : null
const evening = bookends && shouldDrawEveningLeg(bookends, day, lastStop, dayHasCarrier)
const evening = bookends && shouldDrawEveningLeg(bookends, day, lastStop, carrier.located)
&& bookends.evening?.place_lat != null && bookends.evening?.place_lng != null
? { lat: bookends.evening.place_lat, lng: bookends.evening.place_lng, name: bookends.evening.place_name } : null
return [...(morning ? [morning] : []), ...stops, ...(evening ? [evening] : [])]
@@ -107,11 +115,10 @@ export function dayGoogleMapsUrl(
dayAssignments: Assignment[],
accommodations: Accommodation[],
bookendFromAccommodation: boolean,
dayHasCarrier?: boolean,
dayHasCarrierBooking?: boolean,
carrier: DayCarrier = {},
): string | null {
return generateGoogleMapsUrl(
dayExportStops(day, days, dayAssignments, accommodations, bookendFromAccommodation, dayHasCarrier, dayHasCarrierBooking),
dayExportStops(day, days, dayAssignments, accommodations, bookendFromAccommodation, carrier),
) || null
}
@@ -123,11 +130,10 @@ export function dayCoMapsUrl(
accommodations: Accommodation[],
bookendFromAccommodation: boolean,
profile: RouteProfileKey,
dayHasCarrier?: boolean,
dayHasCarrierBooking?: boolean,
carrier: DayCarrier = {},
): string | null {
return generateCoMapsUrl(
dayExportStops(day, days, dayAssignments, accommodations, bookendFromAccommodation, dayHasCarrier, dayHasCarrierBooking),
dayExportStops(day, days, dayAssignments, accommodations, bookendFromAccommodation, carrier),
profile,
) || null
}
@@ -208,7 +208,12 @@ export function hotelLegsForDay(
: [...routeSegments].reverse().find(s => sameCoord(s.to, coord))
return seg ? { seg, name: accommodationName(a) } : null
}
return { top: legAt(morning, 'from'), bottom: legAt(evening, 'to') }
const top = legAt(morning, 'from')
const bottom = legAt(evening, 'to')
// A moving day without stops is one drive from one hotel to the next (#1297): it
// both leaves the morning hotel and reaches the evening one, so it shows once, at
// the top, instead of again at the bottom (#2476).
return { top, bottom: top && bottom && bottom.seg === top.seg ? null : bottom }
}
/** The day headline as city pills — a "Tokyo → Kyoto" title becomes two pills with an arrow. */
@@ -5,7 +5,7 @@ import { assignmentsApi, reservationsApi, weatherApi } from '../../../../api/cli
import { usePluginStore } from '../../../../store/pluginStore'
import { getDayBookendHotels } from '../../../../utils/dayOrder'
import { getDisplayTimeForDay, getMergedItems, getTransportForDay, hasCarrierEndpointOnDay, isCarrierTransport } from '../../../../utils/dayMerge'
import { dayCoMapsUrl, dayExportStops, dayGoogleMapsUrl, optimizeDayOrder } from '../lib/dayRoute'
import { dayCoMapsUrl, dayExportStops, dayGoogleMapsUrl, optimizeDayOrder, type DayCarrier } from '../lib/dayRoute'
import { buildTransitLeg, buildTransitNameIndex, type TransitLeg } from '../../../../components/Planner/transitLeg'
import {
buildPlanRows, breaksChronology, findUpNext, hotelChipsForDay, hotelLegsForDay, itemHasTime,
@@ -60,11 +60,11 @@ export function useMPlanTimeline(planner: TripPlanner) {
() => !!day && merged.some(it => it.type === 'transport' && hasCarrierEndpointOnDay(it.data, day.id)),
[day, merged],
)
// Any carrier booked today, located or not. On a day without stops it is the move
// itself, so the exports have no road to hand over (#2476).
const dayHasCarrierBooking = useMemo(
() => merged.some(it => it.type === 'transport' && isCarrierTransport(it.data)),
[merged],
// Plus any carrier booked today, located or not. On a day without stops it is the
// move itself, so the exports have no road to hand over (#2476).
const dayCarrier = useMemo<DayCarrier>(
() => ({ located: dayHasCarrier, booked: merged.some(it => it.type === 'transport' && isCarrierTransport(it.data)) }),
[dayHasCarrier, merged],
)
// Travel-time connectors (walk · distance · drive between consecutive places)
@@ -324,14 +324,14 @@ export function useMPlanTimeline(planner: TripPlanner) {
}
}, [day, dayAssignments, days, tripAccommodations, settings, dayHasCarrier, tripActions, tripId, pushUndo, updateRouteForDay, toast, t])
// A route needs two ends. With fewer the map hand-offs would open a lone pin or
// nothing at all, so the plan does not offer them (#2476).
// With no stop left to hand over, such as a moving day that only holds its flight,
// the map hand-offs would open nothing, so the plan does not offer them. A single
// stop still opens as a pin (#2476).
const canExportRoute = useMemo(
() => !!day && dayExportStops(
day, days, dayAssignments, tripAccommodations, settings.optimize_from_accommodation !== false,
dayHasCarrier, dayHasCarrierBooking,
).length >= 2,
[day, days, dayAssignments, tripAccommodations, settings, dayHasCarrier, dayHasCarrierBooking],
day, days, dayAssignments, tripAccommodations, settings.optimize_from_accommodation !== false, dayCarrier,
).length > 0,
[day, days, dayAssignments, tripAccommodations, settings, dayCarrier],
)
const exportGoogleMaps = useCallback(() => {
@@ -339,20 +339,19 @@ export function useMPlanTimeline(planner: TripPlanner) {
// Bookend the exported route with the day's accommodation the same way the
// drawn route does — only when the leg is real (#1372, #1465).
const url = dayGoogleMapsUrl(
day, days, dayAssignments, tripAccommodations, settings.optimize_from_accommodation !== false,
dayHasCarrier, dayHasCarrierBooking,
day, days, dayAssignments, tripAccommodations, settings.optimize_from_accommodation !== false, dayCarrier,
)
if (url) window.open(url, '_blank', 'noopener,noreferrer')
}, [day, dayAssignments, days, tripAccommodations, settings, dayHasCarrier, dayHasCarrierBooking])
}, [day, dayAssignments, days, tripAccommodations, settings, dayCarrier])
const exportCoMaps = useCallback(() => {
if (!day) return
const url = dayCoMapsUrl(
day, days, dayAssignments, tripAccommodations, settings.optimize_from_accommodation !== false,
day.default_transport_mode ?? routeProfile, dayHasCarrier, dayHasCarrierBooking,
day.default_transport_mode ?? routeProfile, dayCarrier,
)
if (url) window.open(url, '_blank', 'noopener,noreferrer')
}, [day, dayAssignments, days, tripAccommodations, settings, routeProfile, dayHasCarrier, dayHasCarrierBooking])
}, [day, dayAssignments, days, tripAccommodations, settings, routeProfile, dayCarrier])
const renameDay = useCallback((title: string) => {
if (!day) return
@@ -200,7 +200,7 @@ export default function MDaySheet({ planner, shell }: MTripSheetsProps) {
if (!day) return
const url = dayGoogleMapsUrl(
day, planner.days, dayAssignments, planner.tripAccommodations, optimizeFromAccommodation !== false,
dayHasCarrier,
{ located: dayHasCarrier },
)
if (url) window.open(url, '_blank', 'noopener,noreferrer')
}
@@ -210,7 +210,7 @@ export default function MDaySheet({ planner, shell }: MTripSheetsProps) {
if (!day) return
const url = dayCoMapsUrl(
day, planner.days, dayAssignments, planner.tripAccommodations, optimizeFromAccommodation !== false,
day.default_transport_mode ?? planner.routeProfile, dayHasCarrier,
day.default_transport_mode ?? planner.routeProfile, { located: dayHasCarrier },
)
if (url) window.open(url, '_blank', 'noopener,noreferrer')
}