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
+19 -5
View File
@@ -2,7 +2,7 @@ import { isEffectivelyOffline } from '../sync/networkMode'
import axios, { AxiosInstance } from 'axios'
import type { z } from 'zod'
import type { Day, Place, Trip } from '../types'
import type { TransitProvider } from '@trek/shared'
import type { MapsNearbyRequest, JourneyReorderEntryPhotosRequest, TransitProvider } from '@trek/shared'
import { randomId } from '../utils/randomId'
import { postProviderPhotosInBatches } from './providerPhotoBatches'
import {
@@ -28,13 +28,13 @@ import {
type PackingReorderRequest, type PackingCreateBagRequest, type TodoReorderRequest,
type TripCreateRequest, type TripUpdateRequest, type TripCopyRequest, type ActiveTripResponse,
type DayCreateRequest, type DayUpdateRequest, type DayReorderRequest,
type PlaceCreateRequest, type PlaceUpdateRequest,
type PlaceCreateRequest, type PlaceUpdateRequest, type PlaceImageFromFileRequest,
type ReservationCreateRequest, type ReservationUpdateRequest,
type AccommodationCreateRequest, type AccommodationUpdateRequest,
type BudgetCreateItemRequest, type BudgetUpdateItemRequest,
type PackingCreateItemRequest, type PackingUpdateItemRequest, type PackingSetSharingRequest,
type TodoCreateItemRequest, type TodoUpdateItemRequest,
type AssignmentCreateRequest, type AssignmentNotesRequest, type AssignmentParticipantsRequest, type AssignmentTimeRequest, type AssignmentTransportRequest,
type AssignmentCreateRequest, type AssignmentNotesRequest, type AssignmentParticipantsRequest, type AssignmentRouteRequest, type AssignmentTimeRequest, type AssignmentTransportRequest,
type PlaceBulkDeleteRequest,
type PlaceBulkUpdateRequest,
type DayNoteCreateRequest, type DayNoteUpdateRequest,
@@ -471,23 +471,28 @@ export const placesApi = {
fd.append('image', file)
return postMultipart<{ place: Place }>(`/trips/${tripId}/places/${id}/image`, fd)
},
// A picture already attached in the trip, copied in as the place's image (#1242).
imageFromFile: (tripId: number | string, id: number | string, fileId: number): Promise<{ place: Place }> =>
apiClient.put(`/trips/${tripId}/places/${id}/image/from-file`, { file_id: fileId } satisfies PlaceImageFromFileRequest).then(r => r.data),
rate: (tripId: number | string, id: number | string, rating: number | null): Promise<{ place: Place }> =>
rating === null
? apiClient.delete(`/trips/${tripId}/places/${id}/rating`).then(r => r.data)
: apiClient.put(`/trips/${tripId}/places/${id}/rating`, { rating }).then(r => r.data),
importGpx: (tripId: number | string, file: File, opts?: { waypoints?: boolean; routes?: boolean; tracks?: boolean }) => {
importGpx: (tripId: number | string, file: File, opts?: { waypoints?: boolean; routes?: boolean; tracks?: boolean; enrich?: boolean }) => {
const fd = new FormData()
fd.append('file', file)
if (opts?.waypoints !== undefined) fd.append('importWaypoints', String(opts.waypoints))
if (opts?.routes !== undefined) fd.append('importRoutes', String(opts.routes))
if (opts?.tracks !== undefined) fd.append('importTracks', String(opts.tracks))
if (opts?.enrich) fd.append('enrich', 'true')
return postMultipart(`/trips/${tripId}/places/import/gpx`, fd)
},
importMapFile: (tripId: number | string, file: File, opts?: { points?: boolean; paths?: boolean }) => {
importMapFile: (tripId: number | string, file: File, opts?: { points?: boolean; paths?: boolean; enrich?: boolean }) => {
const fd = new FormData()
fd.append('file', file)
if (opts?.points !== undefined) fd.append('importPoints', String(opts.points))
if (opts?.paths !== undefined) fd.append('importPaths', String(opts.paths))
if (opts?.enrich) fd.append('enrich', 'true')
return postMultipart(`/trips/${tripId}/places/import/map`, fd)
},
// A longer timeout than the shared 8 s, like the other routes here that wait
@@ -511,12 +516,16 @@ export const assignmentsApi = {
list: (tripId: number | string, dayId: number | string) => apiClient.get(`/trips/${tripId}/days/${dayId}/assignments`).then(r => r.data),
create: (tripId: number | string, dayId: number | string, data: AssignmentCreateRequest) => apiClient.post(`/trips/${tripId}/days/${dayId}/assignments`, data).then(r => r.data),
delete: (tripId: number | string, dayId: number | string, id: number) => apiClient.delete(`/trips/${tripId}/days/${dayId}/assignments/${id}`).then(r => r.data),
// Takes every place off the day at once; the day, its notes and bookings stay (#2470).
clearDay: (tripId: number | string, dayId: number | string) => apiClient.delete(`/trips/${tripId}/days/${dayId}/assignments`).then(r => r.data as { success: true; removedIds: number[] }),
reorder: (tripId: number | string, dayId: number | string, orderedIds: number[]) => apiClient.put(`/trips/${tripId}/days/${dayId}/assignments/reorder`, { orderedIds } satisfies AssignmentReorderRequest).then(r => r.data),
move: (tripId: number | string, assignmentId: number, newDayId: number | string, orderIndex: number | null) => apiClient.put(`/trips/${tripId}/assignments/${assignmentId}/move`, { new_day_id: newDayId, order_index: orderIndex }).then(r => r.data),
update: (tripId: number | string, dayId: number | string, id: number, data: Record<string, unknown>) => apiClient.put(`/trips/${tripId}/days/${dayId}/assignments/${id}`, data).then(r => r.data),
getParticipants: (tripId: number | string, id: number) => apiClient.get(`/trips/${tripId}/assignments/${id}/participants`).then(r => r.data),
setParticipants: (tripId: number | string, id: number, userIds: number[]) => apiClient.put(`/trips/${tripId}/assignments/${id}/participants`, { user_ids: userIds } satisfies AssignmentParticipantsRequest).then(r => r.data),
updateTime: (tripId: number | string, id: number, times: AssignmentTimeRequest) => apiClient.put(`/trips/${tripId}/assignments/${id}/time`, times).then(r => r.data),
// Keeps the stop on the day but out of its route (#2532).
setRouteExcluded: (tripId: number | string, id: number, excluded: boolean) => apiClient.put(`/trips/${tripId}/assignments/${id}/route`, { excluded } satisfies AssignmentRouteRequest).then(r => r.data),
// Day-specific note on an assignment (#2163) — null clears it.
updateNotes: (tripId: number | string, id: number, data: AssignmentNotesRequest) => apiClient.put(`/trips/${tripId}/assignments/${id}/notes`, data).then(r => r.data),
// Per-segment travel mode (#1281): mode of the leg leaving this stop (null = inherit day default).
@@ -1033,6 +1042,8 @@ export const journeyApi = {
unlinkPhoto: (entryId: number, journeyPhotoId: number) => apiClient.delete(`/journeys/entries/${entryId}/photos/${journeyPhotoId}`).then(r => r.data),
deleteGalleryPhoto: (journeyId: number, journeyPhotoId: number) => apiClient.delete(`/journeys/${journeyId}/gallery/${journeyPhotoId}`).then(r => r.data),
updatePhoto: (photoId: number, data: Record<string, unknown>) => apiClient.patch(`/journeys/photos/${photoId}`, data).then(r => r.data),
// The photos of one entry in their new order, in one request (#824).
reorderEntryPhotos: (entryId: number, orderedIds: number[]) => apiClient.put(`/journeys/entries/${entryId}/photos/reorder`, { orderedIds } satisfies JourneyReorderEntryPhotosRequest).then(r => r.data),
deletePhoto: (photoId: number) => apiClient.delete(`/journeys/photos/${photoId}`).then(r => r.data),
// Cover
@@ -1253,6 +1264,9 @@ export const mapsApi = {
const from = [...new Set(extra.map(p => String(p.source ?? 'plugin')))].join('+')
return { places: [...core.places, ...extra], source: `${core.source}+${from}` }
}),
/** Places of any kind around a point, nearest first, each with `distance_m` (#976). */
nearby: (lat: number, lng: number, lang?: string) =>
apiClient.post(`/maps/nearby?lang=${lang || 'en'}`, { lat, lng } satisfies MapsNearbyRequest).then(r => checkInDev(mapsSearchResultSchema, r.data, 'maps.nearby')),
autocomplete: (input: string, lang?: string, locationBias?: { low: { lat: number; lng: number }; high: { lat: number; lng: number } }, signal?: AbortSignal, sessionToken?: string) =>
withCachedPlaces(
input,
@@ -0,0 +1,32 @@
// FE-ATLAS-PLACES-001 to FE-ATLAS-PLACES-002 (#2174)
import { vi } from 'vitest'
import { render, screen, fireEvent } from '../../../tests/helpers/render'
import AtlasCountryPlaces from './AtlasCountryPlaces'
const detail = {
trips: [{ id: 1, title: 'Berlin 2026' }],
places: Array.from({ length: 8 }, (_, i) => ({ id: i + 1, name: `Place ${i + 1}`, lat: 0, lng: 0, trip_id: 1, address: i === 0 ? 'Pariser Platz' : null })),
}
describe('AtlasCountryPlaces', () => {
it('FE-ATLAS-PLACES-001: lists the places under their trip and opens the trip on a click', () => {
const onOpenTrip = vi.fn()
render(<AtlasCountryPlaces detail={detail} onOpenTrip={onOpenTrip} />)
expect(screen.getByText('Berlin 2026')).toBeInTheDocument()
expect(screen.getByText('Pariser Platz')).toBeInTheDocument()
fireEvent.click(screen.getByText('Place 3'))
expect(onOpenTrip).toHaveBeenCalledWith(1)
})
it('FE-ATLAS-PLACES-002: narrows a long list and says when nothing matches', () => {
render(<AtlasCountryPlaces detail={detail} onOpenTrip={() => {}} variant="mobile" />)
const search = screen.getByRole('textbox', { name: 'Search places' })
fireEvent.change(search, { target: { value: 'Place 2' } })
expect(screen.getByText('Place 2')).toBeInTheDocument()
expect(screen.queryByText('Place 3')).toBeNull()
fireEvent.change(search, { target: { value: 'zzz' } })
expect(screen.getByText('No place matches')).toBeInTheDocument()
fireEvent.click(screen.getByRole('button', { name: 'Clear' }))
expect(screen.getByText('Place 3')).toBeInTheDocument()
})
})
@@ -0,0 +1,85 @@
import { useState } from 'react'
import { Briefcase, ChevronRight, MapPin, Search, X } from 'lucide-react'
import { useTranslation } from '../../i18n'
import { groupCountryPlaces, type CountryDetail } from '../../pages/atlas/atlasModel'
interface AtlasCountryPlacesProps {
detail: Pick<CountryDetail, 'places' | 'trips'>
onOpenTrip: (tripId: number) => void
/** Phone sheets use their own tokens; the desktop dialog reads the planner's. */
variant?: 'desktop' | 'mobile'
}
/**
* Every place counted for a country, by trip (#2174), so an old trip imported into
* the wrong country, or a place that is missing, can be found and opened. A search
* narrows long lists; a click opens the trip the place belongs to.
*/
export default function AtlasCountryPlaces({ detail, onOpenTrip, variant = 'desktop' }: AtlasCountryPlacesProps) {
const { t, locale } = useTranslation()
const [query, setQuery] = useState('')
const groups = groupCountryPlaces(detail, query, locale)
const mobile = variant === 'mobile'
const ink = mobile ? 'text-m-ink' : 'text-content'
const muted = mobile ? 'text-m-muted' : 'text-content-muted'
const faint = mobile ? 'text-m-faint' : 'text-content-faint'
const rowHover = mobile ? 'active:bg-[color:var(--m-ic)]' : 'hover:bg-surface-hover'
const placeholder = mobile ? 'placeholder:text-m-faint' : 'placeholder:text-content-faint'
return (
<div className="flex min-h-0 flex-col gap-3">
{detail.places.length > 6 && (
<div className={`flex h-9 flex-none items-center gap-2 rounded-[10px] px-3 ${mobile ? 'bg-[color:var(--m-ic)]' : 'border border-edge bg-surface-input'} ${faint}`}>
<Search size={14} strokeWidth={2} className="flex-none" />
<input
value={query}
onChange={e => setQuery(e.target.value)}
placeholder={t('atlas.placesSearch')}
aria-label={t('atlas.placesSearch')}
className={`min-w-0 flex-1 border-0 bg-transparent text-[0.8125rem] outline-none ${ink} ${placeholder}`}
/>
{query && (
<button type="button" onClick={() => setQuery('')} aria-label={t('common.clear')} className={`flex-none ${faint}`}>
<X size={13} />
</button>
)}
</div>
)}
{groups.length === 0 ? (
<p className={`m-0 py-4 text-center text-[0.8125rem] ${faint}`}>{t('atlas.placesNone')}</p>
) : (
<div className="flex flex-col gap-3">
{groups.map(({ trip, places }) => (
<section key={trip.id} className="flex flex-col gap-0.5">
<button
type="button"
onClick={() => onOpenTrip(trip.id)}
className={`group flex items-center gap-2 rounded-[10px] px-2 py-1.5 text-left ${rowHover}`}
>
<Briefcase size={13} strokeWidth={2} className={`flex-none ${muted}`} />
<span className={`min-w-0 flex-1 truncate text-[0.8125rem] font-bold ${ink}`}>{trip.title}</span>
<span className={`flex-none rounded-full px-1.5 text-[0.6875rem] font-semibold tabular-nums ${mobile ? 'bg-[color:var(--m-ic)]' : 'bg-surface-tertiary'} ${muted}`}>{places.length}</span>
<ChevronRight size={14} strokeWidth={2} className={`flex-none ${faint}`} />
</button>
{places.map(place => (
<button
key={place.id}
type="button"
onClick={() => onOpenTrip(trip.id)}
className={`flex items-start gap-2 rounded-[10px] py-1.5 pl-7 pr-2 text-left ${rowHover}`}
>
<MapPin size={12} strokeWidth={2} className={`mt-[3px] flex-none ${faint}`} />
<span className="min-w-0 flex-1">
<span className={`block truncate text-[0.8125rem] font-medium ${ink}`}>{place.name}</span>
{place.address && <span className={`block truncate text-[0.6875rem] ${faint}`}>{place.address}</span>}
</span>
</button>
))}
</section>
))}
</div>
)}
</div>
)
}
+33 -21
View File
@@ -58,6 +58,8 @@ interface Settlement {
// was recorded). Null/absent on rows predating this field — settlementDate()
// falls back to created_at for those.
settled_at?: string | null
/** A free-text note on the payment (#2340). */
note?: string | null
from_username?: string
to_username?: string
}
@@ -117,7 +119,8 @@ export default function CostsPanel({ tripId, tripMembers = [] }: CostsPanelProps
const [previewReceipts, setPreviewReceipts] = useState<{ receipts: BudgetItemReceipt[]; initialIndex: number } | null>(null)
// One note open at a time: two expanded rows next to each other read as a mess,
// and the point of the collapse is that the list stays scannable.
const [expandedNoteId, setExpandedNoteId] = useState<number | null>(null)
// Keyed 'e<id>' for an expense and 's<id>' for a payment, whose ids are counted apart.
const [expandedNoteId, setExpandedNoteId] = useState<string | null>(null)
// One open final-budget breakdown at a time, for the same reason a single note
// is expanded at a time: the card is a sidebar, not a report.
const [expandedFinalId, setExpandedFinalId] = useState<number | null>(null)
@@ -798,7 +801,6 @@ export default function CostsPanel({ tripId, tripMembers = [] }: CostsPanelProps
const net = round2(myPaidOf(e) - myShareOf(e))
const unfinished = isUnfinished(e)
const note = readUserNote(e)
const open = expandedNoteId === e.id
// Fixed columns, identical on every row, so the eye can run down the list
// instead of re-finding each field on each line. A row without a note leaves
@@ -891,21 +893,7 @@ export default function CostsPanel({ tripId, tripMembers = [] }: CostsPanelProps
{/* The note, marked by a rule in the category colour instead of a box, so
it reads as part of the row rather than something dropped onto it. */}
{!isMobile && (
note ? (
<Tooltip label={open ? t('costs.hideNote') : t('costs.showNote')}>
<button type="button" onClick={() => setExpandedNoteId(open ? null : e.id)}
aria-expanded={open}
className="bg-surface-card border border-edge-faint text-content-muted hover:text-content hover:border-content-faint transition-colors exp-note-btn"
style={{ display: 'flex', alignItems: open ? 'flex-start' : 'center', gap: 9, minWidth: 0, width: '100%', padding: open ? '10px 14px 11px 13px' : '7px 12px 7px 11px', borderRadius: open ? 14 : 999, borderLeft: '3px solid ' + c.color, cursor: 'pointer', fontFamily: 'inherit', fontSize: 'calc(12.5px * var(--fs-scale-body, 1))', lineHeight: 1.5, textAlign: 'left' }}>
<span style={open
? { whiteSpace: 'pre-wrap', overflowWrap: 'anywhere', minWidth: 0, flex: 1 }
: { whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis', minWidth: 0, flex: 1 }}>{note}</span>
<ChevronDown size={13} style={{ flexShrink: 0, marginTop: open ? 3 : 0, transition: 'transform .18s ease', transform: open ? 'rotate(180deg)' : 'none' }} />
</button>
</Tooltip>
) : <span />
)}
{!isMobile && NoteCell({ note, noteKey: 'e' + e.id, color: c.color })}
{/* The money, and what to do with it. */}
<div style={{ display: 'flex', alignItems: 'center', gap: 10, alignSelf: 'center' }}>
@@ -924,6 +912,25 @@ export default function CostsPanel({ tripId, tripMembers = [] }: CostsPanelProps
)
}
// A row's note as a pill that opens in place; an empty one keeps the column.
function NoteCell({ note, noteKey, color }: { note: string; noteKey: string; color: string }) {
if (!note) return <span />
const open = expandedNoteId === noteKey
return (
<Tooltip label={open ? t('costs.hideNote') : t('costs.showNote')}>
<button type="button" onClick={() => setExpandedNoteId(open ? null : noteKey)}
aria-expanded={open}
className="bg-surface-card border border-edge-faint text-content-muted hover:text-content hover:border-content-faint transition-colors exp-note-btn"
style={{ display: 'flex', alignItems: open ? 'flex-start' : 'center', gap: 9, minWidth: 0, width: '100%', padding: open ? '10px 14px 11px 13px' : '7px 12px 7px 11px', borderRadius: open ? 14 : 999, borderLeft: '3px solid ' + color, cursor: 'pointer', fontFamily: 'inherit', fontSize: 'calc(12.5px * var(--fs-scale-body, 1))', lineHeight: 1.5, textAlign: 'left' }}>
<span style={open
? { whiteSpace: 'pre-wrap', overflowWrap: 'anywhere', minWidth: 0, flex: 1 }
: { whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis', minWidth: 0, flex: 1 }}>{note}</span>
<ChevronDown size={13} style={{ flexShrink: 0, marginTop: open ? 3 : 0, transition: 'transform .18s ease', transform: open ? 'rotate(180deg)' : 'none' }} />
</button>
</Tooltip>
)
}
// A settle-up payment as a ledger row — visually distinct from an expense, with
// inline edit + undo (reuses deleteSettlement) so it isn't buried in a modal.
function SettlementRow({ s }: { s: Settlement }) {
@@ -947,9 +954,9 @@ export default function CostsPanel({ tripId, tripMembers = [] }: CostsPanelProps
</div>
</Tooltip>
</div>
{/* A payment carries no note, but it keeps the column so its amount lands
on the same axis as every expense above it. */}
{!isMobile && <span />}
{/* The payment's note (#2340), in the column every expense keeps for its own,
so its amount lands on the same axis as every expense above it. */}
{!isMobile && NoteCell({ note: s.note || '', noteKey: 's' + s.id, color: '#16a34a' })}
<div style={{ display: 'flex', alignItems: 'center', gap: 10, alignSelf: 'center' }}>
<span className="bg-surface-card border border-edge-faint text-content" style={{ display: 'inline-flex', alignItems: 'center', padding: '6px 13px', borderRadius: 999, fontSize: 'calc(15px * var(--fs-scale-subtitle, 1))', fontWeight: 700, whiteSpace: 'nowrap', fontVariantNumeric: 'tabular-nums' }}>{fmt(settled(s))}</span>
</div>
@@ -1220,6 +1227,7 @@ function SettlementModal({ tripId, people, me, editing, currency, tripCurrency,
const [amount, setAmount] = useState<string>(editing ? amountToInputString(editing.amount, (editing.currency || currency).toUpperCase()) : '')
const [cur, setCur] = useState<string>((editing?.currency || currency).toUpperCase())
const [day, setDay] = useState(editing ? settlementDate(editing) : localToday())
const [note, setNote] = useState(editing?.note || '')
const [saving, setSaving] = useState(false)
const amt = Number.parseFloat(amount) || 0
@@ -1229,7 +1237,7 @@ function SettlementModal({ tripId, people, me, editing, currency, tripCurrency,
const save = async () => {
if (!valid) return
setSaving(true)
const data = withFallbackFx({ from_user_id: Number(fromId), to_user_id: Number(toId), amount: amt, currency: cur, settled_at: day }, tripCurrency)
const data = withFallbackFx({ from_user_id: Number(fromId), to_user_id: Number(toId), amount: amt, currency: cur, settled_at: day, note: note.trim() || null }, tripCurrency)
try {
if (editing) await budgetApi.updateSettlement(tripId, editing.id, data)
else await budgetApi.createSettlement(tripId, data)
@@ -1294,6 +1302,10 @@ function SettlementModal({ tripId, people, me, editing, currency, tripCurrency,
<EditorField label={t('costs.day')}>
<CustomDatePicker value={day} onChange={setDay} style={{ width: '100%' }} />
</EditorField>
<EditorField label={t('costs.note')} htmlFor="payment-note">
<textarea id="payment-note" value={note} onChange={e => setNote(e.target.value)} rows={2}
placeholder={t('costs.paymentNotePlaceholder')} maxLength={NOTE_MAX} className={`${TEXTAREA} resize-y`} />
</EditorField>
</DialogShell>
)
}
@@ -42,7 +42,7 @@ export const COST_CATEGORY_LIST: CostCategoryMeta[] = COST_CATEGORIES.map(k => C
const LEGACY_CATEGORY_MAP: Record<string, CostCategory> = {
flight: 'flights', flights: 'flights', plane: 'flights', flug: 'flights',
train: 'transport', bus: 'transport', car: 'transport', 'car rental': 'transport',
ferry: 'transport', boat: 'transport', taxi: 'transport', transfer: 'transport',
ferry: 'transport', boat: 'transport', taxi: 'transport', transfer: 'transport', cable_car: 'transport',
transport: 'transport', transportation: 'transport',
hotel: 'accommodation', accommodation: 'accommodation', lodging: 'accommodation', hostel: 'accommodation',
restaurant: 'food', food: 'food', dining: 'food', meal: 'food', meals: 'food',
@@ -17,6 +17,7 @@ import { getApiErrorMessage } from '../../types'
import { normalizeLinkUrl, STATUS_META, STATUS_ORDER } from '../../pages/collections/collectionsModel'
import type { Category, TranslationFn } from '../../types'
import type { CollectionLink, CollectionStatus } from '@trek/shared'
import { usePlaceLanguage } from '../../hooks/usePlaceLanguage'
type MapsPlace = Record<string, unknown>
const str = (v: unknown): string | undefined => (typeof v === 'string' && v ? v : undefined)
@@ -54,6 +55,7 @@ interface AddPlaceToCollectionModalProps {
*/
export default function AddPlaceToCollectionModal({ isOpen, collectionId, collectionName, categories, onClose, onAdded, t }: AddPlaceToCollectionModalProps): React.ReactElement {
const { language } = useTranslation()
const placeLang = usePlaceLanguage()
const toast = useToast()
const [query, setQuery] = useState('')
const [results, setResults] = useState<MapsPlace[]>([])
@@ -87,7 +89,7 @@ export default function AddPlaceToCollectionModal({ isOpen, collectionId, collec
setSearching(true)
setNoResults(false)
try {
const res = await mapsApi.search(query, language)
const res = await mapsApi.search(query, placeLang)
const places = (res.places as MapsPlace[]) || []
setResults(places)
setNoResults(places.length === 0)
@@ -12,7 +12,7 @@ import { mapsApi } from '../../api/client'
import { entityGradient } from '../../utils/gradients'
import { getCategoryIcon } from '../shared/categoryIcons'
import { NavigationMenu } from '../shared/NavigationMenu'
import { getNavigationTargets, openNavigationTarget } from '../Planner/placeNavigation'
import { navigationTargetLabel, getNavigationTargets, openNavigationTarget } from '../Planner/placeNavigation'
import { STATUS_META, STATUS_ORDER, normalizeLinkUrl } from '../../pages/collections/collectionsModel'
import { useToast } from '../shared/Toast'
import { Tooltip } from '../shared/Tooltip'
@@ -373,7 +373,7 @@ export default function CollectionPlaceDetail({
}}
>
<Navigation size={14} />
{navigationTargets.length === 1 ? navigationTargets[0].label : t('inspector.navigation')}
{navigationTargets.length === 1 ? navigationTargetLabel(navigationTargets[0], t) : t('inspector.navigation')}
</button>
{navOpen && (
<NavigationMenu
@@ -1 +1 @@
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'])
@@ -1,4 +1,4 @@
import { FileText, FileImage, File, FileVideo, Plane, Train, Car, Ship, Bus, Sailboat, Bike, CarTaxiFront, Route } from 'lucide-react'
import { FileText, FileImage, File, FileVideo, Plane, Train, Car, Ship, Bus, Sailboat, CableCar, Bike, CarTaxiFront, Route } from 'lucide-react'
import { downloadFile } from '../../utils/fileDownload'
export function isImage(mimeType?: string | null) {
@@ -72,6 +72,7 @@ export function transportIcon(type: string) {
if (type === 'bicycle') return Bike
if (type === 'cruise') return Ship
if (type === 'ferry') return Sailboat
if (type === 'cable_car') return CableCar
if (type === 'transport_other') return Route
return Plane
}
@@ -0,0 +1,46 @@
// FE-JOURNEY-DAYJUMP-001 to FE-JOURNEY-DAYJUMP-003 (#1243)
import { vi } from 'vitest'
import { createRef } from 'react'
import { render, screen, fireEvent } from '../../../tests/helpers/render'
import JourneyDayJump from './JourneyDayJump'
const days = [
{ date: '2026-06-02', color: '#6366f1', entries: [{ id: 1, title: 'Arrival' }] },
{ date: '2026-06-03', color: '#f97316', entries: [{ id: 2, title: 'City walk' }, { id: 3, title: 'Dinner' }] },
]
function setup() {
const feed = document.createElement('div')
feed.innerHTML = '<div data-day="2026-06-03"></div><div data-entry-id="3"></div>'
document.body.appendChild(feed)
const scroll = vi.fn()
Element.prototype.scrollIntoView = scroll
const ref = createRef<HTMLDivElement>()
;(ref as { current: HTMLDivElement }).current = feed
render(<JourneyDayJump days={days} feedRef={ref} />)
return { scroll }
}
describe('JourneyDayJump', () => {
it('FE-JOURNEY-DAYJUMP-001: opens every day and scrolls the feed to the one picked', () => {
const { scroll } = setup()
fireEvent.click(screen.getByRole('button', { name: 'Jump to a day' }))
expect(screen.getAllByRole('menuitem')).toHaveLength(2)
fireEvent.click(screen.getAllByRole('menuitem')[1])
expect(scroll).toHaveBeenCalledTimes(1)
expect(screen.queryByRole('menu')).toBeNull()
})
it('FE-JOURNEY-DAYJUMP-002: a day opens onto its entries, and an entry is a jump too', () => {
const { scroll } = setup()
fireEvent.click(screen.getByRole('button', { name: 'Jump to a day' }))
fireEvent.click(screen.getAllByRole('button', { name: 'Expand' })[1])
fireEvent.click(screen.getByRole('menuitem', { name: /Dinner/ }))
expect(scroll).toHaveBeenCalledTimes(1)
})
it('FE-JOURNEY-DAYJUMP-003: a single day needs no jump', () => {
const { container } = render(<JourneyDayJump days={[days[0]]} feedRef={createRef()} />)
expect(container.innerHTML).toBe('')
})
})
@@ -0,0 +1,90 @@
import { useEffect, useRef, useState, type RefObject } from 'react'
import { CalendarDays, ChevronDown, ChevronRight } from 'lucide-react'
import { useTranslation } from '../../i18n'
import { Tooltip } from '../shared/Tooltip'
export interface JumpDay {
date: string
color: string
entries: { id: number; title: string }[]
}
/** Scrolls the feed to a day header or an entry card, by the marks the timeline puts on them. */
function scrollFeedTo(feed: HTMLElement | null, selector: string) {
feed?.querySelector(selector)?.scrollIntoView({ behavior: 'smooth', block: 'start' })
}
/**
* Where the desktop journal is, and a way to anywhere else in it (#1243): a pill
* among the jump buttons that opens every day, each day opening onto its entries.
* Picking one scrolls the feed there, so a long journey no longer means scrolling
* past every photo to reach the day being written up.
*/
export default function JourneyDayJump({ days, feedRef }: { days: JumpDay[]; feedRef: RefObject<HTMLDivElement | null> }) {
const { t, locale } = useTranslation()
const [open, setOpen] = useState(false)
const [expanded, setExpanded] = useState<string | null>(null)
const boxRef = useRef<HTMLDivElement>(null)
useEffect(() => {
if (!open) return
const onDown = (e: MouseEvent) => { if (!boxRef.current?.contains(e.target as Node)) setOpen(false) }
const onKey = (e: KeyboardEvent) => { if (e.key === 'Escape') setOpen(false) }
document.addEventListener('mousedown', onDown)
document.addEventListener('keydown', onKey)
return () => { document.removeEventListener('mousedown', onDown); document.removeEventListener('keydown', onKey) }
}, [open])
if (days.length < 2) return null
const dateLabel = (date: string) => new Date(`${date}T00:00:00`).toLocaleDateString(locale, { weekday: 'short', day: 'numeric', month: 'short' })
const goDay = (date: string) => { setOpen(false); scrollFeedTo(feedRef.current, `[data-day="${date}"]`) }
const goEntry = (id: number) => { setOpen(false); scrollFeedTo(feedRef.current, `[data-entry-id="${id}"]`) }
return (
<div ref={boxRef} className="relative">
<Tooltip label={t('journey.detail.dayJump')} placement="top" disabled={open}>
<button type="button" onClick={() => setOpen(o => !o)} aria-expanded={open} aria-label={t('journey.detail.dayJump')}
className="h-8 rounded-full px-3 inline-flex items-center gap-1.5 shadow-md text-[12px] font-semibold"
style={{ background: 'var(--vg-surf)', border: '1px solid var(--vg-line)', color: 'var(--vg-ink)' }}>
<CalendarDays size={14} strokeWidth={2.2} />
{t('journey.detail.dayJumpCount', { count: days.length })}
</button>
</Tooltip>
{open && (
<div role="menu" className="absolute bottom-10 left-1/2 -translate-x-1/2 w-[300px] max-h-[60vh] overflow-y-auto rounded-2xl p-1.5 shadow-xl"
style={{ background: 'var(--vg-surf)', border: '1px solid var(--vg-line)' }}>
{days.map((day, i) => {
const isOpen = expanded === day.date
return (
<div key={day.date}>
<div className="flex items-center gap-1">
<button type="button" role="menuitem" onClick={() => goDay(day.date)}
className="flex min-w-0 flex-1 items-center gap-2.5 rounded-xl px-2 py-1.5 text-left hover:bg-[color:var(--vg-surf2)]">
<span className="grid h-6 w-6 flex-none place-items-center rounded-lg text-[11px] font-bold text-white" style={{ background: day.color }}>{i + 1}</span>
<span className="min-w-0 flex-1 truncate text-[12.5px] font-semibold" style={{ color: 'var(--vg-ink)' }}>{dateLabel(day.date)}</span>
<span className="flex-none rounded-full px-1.5 text-[10px] font-bold tabular-nums" style={{ background: 'var(--vg-surf2)', color: 'var(--vg-ink3)' }}>{day.entries.length}</span>
</button>
{day.entries.length > 0 && (
<button type="button" onClick={() => setExpanded(isOpen ? null : day.date)} aria-expanded={isOpen}
aria-label={isOpen ? t('common.collapse') : t('common.expand')}
className="grid h-7 w-7 flex-none place-items-center rounded-full hover:bg-[color:var(--vg-surf2)]" style={{ color: 'var(--vg-ink3)' }}>
{isOpen ? <ChevronDown size={14} /> : <ChevronRight size={14} />}
</button>
)}
</div>
{isOpen && day.entries.map(entry => (
<button key={entry.id} type="button" role="menuitem" onClick={() => goEntry(entry.id)}
className="flex w-full items-center gap-2 rounded-lg py-1 pl-11 pr-2 text-left text-[12px] hover:bg-[color:var(--vg-surf2)]" style={{ color: 'var(--vg-ink2)' }}>
<span className="h-1.5 w-1.5 flex-none rounded-full" style={{ background: day.color }} />
<span className="min-w-0 truncate">{entry.title}</span>
</button>
))}
</div>
)
})}
</div>
)}
</div>
)
}
@@ -209,6 +209,15 @@ describe('EntryCard', () => {
expect(screen.queryByText('Off route')).not.toBeInTheDocument()
})
it('FE-JRN-CARD-020: marks a draft on the header and on the hero (#696)', () => {
const { unmount } = mountCard(buildEntry({ is_draft: true }))
expect(screen.getByText('Draft')).toBeInTheDocument()
unmount()
mountCard(buildEntry({ is_draft: true, photos: [buildPhoto(100)] }))
expect(screen.getByText('Draft')).toBeInTheDocument()
})
})
describe('SkeletonCard', () => {
@@ -1,5 +1,5 @@
import { useState, useEffect } from 'react'
import { MapPin, Clock, Pencil, Trash2, Plus, RouteOff, X } from 'lucide-react'
import { MapPin, Clock, Pencil, Trash2, Plus, PencilLine, RouteOff, X } from 'lucide-react'
import { formatLocationName } from '../../utils/formatters'
import { useTranslation } from '../../i18n'
import { pluginsApi } from '../../api/client'
@@ -79,6 +79,13 @@ export function EntryCard({ entry, readOnly, onEdit, onDelete, onPhotoClick }: {
{t('journey.entry.offRoute')}
</span>
)}
{/* A draft (#696) is the contributors' own until it is published. */}
{entry.is_draft && (
<span className="inline-flex items-center gap-1 px-2.5 py-1 bg-black/40 backdrop-blur-sm rounded-full text-[10px] font-semibold text-white tracking-wide">
<PencilLine size={10} />
{t('journey.entry.draft')}
</span>
)}
</div>
{/* Menu top-right, raised on the card colour so it reads on any photo */}
@@ -114,6 +121,11 @@ export function EntryCard({ entry, readOnly, onEdit, onDelete, onPhotoClick }: {
<RouteOff size={10} /> {t('journey.entry.offRoute')}
</span>
)}
{entry.is_draft && (
<span className="inline-flex items-center gap-1 px-2 py-0.5 bg-zinc-100 dark:bg-zinc-800 rounded-full text-[10px] font-semibold text-zinc-500">
<PencilLine size={10} /> {t('journey.entry.draft')}
</span>
)}
</div>
{!readOnly && <MoreButton label={t('files.menu')} items={menuItems} size={28} alwaysVisible />}
</div>
@@ -1,6 +1,6 @@
import { useEffect, useId, useMemo, useState, useRef, type SyntheticEvent } from 'react'
import { localIsoDate } from '../../utils/localDate'
import { X, Plus, Image, Minus, Check, MapPin, Locate, Camera, Play, Loader2, NotebookPen } from 'lucide-react'
import { Briefcase, X, Plus, Image, Minus, Check, MapPin, Locate, Camera, Play, Loader2, NotebookPen } from 'lucide-react'
import { normalizeImageFiles } from '../../utils/convertHeic'
import { isVideoFile } from '../../utils/videoPoster'
import { type ResilientResult, type UploadProgress } from '../../utils/uploadQueue'
@@ -22,6 +22,9 @@ import ConfirmDialog from '../shared/ConfirmDialog'
import { DialogButton, DialogFooter, DialogHeader, DialogSection, DialogShell, DialogTile, FooterSpacer, NEUTRAL_TINT, fs } from '../shared/DialogShell'
import { AddRowButton, EditorField, INPUT } from '../shared/dialogParts'
import { Tooltip } from '../shared/Tooltip'
import { useJourneyTripSuggestion } from './useJourneyTripSuggestion'
import { useEntryPhotoOrder } from './useEntryPhotoOrder'
import { usePlaceLanguage } from '../../hooks/usePlaceLanguage'
type PendingProviderGroup = ProviderPhotoGroup & { provider: string }
@@ -144,6 +147,7 @@ export function EntryEditor({ entry, journeyId, tripDates, galleryPhotos, trips,
onDone: () => void
}) {
const { t, language } = useTranslation()
const placeLang = usePlaceLanguage()
const toast = useToast()
const [title, setTitle] = useState(entry.title || '')
const [story, setStory] = useState(entry.story || '')
@@ -161,11 +165,17 @@ export function EntryEditor({ entry, journeyId, tripDates, galleryPhotos, trips,
const [mood, setMood] = useState(entry.mood || '')
const [weather, setWeather] = useState(entry.weather || '')
const [statsExcluded, setStatsExcluded] = useState(entry.stats_excluded ?? false)
// The trip this day belongs to, when the journey does not follow it yet (#2265).
const tripSuggestion = useJourneyTripSuggestion(journeyId, trips.map(tr => tr.trip_id), entryDate, true)
const [isDraft, setIsDraft] = useState(entry.is_draft ?? false)
const [pros, setPros] = useState<string[]>(entry.pros_cons?.pros?.length ? entry.pros_cons.pros : [''])
const [cons, setCons] = useState<string[]>(entry.pros_cons?.cons?.length ? entry.pros_cons.cons : [''])
const [saving, setSaving] = useState(false)
const [uploadProgress, setUploadProgress] = useState<{ done: number; total: number } | null>(null)
const [photos, setPhotos] = useState<(JourneyPhoto | GalleryPhoto)[]>(entry.photos || [])
// Drag a photo onto another's place, or send it to the front (#824).
const photoOrder = useEntryPhotoOrder(entry.id, photos, setPhotos)
const canReorder = entry.id > 0 && photos.length > 1
const [pendingFiles, setPendingFiles] = useState<File[]>([])
// Minting the preview URL inline in the JSX would hand out a fresh blob on
// every keystroke in the story field and never give one back.
@@ -206,6 +216,7 @@ export function EntryEditor({ entry, journeyId, tripDates, galleryPhotos, trips,
mood !== (entry.mood || '') ||
weather !== (entry.weather || '') ||
statsExcluded !== (entry.stats_excluded ?? false) ||
isDraft !== (entry.is_draft ?? false) ||
pros.filter(p => p.trim()).join('\n') !== originalPros ||
cons.filter(c => c.trim()).join('\n') !== originalCons ||
pendingFiles.length > 0 ||
@@ -331,6 +342,7 @@ export function EntryEditor({ entry, journeyId, tripDates, galleryPhotos, trips,
location_lat: locationLat,
location_lng: locationLng,
stats_excluded: offersStatsToggle ? statsExcluded : undefined,
is_draft: isDraft,
mood: mood || null,
weather: weather || null,
pros_cons: { pros: pros.filter(p => p.trim()), cons: cons.filter(c => c.trim()) },
@@ -420,7 +432,7 @@ export function EntryEditor({ entry, journeyId, tripDates, galleryPhotos, trips,
setLocationResults([])
setShowLocationResults(false)
try {
const data = await mapsApi.reverse(pos.lat, pos.lng, language)
const data = await mapsApi.reverse(pos.lat, pos.lng, placeLang)
const name = data.name || data.address
// Only replace the coordinate fallback — don't clobber a search
// result the user may have picked while the reverse call was in flight.
@@ -637,7 +649,10 @@ export function EntryEditor({ entry, journeyId, tripDates, galleryPhotos, trips,
{(photos.length > 0 || pendingFiles.length > 0) && (
<div className="mt-3 flex flex-wrap gap-2">
{photos.map((p, idx) => (
<div key={p.id} className={`group relative h-20 w-20 overflow-hidden rounded-[12px] ${idx === 0 && photos.length > 1 ? 'ring-2 ring-accent ring-offset-1 ring-offset-surface-card' : ''}`}>
<div key={p.id}
{...(canReorder ? photoOrder.dragProps(idx) : {})}
className={`group relative h-20 w-20 overflow-hidden rounded-[12px] ${canReorder ? 'cursor-grab active:cursor-grabbing' : ''} ${idx === 0 && photos.length > 1 ? 'ring-2 ring-accent ring-offset-1 ring-offset-surface-card' : ''} ${photoOrder.overIndex === idx && photoOrder.dragIndex !== idx ? 'outline outline-2 outline-offset-2 outline-[color:var(--accent)]' : ''}`}
style={photoOrder.dragIndex === idx ? { opacity: 0.4 } : undefined}>
{posterlessVideo(p) ? (
<ClipTile />
) : (
@@ -648,28 +663,7 @@ export function EntryEditor({ entry, journeyId, tripDates, galleryPhotos, trips,
)}
{idx > 0 && photos.length > 1 && (
<button type="button"
onClick={e => {
e.stopPropagation()
const prevOrder = photos
const next = [...photos]
const [moved] = next.splice(idx, 1)
next.unshift(moved)
setPhotos(next)
// The order is shared: other members, the share view and the
// PDF all read it, so a rejected write has to go back. Every
// write is awaited first: snapping back on the first
// rejection would do it while the rest are still landing.
void (async () => {
const results = await Promise.allSettled(next.map((ph, i) => journeyApi.updatePhoto(ph.id, { sort_order: i })))
const rejected = results.filter(r => r.status === 'rejected') as PromiseRejectedResult[]
if (rejected.length === 0) return
toast.error(getApiErrorMessage(rejected[0].reason, t('common.error')))
// Only a run where nothing landed can be put back: with a
// partial one the server already holds part of the new
// order, and hiding that would be the worse lie.
if (rejected.length === results.length) setPhotos(prevOrder)
})()
}}
onClick={e => { e.stopPropagation(); photoOrder.makeFirst(idx) }}
className={`absolute bottom-0.5 left-0.5 rounded px-1.5 py-0.5 font-semibold opacity-0 transition-opacity group-hover:opacity-100 ${ON_PHOTO}`}
style={fs(8)}
>
@@ -787,6 +781,22 @@ export function EntryEditor({ entry, journeyId, tripDates, galleryPhotos, trips,
</EditorField>
</div>
{tripSuggestion.trip && (
<div className="flex flex-col gap-2.5 rounded-[14px] border border-edge-faint bg-surface-secondary px-3 py-2.5">
<div className="flex items-start gap-3">
<span className="grid h-8 w-8 flex-none place-items-center rounded-[10px] bg-surface-card text-content-muted shadow-sm"><Briefcase size={15} /></span>
<div className="min-w-0 flex-1">
<div className="font-semibold text-content [overflow-wrap:anywhere]" style={fs(12.5, 'body')}>{tripSuggestion.trip.title}</div>
<div className="mt-0.5 leading-snug text-content-muted" style={fs(11)}>{t('journey.editor.tripSuggestionHint')}</div>
</div>
</div>
<div className="flex justify-end gap-2">
<DialogButton onClick={tripSuggestion.dismiss}>{t('journey.editor.tripSuggestionLater')}</DialogButton>
<DialogButton variant="primary" onClick={() => void tripSuggestion.link()} disabled={tripSuggestion.linking}>{t('journey.trips.linkTrip')}</DialogButton>
</div>
</div>
)}
{/* The location is a free-text search, so it gets the whole width. */}
<EditorField label={t('journey.editor.location')} htmlFor={`${labelId}-location`} className="relative">
<div className="relative">
@@ -802,7 +812,7 @@ export function EntryEditor({ entry, journeyId, tripDates, galleryPhotos, trips,
locationTimerRef.current = setTimeout(async () => {
setLocationSearching(true)
try {
const res = await mapsApi.search(q)
const res = await mapsApi.search(q, placeLang)
setLocationResults((res.places || []).slice(0, 6).map((p: any) => ({
name: p.name, address: p.address, lat: Number(p.lat), lng: Number(p.lng),
})))
@@ -876,6 +886,15 @@ export function EntryEditor({ entry, journeyId, tripDates, galleryPhotos, trips,
</div>
)}
{/* A draft stays among the contributors until it is ready (#696). */}
<div className="flex items-center gap-3 rounded-[14px] border border-edge-faint bg-surface-secondary px-3 py-2.5">
<div className="min-w-0 flex-1">
<div className="font-semibold text-content" style={fs(12.5, 'body')}>{t('journey.editor.draft')}</div>
<div className="mt-0.5 leading-snug text-content-muted" style={fs(11)}>{t('journey.editor.draftHint')}</div>
</div>
<ToggleSwitch on={isDraft} onToggle={() => setIsDraft(v => !v)} label={t('journey.editor.draft')} />
</div>
{/* The size sits on the rows rather than on each chip, so a chip at rest
carries no inline style and only a picked mood wears its palette. */}
{showMood && (
@@ -1,4 +1,4 @@
// FE-COMP-LIGHTBOX-001 to FE-COMP-LIGHTBOX-017
// FE-COMP-LIGHTBOX-001 to FE-COMP-LIGHTBOX-020
// Plyr needs a real media pipeline, so the player is stubbed here.
vi.mock('./VideoPlayer', () => ({
@@ -94,10 +94,7 @@ describe('PhotoLightbox', () => {
it('FE-COMP-LIGHTBOX-008: calls onClose when close button clicked', () => {
const onClose = vi.fn();
render(<PhotoLightbox photos={samplePhotos} onClose={onClose} />);
// The close button is in the top bar — find the button and click it
const buttons = screen.getAllByRole('button');
// The first button in the top bar is the close (X) button
buttons[0].click();
screen.getByRole('button', { name: 'Close' }).click();
expect(onClose).toHaveBeenCalled();
});
@@ -110,15 +107,15 @@ describe('PhotoLightbox', () => {
it('FE-COMP-LIGHTBOX-010: only offers the arrows that lead somewhere', () => {
render(<PhotoLightbox photos={samplePhotos} onClose={vi.fn()} />);
// First photo: close + next.
expect(screen.getAllByRole('button')).toHaveLength(2);
// First photo: the two zoom buttons, close and next.
expect(screen.getAllByRole('button')).toHaveLength(4);
fireEvent.keyDown(window, { key: 'ArrowRight' });
expect(screen.getAllByRole('button')).toHaveLength(3);
expect(screen.getAllByRole('button')).toHaveLength(5);
fireEvent.keyDown(window, { key: 'ArrowRight' });
// Last photo: close + prev.
expect(screen.getAllByRole('button')).toHaveLength(2);
// Last photo: zoom, close and prev.
expect(screen.getAllByRole('button')).toHaveLength(4);
fireEvent.keyDown(window, { key: 'ArrowRight' });
expect(screen.getByText('3 / 3')).toBeInTheDocument();
@@ -126,12 +123,12 @@ describe('PhotoLightbox', () => {
it('FE-COMP-LIGHTBOX-011: navigates with the on-screen arrows', () => {
render(<PhotoLightbox photos={samplePhotos} startIndex={1} onClose={vi.fn()} />);
const [, prev, next] = screen.getAllByRole('button');
const [, , , prev, next] = screen.getAllByRole('button');
fireEvent.click(next);
expect(screen.getByText('3 / 3')).toBeInTheDocument();
fireEvent.click(screen.getAllByRole('button')[1]);
fireEvent.click(screen.getAllByRole('button')[3]);
expect(screen.getByText('2 / 3')).toBeInTheDocument();
expect(prev).toBeInTheDocument();
});
@@ -197,4 +194,46 @@ describe('PhotoLightbox', () => {
expect(onClose).not.toHaveBeenCalled();
expect(screen.getByText('1 / 3')).toBeInTheDocument();
});
it('FE-COMP-LIGHTBOX-018: zooms with the buttons and a double click, and starts over on the next photo (#1484)', () => {
render(<PhotoLightbox photos={samplePhotos} onClose={vi.fn()} />);
const img = screen.getByRole('img');
expect(screen.getByRole('button', { name: 'Zoom out' })).toBeDisabled();
fireEvent.click(screen.getByRole('button', { name: 'Zoom in' }));
expect(img.style.transform).toContain('scale(1.5)');
expect(screen.getByRole('button', { name: 'Back to full photo' })).toHaveTextContent('150%');
fireEvent.click(screen.getByRole('button', { name: 'Back to full photo' }));
expect(img.style.transform).toBe('translate(0px, 0px) scale(1)');
fireEvent.doubleClick(img);
expect(img.style.transform).toContain('scale(2.5)');
fireEvent.keyDown(window, { key: 'ArrowRight' });
expect(screen.getByRole('img').style.transform).toBe('translate(0px, 0px) scale(1)');
});
it('FE-COMP-LIGHTBOX-019: a swipe on an enlarged photo pans instead of turning the page', () => {
const onClose = vi.fn();
render(<PhotoLightbox photos={samplePhotos} onClose={onClose} />);
fireEvent.click(screen.getByRole('button', { name: 'Zoom in' }));
const overlay = screen.getByRole('img').closest('div[style*="position: fixed"]') as HTMLElement;
fireEvent.touchStart(overlay, { touches: [{ clientX: 200, clientY: 200 }] });
fireEvent.touchMove(overlay, { touches: [{ clientX: 80, clientY: 320 }] });
fireEvent.touchEnd(overlay, { touches: [], changedTouches: [{ clientX: 80, clientY: 320 }] });
expect(screen.getByText('1 / 3')).toBeInTheDocument();
expect(onClose).not.toHaveBeenCalled();
});
it('FE-COMP-LIGHTBOX-020: a pinch zooms in without closing', () => {
const onClose = vi.fn();
render(<PhotoLightbox photos={samplePhotos} onClose={onClose} />);
const img = screen.getByRole('img');
const overlay = img.closest('div[style*="position: fixed"]') as HTMLElement;
fireEvent.touchStart(overlay, { touches: [{ clientX: 100, clientY: 100 }, { clientX: 200, clientY: 100 }] });
fireEvent.touchMove(overlay, { touches: [{ clientX: 50, clientY: 100 }, { clientX: 250, clientY: 100 }] });
fireEvent.touchEnd(overlay, { touches: [], changedTouches: [{ clientX: 250, clientY: 100 }] });
expect(img.style.transform).toContain('scale(2)');
expect(onClose).not.toHaveBeenCalled();
});
});
@@ -1,6 +1,9 @@
import { useState, useEffect, useCallback, useRef } from 'react'
import { ChevronLeft, ChevronRight, X } from 'lucide-react'
import { ChevronLeft, ChevronRight, X, ZoomIn, ZoomOut } from 'lucide-react'
import VideoPlayer from './VideoPlayerLazy'
import { usePhotoZoom } from './usePhotoZoom'
import { Tooltip } from '../shared/Tooltip'
import { useTranslation } from '../../i18n'
interface LightboxPhoto {
id: string
@@ -18,11 +21,22 @@ interface Props {
onClose: () => void
}
/** The round glass buttons of the top bar. */
const ROUND_BTN: React.CSSProperties = {
background: 'rgba(255,255,255,0.1)', border: 'none', borderRadius: '50%',
width: 36, height: 36, display: 'flex', alignItems: 'center', justifyContent: 'center',
color: '#fff', cursor: 'pointer',
}
export default function PhotoLightbox({ photos, startIndex = 0, onClose }: Props) {
const [idx, setIdx] = useState(startIndex)
const touchStart = useRef<{ x: number; y: number } | null>(null)
const { t } = useTranslation()
const photo = photos[idx]
// Zoom starts over on every photo (#1484).
const zoom = usePhotoZoom(photo?.id)
const isImage = !!photo && photo.mediaType !== 'video'
const hasPrev = idx > 0
const hasNext = idx < photos.length - 1
@@ -39,12 +53,20 @@ export default function PhotoLightbox({ photos, startIndex = 0, onClose }: Props
return () => window.removeEventListener('keydown', onKey)
}, [prev, next, onClose])
// A pinch, a double tap or a pan of an enlarged photo belongs to the zoom; only
// the rest is a swipe that turns the page or closes the lightbox.
const onTouchStart = (e: React.TouchEvent) => {
if (isImage && zoom.touchStart(e)) { touchStart.current = null; return }
const t = e.touches[0]
touchStart.current = { x: t.clientX, y: t.clientY }
}
const onTouchMove = (e: React.TouchEvent) => {
if (isImage && zoom.touchMove(e)) touchStart.current = null
}
const onTouchEnd = (e: React.TouchEvent) => {
if (isImage && zoom.touchEnd(e)) { touchStart.current = null; return }
if (!touchStart.current) return
const t = e.changedTouches[0]
const dx = t.clientX - touchStart.current.x
@@ -74,6 +96,7 @@ export default function PhotoLightbox({ photos, startIndex = 0, onClose }: Props
paddingBottom: 'var(--bottom-nav-h)',
}}
onTouchStart={onTouchStart}
onTouchMove={onTouchMove}
onTouchEnd={onTouchEnd}
>
{/* Photo area — centered with nav overlays */}
@@ -86,13 +109,37 @@ export default function PhotoLightbox({ photos, startIndex = 0, onClose }: Props
<span style={{ color: 'rgba(255,255,255,0.5)', fontSize: 'calc(13px * var(--fs-scale-body, 1))', fontWeight: 500 }}>
{idx + 1} / {photos.length}
</span>
<button type="button" onClick={onClose} style={{
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
{isImage && (
<>
<Tooltip label={t('journey.lightbox.zoomOut')}>
<button type="button" onClick={zoom.zoomOut} disabled={!zoom.zoomed} aria-label={t('journey.lightbox.zoomOut')} style={{ ...ROUND_BTN, opacity: zoom.zoomed ? 1 : 0.4 }}>
<ZoomOut size={17} />
</button>
</Tooltip>
{zoom.zoomed && (
<Tooltip label={t('journey.lightbox.zoomReset')}>
<button type="button" onClick={zoom.reset} aria-label={t('journey.lightbox.zoomReset')}
style={{ ...ROUND_BTN, width: 'auto', borderRadius: 99, padding: '0 12px', fontSize: 'calc(12px * var(--fs-scale-caption, 1))', fontWeight: 600, fontVariantNumeric: 'tabular-nums' }}>
{Math.round(zoom.zoom.scale * 100)}%
</button>
</Tooltip>
)}
<Tooltip label={t('journey.lightbox.zoomIn')}>
<button type="button" onClick={zoom.zoomIn} disabled={!zoom.canZoomIn} aria-label={t('journey.lightbox.zoomIn')} style={{ ...ROUND_BTN, opacity: zoom.canZoomIn ? 1 : 0.4 }}>
<ZoomIn size={17} />
</button>
</Tooltip>
</>
)}
<button type="button" onClick={onClose} aria-label={t('common.close')} style={{
background: 'rgba(255,255,255,0.1)', border: 'none', borderRadius: '50%',
width: 36, height: 36, display: 'flex', alignItems: 'center', justifyContent: 'center',
color: '#fff', cursor: 'pointer',
}}>
<X size={18} />
</button>
</div>
</div>
{/* Prev button — visible on hover (desktop), always visible (mobile) */}
@@ -115,12 +162,17 @@ export default function PhotoLightbox({ photos, startIndex = 0, onClose }: Props
) : (
<img
key={photo.id}
ref={zoom.imgRef}
src={photo.src}
alt={photo.caption || ''}
draggable={false}
{...zoom.mouse}
style={{
maxWidth: '92vw', maxHeight: '92vh',
objectFit: 'contain', borderRadius: 4,
animation: 'fadeIn 0.15s ease',
userSelect: 'none',
...zoom.style,
}}
/>
)}
@@ -0,0 +1,21 @@
// FE-JOURNEY-ZOOM-001 to FE-JOURNEY-ZOOM-004 (#1484)
import { describe, it, expect } from 'vitest'
import { clampOffset, NO_ZOOM, panBy, toggleZoom, zoomAt } from './photoZoom'
describe('photoZoom', () => {
it('FE-JOURNEY-ZOOM-001: zooms into the point under the pointer and stays within 1x to 4x', () => {
expect(zoomAt(NO_ZOOM, 2, 50, 20, 400, 300)).toEqual({ scale: 2, x: -50, y: -20 })
expect(zoomAt(NO_ZOOM, 9, 0, 0, 400, 300).scale).toBe(4)
expect(zoomAt({ scale: 2, x: 30, y: 0 }, 0.5, 0, 0, 400, 300)).toEqual(NO_ZOOM)
})
it('FE-JOURNEY-ZOOM-002: never lets the photo slide off its own place', () => {
expect(clampOffset({ scale: 2, x: 999, y: -999 }, 400, 300)).toEqual({ scale: 2, x: 200, y: -150 })
expect(panBy({ scale: 2, x: 0, y: 0 }, 30, 40, 400, 300)).toEqual({ scale: 2, x: 30, y: 40 })
})
it('FE-JOURNEY-ZOOM-003: a double click goes in, and back out from anywhere', () => {
expect(toggleZoom(NO_ZOOM, 0, 0, 400, 300).scale).toBe(2.5)
expect(toggleZoom({ scale: 3.2, x: 10, y: 10 }, 0, 0, 400, 300)).toEqual(NO_ZOOM)
})
})
@@ -0,0 +1,47 @@
/**
* The zoom of a photo in the lightbox (#1484), as plain arithmetic so it can be
* tested without a browser: a scale and how far the photo is moved off centre.
*/
export interface ZoomState {
scale: number
x: number
y: number
}
export const MIN_ZOOM = 1
export const MAX_ZOOM = 4
/** What a double click or a double tap jumps to. */
export const TAP_ZOOM = 2.5
export const NO_ZOOM: ZoomState = { scale: 1, x: 0, y: 0 }
/** The photo may move only as far as its enlarged edge still covers where it sat. */
export function clampOffset(state: ZoomState, width: number, height: number): ZoomState {
if (state.scale <= MIN_ZOOM) return NO_ZOOM
const maxX = ((state.scale - 1) * width) / 2
const maxY = ((state.scale - 1) * height) / 2
return {
scale: state.scale,
x: Math.max(-maxX, Math.min(maxX, state.x)),
y: Math.max(-maxY, Math.min(maxY, state.y)),
}
}
/**
* Zoom to `nextScale` keeping the point under the finger or the pointer where it
* is. `px`/`py` are measured from the photo's centre, in screen pixels.
*/
export function zoomAt(state: ZoomState, nextScale: number, px: number, py: number, width: number, height: number): ZoomState {
const scale = Math.max(MIN_ZOOM, Math.min(MAX_ZOOM, nextScale))
const ratio = scale / state.scale
return clampOffset({ scale, x: px - (px - state.x) * ratio, y: py - (py - state.y) * ratio }, width, height)
}
/** A double click: in to the tapped spot from rest, back to rest from anywhere else. */
export function toggleZoom(state: ZoomState, px: number, py: number, width: number, height: number): ZoomState {
return state.scale > MIN_ZOOM ? NO_ZOOM : zoomAt(state, TAP_ZOOM, px, py, width, height)
}
export function panBy(state: ZoomState, dx: number, dy: number, width: number, height: number): ZoomState {
return clampOffset({ scale: state.scale, x: state.x + dx, y: state.y + dy }, width, height)
}
@@ -0,0 +1,13 @@
// FE-JOURNEY-PHOTOORDER-001 (#824)
import { describe, it, expect } from 'vitest'
import { movedTo } from './useEntryPhotoOrder'
describe('movedTo', () => {
it('FE-JOURNEY-PHOTOORDER-001: moves an item to a new place and leaves the list alone otherwise', () => {
expect(movedTo(['a', 'b', 'c', 'd'], 3, 0)).toEqual(['d', 'a', 'b', 'c'])
expect(movedTo(['a', 'b', 'c', 'd'], 0, 2)).toEqual(['b', 'c', 'a', 'd'])
const same = ['a', 'b']
expect(movedTo(same, 1, 1)).toBe(same)
expect(movedTo(same, 5, 0)).toBe(same)
})
})
@@ -0,0 +1,53 @@
import { useState, type DragEvent } from 'react'
import { journeyApi } from '../../api/client'
import { getApiErrorMessage } from '../../types'
import { useToast } from '../shared/Toast'
import { useTranslation } from '../../i18n'
/** The list with the item at `from` taken out and put back at `to`. */
export function movedTo<T>(list: T[], from: number, to: number): T[] {
if (from === to || from < 0 || from >= list.length) return list
const next = [...list]
const [item] = next.splice(from, 1)
next.splice(Math.max(0, Math.min(to, next.length)), 0, item)
return next
}
/**
* The order of an entry's photos (#824): drag one onto another's place, or send
* one to the front. One request for the whole order; the order is shared with the
* other members, the share page and the PDF, so a refused write goes back.
*/
export function useEntryPhotoOrder<T extends { id: number }>(entryId: number, photos: T[], setPhotos: (next: T[]) => void) {
const { t } = useTranslation()
const toast = useToast()
const [dragIndex, setDragIndex] = useState<number | null>(null)
const [overIndex, setOverIndex] = useState<number | null>(null)
const commit = (next: T[]) => {
if (next === photos) return
const before = photos
setPhotos(next)
journeyApi.reorderEntryPhotos(entryId, next.map(p => p.id)).catch((err: unknown) => {
toast.error(getApiErrorMessage(err, t('common.error')))
setPhotos(before)
})
}
const reset = () => { setDragIndex(null); setOverIndex(null) }
const dragProps = (index: number) => ({
draggable: true,
onDragStart: (e: DragEvent) => { e.dataTransfer.effectAllowed = 'move'; setDragIndex(index) },
onDragOver: (e: DragEvent) => { if (dragIndex === null) return; e.preventDefault(); e.dataTransfer.dropEffect = 'move'; setOverIndex(index) },
onDrop: (e: DragEvent) => { e.preventDefault(); if (dragIndex !== null) commit(movedTo(photos, dragIndex, index)); reset() },
onDragEnd: reset,
})
return {
makeFirst: (index: number) => commit(movedTo(photos, index, 0)),
dragProps,
dragIndex,
overIndex,
}
}
@@ -0,0 +1,20 @@
// FE-JOURNEY-TRIPSUGGEST-001 (#2265)
import { describe, it, expect } from 'vitest'
import { tripForDate } from './useJourneyTripSuggestion'
const trips = [
{ id: 1, title: 'Summer', start_date: '2026-07-01', end_date: '2026-07-31' },
{ id: 2, title: 'Berlin weekend', start_date: '2026-07-10', end_date: '2026-07-12' },
{ id: 3, title: 'Undated' },
]
describe('tripForDate', () => {
it('FE-JOURNEY-TRIPSUGGEST-001: picks the shortest unlinked trip that takes in the day', () => {
expect(tripForDate(trips, '2026-07-11', new Set())?.id).toBe(2)
expect(tripForDate(trips, '2026-07-11T09:00', new Set([2]))?.id).toBe(1)
expect(tripForDate(trips, '2026-07-02', new Set())?.id).toBe(1)
expect(tripForDate(trips, '2026-08-01', new Set())).toBeNull()
expect(tripForDate(trips, null, new Set())).toBeNull()
expect(tripForDate(trips, '2026-07-31', new Set([1]))).toBeNull()
})
})
@@ -0,0 +1,71 @@
import { useEffect, useState } from 'react'
import { journeyApi } from '../../api/client'
import { useJourneyStore } from '../../store/journeyStore'
import { useToast } from '../shared/Toast'
import { useTranslation } from '../../i18n'
export interface SuggestableTrip {
id: number
title: string
start_date?: string | null
end_date?: string | null
}
/**
* The trip a journal day belongs to (#2265): one of the user's trips whose dates
* take in `date` and that the journey does not follow yet. When several do, the
* shortest one wins, the weekend inside a long holiday being the likelier match.
*/
export function tripForDate(trips: SuggestableTrip[], date: string | null | undefined, linkedIds: ReadonlySet<number>): SuggestableTrip | null {
if (!date) return null
const day = date.slice(0, 10)
const span = (t: SuggestableTrip) => Date.parse(t.end_date!) - Date.parse(t.start_date!)
return trips
.filter(t => !linkedIds.has(t.id) && !!t.start_date && !!t.end_date && t.start_date <= day && day <= t.end_date)
.sort((a, b) => span(a) - span(b))[0] ?? null
}
/**
* Offers to link the trip an entry's date falls in (#2265), the reverse of the
* "trip just ended, make a journey" prompt. Asks the server for the user's trips
* once, and only for someone who may change the journey.
*/
export function useJourneyTripSuggestion(journeyId: number, linkedTripIds: number[], date: string | null | undefined, enabled: boolean) {
const { t } = useTranslation()
const toast = useToast()
const [trips, setTrips] = useState<SuggestableTrip[]>([])
const [dismissed, setDismissed] = useState<number | null>(null)
const [linking, setLinking] = useState(false)
useEffect(() => {
if (!enabled) return
let cancelled = false
journeyApi.availableTrips()
.then(d => { if (!cancelled) setTrips(d.trips || []) })
.catch(() => { /* No suggestion is the quiet fallback: the entry saves either way. */ })
return () => { cancelled = true }
}, [enabled])
const suggestion = enabled ? tripForDate(trips, date, new Set(linkedTripIds)) : null
const link = async () => {
if (!suggestion || linking) return
setLinking(true)
try {
await journeyApi.addTrip(journeyId, suggestion.id)
toast.success(t('journey.trips.tripLinked'))
await useJourneyStore.getState().loadJourney(journeyId)
} catch {
toast.error(t('journey.trips.linkFailed'))
} finally {
setLinking(false)
}
}
return {
trip: suggestion && suggestion.id !== dismissed ? suggestion : null,
linking,
link,
dismiss: () => setDismissed(suggestion?.id ?? null),
}
}
@@ -0,0 +1,146 @@
import { useCallback, useEffect, useRef, useState, type CSSProperties } from 'react'
import { MAX_ZOOM, NO_ZOOM, panBy, toggleZoom, zoomAt, type ZoomState } from './photoZoom'
const STEP = 1.5
const DOUBLE_TAP_MS = 300
/**
* Zoom and pan for one photo of the lightbox (#1484): the wheel and a double click
* on a desktop, a pinch and a double tap on a phone, and dragging once it is
* enlarged. Starts over whenever `resetKey` changes, which is the photo shown.
*/
export function usePhotoZoom(resetKey: unknown) {
const [zoom, setZoom] = useState<ZoomState>(NO_ZOOM)
const imgRef = useRef<HTMLImageElement | null>(null)
const zoomRef = useRef(zoom)
zoomRef.current = zoom
const pinch = useRef<{ dist: number; scale: number } | null>(null)
const drag = useRef<{ x: number; y: number } | null>(null)
const lastTap = useRef(0)
useEffect(() => { setZoom(NO_ZOOM) }, [resetKey])
/** The photo's size at rest, and a point as measured from its centre. */
const measure = useCallback((clientX?: number, clientY?: number) => {
const img = imgRef.current
const z = zoomRef.current
if (!img) return null
const rect = img.getBoundingClientRect()
const width = rect.width / z.scale
const height = rect.height / z.scale
const cx = rect.left + rect.width / 2 - z.x
const cy = rect.top + rect.height / 2 - z.y
return { width, height, px: clientX == null ? 0 : clientX - cx, py: clientY == null ? 0 : clientY - cy }
}, [])
const scaleBy = useCallback((factor: number, clientX?: number, clientY?: number) => {
const m = measure(clientX, clientY)
if (!m) return
setZoom(z => zoomAt(z, z.scale * factor, m.px, m.py, m.width, m.height))
}, [measure])
const toggleAt = useCallback((clientX: number, clientY: number) => {
const m = measure(clientX, clientY)
if (!m) return
setZoom(z => toggleZoom(z, m.px, m.py, m.width, m.height))
}, [measure])
const onWheel = useCallback((e: React.WheelEvent) => {
scaleBy(Math.exp(-e.deltaY * 0.002), e.clientX, e.clientY)
}, [scaleBy])
const onDoubleClick = useCallback((e: React.MouseEvent) => toggleAt(e.clientX, e.clientY), [toggleAt])
const onMouseDown = useCallback((e: React.MouseEvent) => {
if (zoomRef.current.scale <= 1) return
e.preventDefault()
drag.current = { x: e.clientX, y: e.clientY }
}, [])
const onMouseMove = useCallback((e: React.MouseEvent) => {
if (!drag.current) return
const m = measure()
if (!m) return
const dx = e.clientX - drag.current.x
const dy = e.clientY - drag.current.y
drag.current = { x: e.clientX, y: e.clientY }
setZoom(z => panBy(z, dx, dy, m.width, m.height))
}, [measure])
const endDrag = useCallback(() => { drag.current = null }, [])
/**
* Touch goes through here before the lightbox's swipes. Answers true when the
* gesture was a zoom or a pan, so a pinch never also turns the page.
*/
const touchStart = useCallback((e: React.TouchEvent): boolean => {
if (e.touches.length === 2) {
const [a, b] = [e.touches[0], e.touches[1]]
pinch.current = { dist: Math.hypot(a.clientX - b.clientX, a.clientY - b.clientY), scale: zoomRef.current.scale }
return true
}
if (e.touches.length === 1 && zoomRef.current.scale > 1) {
drag.current = { x: e.touches[0].clientX, y: e.touches[0].clientY }
return true
}
return false
}, [])
const touchMove = useCallback((e: React.TouchEvent): boolean => {
const m = measure()
if (!m) return false
if (pinch.current && e.touches.length === 2) {
const [a, b] = [e.touches[0], e.touches[1]]
const dist = Math.hypot(a.clientX - b.clientX, a.clientY - b.clientY)
const mid = measure((a.clientX + b.clientX) / 2, (a.clientY + b.clientY) / 2)
if (!mid) return true
const target = (pinch.current.scale * dist) / pinch.current.dist
setZoom(z => zoomAt(z, target, mid.px, mid.py, m.width, m.height))
return true
}
if (drag.current && e.touches.length === 1) {
const t = e.touches[0]
const dx = t.clientX - drag.current.x
const dy = t.clientY - drag.current.y
drag.current = { x: t.clientX, y: t.clientY }
setZoom(z => panBy(z, dx, dy, m.width, m.height))
return true
}
return false
}, [measure])
const touchEnd = useCallback((e: React.TouchEvent): boolean => {
const wasGesture = !!pinch.current || !!drag.current
if (e.touches.length < 2) pinch.current = null
if (e.touches.length === 0) drag.current = null
// A double tap zooms in on the tapped spot, or back out.
if (!wasGesture && e.changedTouches.length === 1) {
const now = Date.now()
if (now - lastTap.current < DOUBLE_TAP_MS) {
lastTap.current = 0
toggleAt(e.changedTouches[0].clientX, e.changedTouches[0].clientY)
return true
}
lastTap.current = now
}
return wasGesture || zoomRef.current.scale > 1
}, [toggleAt])
const zoomed = zoom.scale > 1
const style: CSSProperties = {
transform: `translate(${zoom.x}px, ${zoom.y}px) scale(${zoom.scale})`,
transition: drag.current || pinch.current ? 'none' : 'transform 160ms ease',
cursor: zoomed ? (drag.current ? 'grabbing' : 'grab') : 'zoom-in',
touchAction: 'none',
}
return {
zoom, zoomed, imgRef, style,
canZoomIn: zoom.scale < MAX_ZOOM,
zoomIn: () => scaleBy(STEP),
zoomOut: () => scaleBy(1 / STEP),
reset: () => setZoom(NO_ZOOM),
mouse: { onWheel, onDoubleClick, onMouseDown, onMouseMove, onMouseUp: endDrag, onMouseLeave: endDrag },
touchStart, touchMove, touchEnd,
}
}
+21
View File
@@ -0,0 +1,21 @@
import { Lock, Unlock } from 'lucide-react'
import { useTranslation } from '../../i18n'
import { MapTogglePill } from './MapTogglePill'
/**
* Locks the map view (#2010): picking days and places then leaves the camera where it is.
* Sits on top of the base-layer switcher, the other control about the view itself.
*/
export function MapLockPill({ locked, onToggle }: { locked: boolean; onToggle: () => void }) {
const { t } = useTranslation()
return (
<MapTogglePill
active={locked}
onToggle={onToggle}
label={locked ? t('map.lock.unlock') : t('map.lock.lock')}
testId="map-lock-pill"
tooltipPlacement="right"
icon={locked ? <Lock size={16} strokeWidth={2} /> : <Unlock size={16} strokeWidth={2} />}
/>
)
}
@@ -0,0 +1,50 @@
import type { ReactNode } from 'react'
import { Tooltip } from '../shared/Tooltip'
import { MAP_CONTROL_SHADOW } from './mapControlShadow'
/**
* One round on/off control in the frosted shell every map control wears, so a new
* one lines up with the compass, the layer switcher and the overview toggle.
*/
export function MapTogglePill({ active, onToggle, label, icon, testId, tooltipPlacement = 'left' }: {
active: boolean
onToggle: () => void
label: string
icon: ReactNode
testId?: string
/** Away from the edge the control hugs: left on the right edge, right on the left one. */
tooltipPlacement?: 'left' | 'right'
}) {
return (
<div style={{
display: 'inline-flex', alignItems: 'center', padding: 4, borderRadius: 999, pointerEvents: 'auto',
background: 'var(--sidebar-bg)',
backdropFilter: 'blur(20px) saturate(180%)',
WebkitBackdropFilter: 'blur(20px) saturate(180%)',
boxShadow: MAP_CONTROL_SHADOW,
}}>
{/* TREK's own tooltip, not the browser's: the native one ignores the colour
scheme, waits a second and a half, and cannot be read on a touch device. */}
<Tooltip label={label} placement={tooltipPlacement}>
<button
type="button"
onClick={onToggle}
aria-label={label}
aria-pressed={active}
data-testid={testId}
className={active ? 'text-accent' : 'text-content-muted'}
style={{
display: 'inline-flex', alignItems: 'center', justifyContent: 'center',
width: 34, height: 34, borderRadius: 999, border: 'none', cursor: 'pointer',
background: 'transparent', padding: 0,
transition: 'background 0.14s, color 0.14s',
}}
onMouseEnter={e => { e.currentTarget.style.background = 'var(--bg-hover)' }}
onMouseLeave={e => { e.currentTarget.style.background = 'transparent' }}
>
{icon}
</button>
</Tooltip>
</div>
)
}
+40 -17
View File
@@ -46,6 +46,7 @@ import { PluginMapLayers } from './MapPluginLayers'
import { useTransportRoutes } from '../../hooks/useTransportRoutes'
import { visibleRouteReservations } from '../../utils/reservationRoutes'
import { safeHexColor } from '../../utils/safeColor'
import { placeMarkerLook } from './markerLook'
import { escapeHtml } from '@trek/shared'
import type { Day, Reservation, RouteVia } from '../../types'
import type { MapHoverInfo } from './mapHover'
@@ -64,6 +65,7 @@ import { isGcj02Basemap, resolveBasemap } from '../../utils/tileUrl'
import VectorBasemap from './VectorBasemap'
import { useSettingsStore } from '../../store/settingsStore'
import { MapLayerSwitcher, MAP_LAYER_SWITCHER_INSET } from './MapLayerSwitcher'
import { MapLockPill } from './MapLockPill'
import { computeMapViewport, TILE_SIZE_RASTER, type ViewportPadding } from '../../utils/mapViewport'
function categoryIconSvg(iconName: string | null | undefined, size: number): string {
@@ -136,7 +138,8 @@ function RouteViaMarker({ via, controls, eventHandlers, children }: {
* Shows image_url if available, otherwise category icon in colored circle.
*/
function createPlaceIcon(place, orderNumbers, isSelected) {
const cacheKey = `${place.id}:${isSelected}:${place.image_url || ''}:${place.category_color || ''}:${place.category_icon || ''}:${place.stop_type || ''}:${orderNumbers?.join(',') || ''}:${(place as { rating_avg?: number | null }).rating_avg ?? ''}`
const look = placeMarkerLook(place, isSelected)
const cacheKey = `${place.id}:${isSelected}:${look.key}:${place.image_url || ''}:${place.category_color || ''}:${place.category_icon || ''}:${place.stop_type || ''}:${orderNumbers?.join(',') || ''}:${(place as { rating_avg?: number | null }).rating_avg ?? ''}`
const cached = iconCache.get(cacheKey)
if (cached) return cached
@@ -151,11 +154,10 @@ function createPlaceIcon(place, orderNumbers, isSelected) {
iconCache.set(cacheKey, icon)
return icon
}
const size = isSelected ? 44 : 36
const { size, borderWidth } = look
// Allow-listed, not escaped: the value lands in style="…" of a divIcon, where
// escaping stops the attribute breakout but still permits a CSS url().
const borderColor = isSelected ? '#111827' : safeHexColor(place.category_color, 'white')
const borderWidth = isSelected ? 3 : 2.5
const shadow = isSelected
? '0 0 0 3px rgba(17,24,39,0.25), 0 4px 14px rgba(0,0,0,0.3)'
: '0 2px 8px rgba(0,0,0,0.22)'
@@ -164,7 +166,7 @@ function createPlaceIcon(place, orderNumbers, isSelected) {
// Number badges (bottom-right), or the rating where there are none: a numbered stop
// is one already planned into a day, and the rating answers the question asked before
// that. The two never want the same corner at the same time.
let badgeHtml = ratingBadgeHtml((place as { rating_avg?: number | null }).rating_avg)
let badgeHtml = look.showRating ? ratingBadgeHtml((place as { rating_avg?: number | null }).rating_avg) : ''
if (orderNumbers && orderNumbers.length > 0) {
const label = orderNumbers.join(' · ')
badgeHtml = `<span style="
@@ -183,7 +185,7 @@ function createPlaceIcon(place, orderNumbers, isSelected) {
// Prefer base64 data URLs (no zoom lag); also accept same-origin proxy + uploaded
// custom images (#1136) as a fallback while the thumb is still being generated
if (place.image_url && (place.image_url.startsWith('data:') || place.image_url.startsWith('/api/maps/place-photo/') || place.image_url.startsWith('/uploads/'))) {
if (look.showPhoto && place.image_url && (place.image_url.startsWith('data:') || place.image_url.startsWith('/api/maps/place-photo/') || place.image_url.startsWith('/uploads/'))) {
const imgIcon = L.divIcon({
className: '',
html: `<div style="
@@ -194,7 +196,7 @@ function createPlaceIcon(place, orderNumbers, isSelected) {
width:${size}px;height:${size}px;border-radius:50%;
border:${borderWidth}px solid ${borderColor};
box-shadow:${shadow};
overflow:hidden;background:${bgColor};
overflow:hidden;background:${bgColor};${look.circleCss}
">
${markerPhotoHtml(place.image_url)}
</div>
@@ -217,9 +219,9 @@ function createPlaceIcon(place, orderNumbers, isSelected) {
background:${bgColor};
display:flex;align-items:center;justify-content:center;
cursor:pointer;position:relative;
will-change:transform;contain:layout style;
will-change:transform;contain:layout style;${look.circleCss}
">
${categoryIconSvg(place.category_icon, isSelected ? 18 : 15)}
${categoryIconSvg(place.category_icon, look.iconSize)}
${badgeHtml}
</div>`,
iconSize: [size, size],
@@ -355,6 +357,8 @@ function ViewportController({ onViewportChange }: { onViewportChange?: (b: { sou
}
interface SelectionControllerProps {
/** False while the traveller locked the map (#2010): a pick changes nothing on it. */
follow: boolean
places: Place[]
selectedPlaceId: number | null
dayPlaces: Place[]
@@ -362,12 +366,12 @@ interface SelectionControllerProps {
paddingOpts: L.FitBoundsOptions
}
function SelectionController({ places, selectedPlaceId, dayPlaces, selectedPlace, paddingOpts }: SelectionControllerProps) {
function SelectionController({ follow, places, selectedPlaceId, dayPlaces, selectedPlace, paddingOpts }: SelectionControllerProps) {
const map = useMap()
const prev = useRef(null)
useEffect(() => {
if (selectedPlaceId && selectedPlaceId !== prev.current) {
if (follow && selectedPlaceId && selectedPlaceId !== prev.current) {
// Pan to the selected place without changing zoom. Offset the centre by the
// side-panel + bottom-inspector padding so the pin lands in the middle of the
// *visible* map area rather than the geometric centre (where the bottom panel
@@ -386,7 +390,7 @@ function SelectionController({ places, selectedPlaceId, dayPlaces, selectedPlace
}
}
prev.current = selectedPlaceId
}, [selectedPlaceId, places, dayPlaces, selectedPlace, map])
}, [selectedPlaceId, places, dayPlaces, selectedPlace, map, follow])
return null
}
@@ -429,6 +433,8 @@ interface BoundsControllerProps {
* needs that leg on screen, which is neither the day nor the trip.
*/
focusPoints?: [number, number][]
/** False while the map is locked (#2010): an arriving route no longer re-fits it. */
follow?: boolean
/**
* What the caller's own chrome covers while `focusPoints` is framed, in pixels per edge.
*
@@ -449,7 +455,7 @@ function leafletPadding(box: ViewportPadding): L.FitBoundsOptions {
}
}
function BoundsController({ places, routeCoords, fitKey, paddingOpts, framedOnMount = false, focusPoints, fitPadding }: BoundsControllerProps) {
function BoundsController({ places, routeCoords, fitKey, paddingOpts, framedOnMount = false, focusPoints, fitPadding, follow = true }: BoundsControllerProps) {
const map = useMap()
const prevFitKey = useRef(-1)
const awaitingRoute = useRef(false)
@@ -503,6 +509,8 @@ function BoundsController({ places, routeCoords, fitKey, paddingOpts, framedOnMo
useEffect(() => {
if (!awaitingRoute.current || routeCoords.length === 0) return
awaitingRoute.current = false
// A map locked since that fit keeps its view when the route turns up (#2010).
if (!follow) return
fitTo([...places.map(p => [p.lat, p.lng] as [number, number]), ...routeCoords])
}, [routeCoords]) // eslint-disable-line react-hooks/exhaustive-deps
@@ -733,6 +741,12 @@ export const MapView = memo(function MapView({
// One colour pair per entry of `route`, or absent for the blue the route has always
// been. Only the road trip passes these, and only while colouring by day is on.
routeColors = null,
// One flag per entry of `route`: a walked stretch is drawn dashed (#2532).
routeWalking = null,
// False while the map is locked (#2010): picking a place leaves the view alone.
followSelection = true,
// Given, the lock that sets followSelection sits above the layer switcher (#2010).
onToggleFollow,
routeSegments = [],
selectedPlaceId = null,
// The selected place itself, for when no pin on this map stands for it.
@@ -1186,8 +1200,8 @@ export const MapView = memo(function MapView({
)}
<MapController center={center} zoom={zoom} />
<BoundsController places={dayPlaces.length > 0 ? dayPlaces : places} routeCoords={dayPlaces.length > 0 ? routeCoords : []} fitKey={fitKey} paddingOpts={paddingOpts} framedOnMount={initialView.framed} focusPoints={focusPoints} fitPadding={fitPadding} />
<SelectionController places={places} selectedPlaceId={selectedPlaceId} dayPlaces={dayPlaces} selectedPlace={selectedPlace} paddingOpts={paddingOpts} />
<BoundsController places={dayPlaces.length > 0 ? dayPlaces : places} routeCoords={dayPlaces.length > 0 ? routeCoords : []} fitKey={fitKey} paddingOpts={paddingOpts} framedOnMount={initialView.framed} focusPoints={focusPoints} fitPadding={fitPadding} follow={followSelection} />
<SelectionController follow={followSelection} places={places} selectedPlaceId={selectedPlaceId} dayPlaces={dayPlaces} selectedPlace={selectedPlace} paddingOpts={paddingOpts} />
<MapClickHandler onClick={onMapClick} />
<MapContextMenuHandler onContextMenu={onMapContextMenu} />
<CameraHoverGuard movingRef={mapMovingRef} onMoveStart={clearHover} onZoom={setMapZoom} />
@@ -1202,7 +1216,15 @@ export const MapView = memo(function MapView({
{/* Apple-Maps style: darker-blue casing under a bright-blue core, rounded.
The casing carries the click when the route can be reshaped: it is the wider of
the two, so it is the one a pointer actually lands on. */}
{route && route.length > 0 && route.flatMap((seg, i) => seg.length > 1 ? [
{route && route.length > 0 && route.flatMap((seg, i) => seg.length < 2 ? [] : routeWalking?.[i] ? [
// Walked, so dashed and without the casing: the drive is the solid line (#2532).
<Polyline
key={`${i}-walk`}
positions={seg}
pathOptions={{ color: routeColors?.[i]?.line ?? '#0a84ff', weight: 4, opacity: 1, dashArray: '1 9', lineCap: 'round', lineJoin: 'round' }}
interactive={false}
/>,
] : [
<Polyline
key={`${i}-casing`}
positions={seg}
@@ -1223,7 +1245,7 @@ export const MapView = memo(function MapView({
pathOptions={{ color: routeColors?.[i]?.line ?? '#0a84ff', weight: 5, opacity: 1, lineCap: 'round', lineJoin: 'round' }}
interactive={false}
/>,
] : [])}
])}
{/* The last bit to a place the road does not reach.
Dashed and thin, over the route rather than under it, because it is the one
@@ -1349,7 +1371,8 @@ export const MapView = memo(function MapView({
top of the map, which read as the control moving house rather than as room being
made: the inspector is a centred card at most 800 wide, so the corner it would
have been clearing is one the card never reaches. */}
<div style={{ position: 'absolute', left: leftWidth + MAP_LAYER_SWITCHER_INSET, bottom: switcherBottom, zIndex: 1000, pointerEvents: 'none' }}>
<div style={{ position: 'absolute', left: leftWidth + MAP_LAYER_SWITCHER_INSET, bottom: switcherBottom, zIndex: 1000, pointerEvents: 'none', display: 'flex', flexDirection: 'column', gap: 8 }}>
{onToggleFollow && <MapLockPill locked={!followSelection} onToggle={onToggleFollow} />}
<MapLayerSwitcher active={baseLayer} onToggle={toggleBaseLayer} />
</div>
</div>
+40 -14
View File
@@ -10,8 +10,10 @@ import type mapboxgl from 'mapbox-gl'
import { useSettingsStore } from '../../store/settingsStore'
import { useTranslation } from '../../i18n/TranslationContext'
import { MapLayerSwitcher, MAP_LAYER_SWITCHER_INSET, type BaseLayer } from './MapLayerSwitcher'
import { MapLockPill } from './MapLockPill'
import { useAuthStore } from '../../store/authStore'
import { getCached, isLoading, fetchPhoto, onThumbReady, getAllThumbs } from '../../services/photoService'
import { placeMarkerLook, type PlaceMarkerFlags } from './markerLook'
import { isCustomPlaceImage, markerPhotoHtml, photoCacheKey, photoSourcesKey, placePhotoFull, placePhotoUrl } from './placePhoto'
import { CATEGORY_ICON_MAP } from '../shared/categoryIcons'
import { isStandardFamily, supportsCustom3d, wantsTerrain, addCustom3dBuildings, addTerrainAndSky } from './mapboxSetup'
@@ -147,6 +149,12 @@ interface Props {
* been. Only the road trip passes these, and only while colouring by day is on.
*/
routeColors?: ({ line: string; casing: string } | undefined)[] | null
/** One flag per entry of `route`: true where the stretch is walked, drawn dashed (#2532). */
routeWalking?: boolean[] | null
/** False while the map is locked (#2010): picking a place leaves the view alone. */
followSelection?: boolean
/** Given, the lock that sets followSelection sits above the layer switcher. */
onToggleFollow?: () => void
routeSegments?: RouteSegment[]
selectedPlaceId?: number | null
/** The selected place itself, for when no pin on this map stands for it. */
@@ -355,7 +363,7 @@ function applySatellite(map: any, on: boolean): void {
} catch { /* a style that refuses the layer keeps the plain basemap */ }
}
function createMarkerElement(place: Place & { category_color?: string; category_icon?: string }, photoUrl: string | null, orderNumbers: number[] | null, selected: boolean): HTMLDivElement {
function createMarkerElement(place: Place & PlaceMarkerFlags & { category_color?: string; category_icon?: string }, photoUrl: string | null, orderNumbers: number[] | null, selected: boolean): HTMLDivElement {
// A stop that interrupts the drive gets its own small disc, decided before the photo
// branch: the brand logo a fuel search comes back with is exactly what this replaces.
// No number badge either, for the same reason the rail gives it none.
@@ -368,10 +376,10 @@ function createMarkerElement(place: Place & { category_color?: string; category_
return wrap
}
const size = selected ? 44 : 36
const look = placeMarkerLook(place, selected)
const { size, borderWidth } = look
// See MapView: allow-listed rather than escaped, because this is a CSS context.
const borderColor = selected ? '#111827' : safeHexColor(place.category_color, 'white')
const borderWidth = selected ? 3 : 2.5
const shadow = selected
? '0 0 0 3px rgba(17,24,39,0.25), 0 4px 14px rgba(0,0,0,0.3)'
: '0 2px 8px rgba(0,0,0,0.22)'
@@ -386,7 +394,7 @@ function createMarkerElement(place: Place & { category_color?: string; category_
// Same corner, same rule as the Leaflet map: numbers when the place is planned into a
// day, the rating when it is not.
let badgeHtml = ratingBadgeHtml((place as { rating_avg?: number | null }).rating_avg)
let badgeHtml = look.showRating ? ratingBadgeHtml((place as { rating_avg?: number | null }).rating_avg) : ''
if (orderNumbers && orderNumbers.length > 0) {
const label = orderNumbers.join(' · ')
badgeHtml = `<span style="
@@ -413,7 +421,7 @@ function createMarkerElement(place: Place & { category_color?: string; category_
// to its stacked slot, not to the map viewport.
wrap.style.cssText = `width:${outer}px;height:${outer}px;cursor:pointer;`
const hasPhoto = photoUrl && (photoUrl.startsWith('data:') || photoUrl.startsWith('/api/maps/place-photo/') || photoUrl.startsWith('/uploads/'))
const hasPhoto = look.showPhoto && photoUrl && (photoUrl.startsWith('data:') || photoUrl.startsWith('/api/maps/place-photo/') || photoUrl.startsWith('/uploads/'))
if (hasPhoto) {
wrap.innerHTML = `
<div style="
@@ -422,7 +430,7 @@ function createMarkerElement(place: Place & { category_color?: string; category_
border:${borderWidth}px solid ${borderColor};
box-shadow:${shadow};
overflow:hidden;background:${bgColor};
box-sizing:content-box;
box-sizing:content-box;${look.circleCss}
">
${markerPhotoHtml(photoUrl)}
</div>
@@ -437,9 +445,9 @@ function createMarkerElement(place: Place & { category_color?: string; category_
box-shadow:${shadow};
background:${bgColor};
display:flex;align-items:center;justify-content:center;
box-sizing:content-box;
box-sizing:content-box;${look.circleCss}
">
${categoryIconSvg(place.category_icon, selected ? 18 : 15)}
${categoryIconSvg(place.category_icon, look.iconSize)}
</div>
${badgeHtml}
`
@@ -678,6 +686,9 @@ export function MapViewGL({
accessLines = NO_ACCESS_LINES,
route = null,
routeColors = null,
routeWalking = null,
followSelection = true,
onToggleFollow,
routeSegments = NO_ROUTE_SEGMENTS,
selectedPlaceId = null,
selectedPlace = null,
@@ -1344,6 +1355,8 @@ export function MapViewGL({
source: 'trip-route',
// Per feature where the caller gave one, else the blue the route has always
// been. `coalesce` rather than a second layer: one source, one stroke.
// A walked stretch has no casing; it is drawn dashed by its own layer (#2532).
filter: ['!=', ['get', 'walks'], true],
paint: { 'line-color': ['coalesce', ['get', 'casing'], '#0a5cc2'], 'line-width': 8 },
layout: { 'line-cap': 'round', 'line-join': 'round' },
})
@@ -1361,9 +1374,18 @@ export function MapViewGL({
id: 'trip-route-line',
type: 'line',
source: 'trip-route',
filter: ['!=', ['get', 'walks'], true],
paint: { 'line-color': ['coalesce', ['get', 'color'], '#0a84ff'], 'line-width': 5 },
layout: { 'line-cap': 'round', 'line-join': 'round' },
})
map.addLayer({
id: 'trip-route-walk',
type: 'line',
source: 'trip-route',
filter: ['==', ['get', 'walks'], true],
paint: { 'line-color': ['coalesce', ['get', 'color'], '#0a84ff'], 'line-width': 4, 'line-dasharray': [0.1, 2.2] },
layout: { 'line-cap': 'round', 'line-join': 'round' },
})
}
// The last bit to a place the road does not reach (#1797). Added after the route
// so it draws over it: it is the one piece of the line that is not driving, and it
@@ -1851,7 +1873,7 @@ export function MapViewGL({
const orderNumbers = dayOrderMap[place.id] ?? null
const photoUrl = placePhotoUrl(place, photoUrls)
const selected = place.id === selectedPlaceId
const el = createMarkerElement(place as Place & { category_color?: string; category_icon?: string }, photoUrl, orderNumbers, selected)
const el = createMarkerElement(place as Place & PlaceMarkerFlags & { category_color?: string; category_icon?: string }, photoUrl, orderNumbers, selected)
// Drag onto a day in the plan (#891). Markers are rebuilt from scratch
// on every reconcile, so the listeners go with the element and need no
// teardown of their own.
@@ -2129,17 +2151,17 @@ export function MapViewGL({
const src = map.getSource('trip-route') as mapboxgl.GeoJSONSource | undefined
if (!src) return
const features = (route || [])
.map((seg, i) => ({ seg, colors: routeColors?.[i] }))
.map((seg, i) => ({ seg, colors: routeColors?.[i], walks: !!routeWalking?.[i] }))
.filter(({ seg }) => seg && seg.length > 1)
.map(({ seg, colors }) => ({
.map(({ seg, colors, walks }) => ({
type: 'Feature' as const,
// Null rather than absent: `coalesce` in the paint expression falls through on
// null, and an absent property would make every line the default colour.
properties: { color: colors?.line ?? null, casing: colors?.casing ?? null },
properties: { color: colors?.line ?? null, casing: colors?.casing ?? null, walks },
geometry: { type: 'LineString' as const, coordinates: seg.map(([lat, lng]) => [lng, lat]) },
}))
src.setData({ type: 'FeatureCollection', features })
}, [route, routeColors, mapReady])
}, [route, routeColors, routeWalking, mapReady])
// Update access-spur geojson
useEffect(() => {
@@ -2239,6 +2261,8 @@ export function MapViewGL({
&& !!routeFitKey
&& routeFitKey !== pendingRouteFitRef.current.routeKey
if (!fitKeyChanged && !routeArrivedForPendingFit) return
// Locked since the last fit (#2010): the day's route arriving must not move the view.
if (!fitKeyChanged && !followSelection) { pendingRouteFitRef.current = null; return }
const map = mapRef.current
if (!map) return
@@ -2310,7 +2334,7 @@ export function MapViewGL({
// flyTo selected place
useEffect(() => {
const map = mapRef.current
if (!map || !selectedPlaceId) return
if (!map || !selectedPlaceId || !followSelection) return
const target = selectedPlaceTarget(selectedPlaceId, places, dayPlaces, selectedPlace)
if (!target?.lat || !target?.lng) return
try {
@@ -2425,10 +2449,12 @@ export function MapViewGL({
covers. */}
<div style={{
position: 'absolute', left: leftWidth + MAP_LAYER_SWITCHER_INSET, zIndex: 1000, pointerEvents: 'none',
display: 'flex', flexDirection: 'column', gap: 8,
bottom: isMobile && hasDayDetail
? 'calc(var(--bottom-nav-h, 0px) + 20px + var(--day-panel-h, 0px) + 12px)'
: 'calc(var(--bottom-nav-h, 0px) + 12px)',
}}>
{onToggleFollow && <MapLockPill locked={!followSelection} onToggle={onToggleFollow} />}
<MapLayerSwitcher active={baseLayer as BaseLayer} onToggle={toggleBaseLayer} />
</div>
{/* Hover tooltip — cursor-following name/category/address card, identical to
@@ -2,7 +2,7 @@ import { Fragment, createElement, useMemo, useState } from 'react'
import { renderIconMarkup } from '../../utils/iconMarkup'
import { Marker, Polyline, Tooltip, useMap, useMapEvents } from 'react-leaflet'
import L from 'leaflet'
import { Plane, Train, Ship, Car, Bus, Sailboat, Bike, CarTaxiFront, Route, TramFront } from 'lucide-react'
import { Plane, Train, Ship, Car, Bus, Sailboat, CableCar, Bike, CarTaxiFront, Route, TramFront } from 'lucide-react'
import { escapeHtml } from '@trek/shared'
import { getTransitMapSegments, type TransitMapSegment } from './transitGeometry'
import { geodesicArcs } from './flightGeodesy'
@@ -13,8 +13,8 @@ import type { Reservation, ReservationEndpoint } from '../../types'
const ENDPOINT_PANE = 'reservation-endpoints'
type TransportType = 'flight' | 'train' | 'cruise' | 'car' | 'bus' | 'taxi' | 'bicycle' | 'ferry' | 'transit' | 'transport_other'
const TRANSPORT_TYPES: TransportType[] = ['flight', 'train', 'cruise', 'car', 'bus', 'taxi', 'bicycle', 'ferry', 'transit', 'transport_other']
type TransportType = 'flight' | 'train' | 'cruise' | 'car' | 'bus' | 'taxi' | 'bicycle' | 'ferry' | 'cable_car' | 'transit' | 'transport_other'
const TRANSPORT_TYPES: TransportType[] = ['flight', 'train', 'cruise', 'car', 'bus', 'taxi', 'bicycle', 'ferry', 'cable_car', 'transit', 'transport_other']
const TRANSPORT_COLOR = '#3b82f6'
@@ -27,6 +27,8 @@ const TYPE_META: Record<TransportType, { color: string; icon: typeof Plane; geod
taxi: { color: TRANSPORT_COLOR, icon: CarTaxiFront, geodesic: false },
bicycle: { color: TRANSPORT_COLOR, icon: Bike, geodesic: false },
ferry: { color: TRANSPORT_COLOR, icon: Sailboat, geodesic: true },
// Valley to mountain station as the rope runs: a straight line, never a road (#2535).
cable_car: { color: TRANSPORT_COLOR, icon: CableCar, geodesic: false },
transit: { color: TRANSPORT_COLOR, icon: TramFront, geodesic: false },
transport_other: { color: TRANSPORT_COLOR, icon: Route, geodesic: false },
}
+10 -37
View File
@@ -1,9 +1,8 @@
import { AlertTriangle, Route } from 'lucide-react'
import { useTranslation } from '../../i18n'
import { Tooltip } from '../shared/Tooltip'
import { profileIcon } from '../Planner/DayPlanSidebarRouteConnector'
import { MapTogglePill } from './MapTogglePill'
import { routeModeIcon } from '../Planner/routeModes'
import { formatDistance } from '../../utils/units'
import { MAP_CONTROL_SHADOW } from './mapControlShadow'
import type { TripRouteOverview as Overview } from './useTripRouteOverview'
import type { DistanceUnit } from '../../types'
@@ -13,40 +12,14 @@ import type { DistanceUnit } from '../../types'
*/
export function TripRouteOverviewPill({ active, onToggle }: { active: boolean; onToggle: () => void }) {
const { t } = useTranslation()
const label = active ? t('map.overview.hide') : t('map.overview.show')
return (
<div style={{
display: 'inline-flex', alignItems: 'center', padding: 4, borderRadius: 999, pointerEvents: 'auto',
background: 'var(--sidebar-bg)',
backdropFilter: 'blur(20px) saturate(180%)',
WebkitBackdropFilter: 'blur(20px) saturate(180%)',
boxShadow: MAP_CONTROL_SHADOW,
}}>
{/* TREK's own tooltip, not the browser's — the native one ignores the
colour scheme, waits a second and a half, and cannot be read on a touch
device at all. `left`, because these controls hug the right edge of the
map and a tooltip to the right would hang off it. */}
<Tooltip label={label} placement="left">
<button
type="button"
onClick={onToggle}
aria-label={label}
aria-pressed={active}
data-testid="trip-overview-pill"
className={active ? 'text-accent' : 'text-content-muted'}
style={{
display: 'inline-flex', alignItems: 'center', justifyContent: 'center',
width: 34, height: 34, borderRadius: 999, border: 'none', cursor: 'pointer',
background: 'transparent', padding: 0,
transition: 'background 0.14s, color 0.14s',
}}
onMouseEnter={e => { e.currentTarget.style.background = 'var(--bg-hover)' }}
onMouseLeave={e => { e.currentTarget.style.background = 'transparent' }}
>
<Route size={17} strokeWidth={2} />
</button>
</Tooltip>
</div>
<MapTogglePill
active={active}
onToggle={onToggle}
label={active ? t('map.overview.hide') : t('map.overview.show')}
testId="trip-overview-pill"
icon={<Route size={17} strokeWidth={2} />}
/>
)
}
@@ -123,7 +96,7 @@ export function TripRouteOverviewPanel({ overview, unit, selectedDayId, onSelect
</span>
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 3, flexShrink: 0 }} className="text-content-muted">
{day.modes.map(mode => {
const Icon = profileIcon(mode)
const Icon = routeModeIcon(mode)
return <Icon key={mode} size={12} strokeWidth={2} aria-hidden />
})}
</span>
+3 -1
View File
@@ -4,7 +4,7 @@ import { getDayBookendHotels, shouldDrawMorningLeg, shouldDrawEveningLeg, type C
import { withinDriveRange } from '@trek/shared/roadtrip'
import type { Accommodation, AssignmentsMap, Day, Reservation } from '../../types'
export const TRANSPORT_TYPES = ['flight', 'train', 'bus', 'car', 'taxi', 'bicycle', 'cruise', 'ferry', 'transit', 'transport_other']
export const TRANSPORT_TYPES = ['flight', 'train', 'bus', 'car', 'taxi', 'bicycle', 'cruise', 'ferry', 'cable_car', 'transit', 'transport_other']
/** A waypoint on a day's drive, carrying the per-leg modes `resolveLegMode` reads off it. */
export interface DayRoutePoint {
@@ -86,6 +86,8 @@ export function buildDayRouteRuns(dayId: number, input: DayRouteInputs): DayRout
if (item.type === 'place') {
const a = item.data
if (!a.place?.lat || !a.place?.lng) return []
// Kept on the day, shown on the map, but not driven to (#2532).
if ((a as { route_excluded?: boolean }).route_excluded) return []
return [{
kind: 'place', lat: a.place.lat, lng: a.place.lng, pos: item.sortKey, time: a.place?.place_time ?? null,
// Per-segment travel mode (#1281): mode of the leg leaving this place.
+41
View File
@@ -0,0 +1,41 @@
/**
* Two ways a place marker can be told apart from the rest, set on the place object the
* planner hands to the map. Underscored because they are the map's reading of the plan,
* never fields of a stored place.
*/
export interface PlaceMarkerFlags {
/** Not planned into any day, drawn small and without its photo (#2024). */
_compact?: boolean
/** Its stay's booking still waits for a confirmation (#2281). */
_pending?: boolean
}
export interface PlaceMarkerLook {
size: number
borderWidth: number
showPhoto: boolean
showRating: boolean
iconSize: number
/** Extra CSS for the round marker: a dashed ring and a faded face for a pending stay. */
circleCss: string
/** Part of the icon cache key, so a changed look is never served from the cache. */
key: string
}
/**
* How a place marker is drawn, the same way on the Leaflet and the GL map. A selected
* marker always shows in full: whatever made it quiet, it is the one being looked at.
*/
export function placeMarkerLook(place: PlaceMarkerFlags, selected: boolean): PlaceMarkerLook {
const compact = !!place._compact && !selected
const pending = !!place._pending
return {
size: selected ? 44 : compact ? 24 : 36,
borderWidth: selected ? 3 : compact ? 2 : 2.5,
showPhoto: !compact,
showRating: !compact,
iconSize: selected ? 18 : compact ? 12 : 15,
circleCss: pending ? 'border-style:dashed;opacity:0.62;' : '',
key: `${compact ? 'c' : ''}${pending ? 'p' : ''}`,
}
}
@@ -9,7 +9,7 @@
import { createElement } from 'react'
import { renderIconMarkup } from '../../utils/iconMarkup'
import type mapboxgl from 'mapbox-gl'
import { Plane, Train, Ship, Car, Bus, Sailboat, Bike, CarTaxiFront, Route, TramFront } from 'lucide-react'
import { Plane, Train, Ship, Car, Bus, Sailboat, CableCar, Bike, CarTaxiFront, Route, TramFront } from 'lucide-react'
import { getTransitMapSegments } from './transitGeometry'
import { geodesicArcs } from './flightGeodesy'
import { cleanEndpointName } from './reservationName'
@@ -22,8 +22,8 @@ export const RESERVATION_LINE_LAYER_ID = 'trek-reservations-lines'
/** Sits under the coloured transit lines; named here so teardown can find it. */
export const TRANSIT_CASING_LAYER_ID = `${RESERVATION_LINE_LAYER_ID}-transit-casing`
type TransportType = 'flight' | 'train' | 'cruise' | 'car' | 'bus' | 'taxi' | 'bicycle' | 'ferry' | 'transit' | 'transport_other'
const TRANSPORT_TYPES: TransportType[] = ['flight', 'train', 'cruise', 'car', 'bus', 'taxi', 'bicycle', 'ferry', 'transit', 'transport_other']
type TransportType = 'flight' | 'train' | 'cruise' | 'car' | 'bus' | 'taxi' | 'bicycle' | 'ferry' | 'cable_car' | 'transit' | 'transport_other'
const TRANSPORT_TYPES: TransportType[] = ['flight', 'train', 'cruise', 'car', 'bus', 'taxi', 'bicycle', 'ferry', 'cable_car', 'transit', 'transport_other']
const TRANSPORT_COLOR = '#3b82f6'
const TYPE_META: Record<TransportType, { icon: typeof Plane; geodesic: boolean }> = {
@@ -35,6 +35,7 @@ const TYPE_META: Record<TransportType, { icon: typeof Plane; geodesic: boolean }
taxi: { icon: CarTaxiFront, geodesic: false },
bicycle: { icon: Bike, geodesic: false },
ferry: { icon: Sailboat, geodesic: true },
cable_car: { icon: CableCar, geodesic: false },
transit: { icon: TramFront, geodesic: false },
transport_other: { icon: Route, geodesic: false },
}
+10 -4
View File
@@ -1,7 +1,7 @@
// Trip PDF via browser print window
import { createElement } from 'react'
import { getCategoryIcon } from '../shared/categoryIcons'
import { FileText, Info, Clock, MapPin, Navigation, Train, Plane, Bus, Car, Ship, Sailboat, Bike, CarTaxiFront, Route, Coffee, Ticket, Star, Heart, Camera, Flag, Lightbulb, AlertTriangle, ShoppingBag, Bookmark, Hotel, LogIn, LogOut, KeyRound, BedDouble, Utensils, Users, ParkingSquare, LucideIcon } from 'lucide-react'
import { FileText, Info, Clock, MapPin, Navigation, Train, Plane, Bus, Car, Ship, Sailboat, CableCar, Bike, CarTaxiFront, Route, Coffee, Ticket, Star, Heart, Camera, Flag, Lightbulb, AlertTriangle, ShoppingBag, Bookmark, Hotel, LogIn, LogOut, KeyRound, BedDouble, Utensils, Users, ParkingSquare, LucideIcon } from 'lucide-react'
import { accommodationsApi, mapsApi, pluginsApi } from '../../api/client'
import type { Trip, Day, Place, Category, AssignmentsMap, DayNote, DistanceUnit, BudgetItem } from '../../types'
import { isDayInAccommodationRange, getDayOrder } from '../../utils/dayOrder'
@@ -20,6 +20,7 @@ import { formatDistance } from '../../utils/units'
import { fetchExchangeRates } from '../../hooks/useExchangeRates'
import { getFlightLegs, getTrainLegs } from '../../utils/flightLegs'
import { isServiceStopType } from '../Roadtrip/roadtripModel'
import { onlyMyPlan } from './pdfScope'
/**
* Every day starts a new page by default. On a trip of short days that prints
@@ -64,8 +65,8 @@ function noteIconSvg(iconId) {
return renderLucideIcon(Icon, { size: 14, strokeWidth: 1.8, color: '#94a3b8' })
}
const RESERVATION_ICON_MAP = { flight: Plane, train: Train, bus: Bus, car: Car, taxi: CarTaxiFront, bicycle: Bike, cruise: Ship, ferry: Sailboat, transport_other: Route, restaurant: Utensils, event: Ticket, tour: Users, parking: ParkingSquare, other: FileText }
const RESERVATION_COLOR_MAP = { flight: '#3b82f6', train: '#06b6d4', bus: '#059669', car: '#6b7280', taxi: '#ca8a04', bicycle: '#84cc16', cruise: '#0ea5e9', ferry: '#0d9488', transport_other: '#6b7280', restaurant: '#ef4444', event: '#f59e0b', tour: '#10b981', parking: '#2563eb', other: '#6b7280' }
const RESERVATION_ICON_MAP = { flight: Plane, train: Train, bus: Bus, car: Car, taxi: CarTaxiFront, bicycle: Bike, cruise: Ship, ferry: Sailboat, cable_car: CableCar, transport_other: Route, restaurant: Utensils, event: Ticket, tour: Users, parking: ParkingSquare, other: FileText }
const RESERVATION_COLOR_MAP = { flight: '#3b82f6', train: '#06b6d4', bus: '#059669', car: '#6b7280', taxi: '#ca8a04', bicycle: '#84cc16', cruise: '#0ea5e9', ferry: '#0d9488', cable_car: '#dc2626', transport_other: '#6b7280', restaurant: '#ef4444', event: '#f59e0b', tour: '#10b981', parking: '#2563eb', other: '#6b7280' }
function reservationIconSvg(type) {
const Icon = RESERVATION_ICON_MAP[type] || Ticket
const color = RESERVATION_COLOR_MAP[type] || '#3b82f6'
@@ -203,6 +204,8 @@ interface downloadTripPDFProps {
budgetItems?: BudgetItem[]
/** Whether the Costs addon is on. Read from the addon store when left out. */
costsEnabled?: boolean
/** Prints only this member's plan (#2168): the stops and bookings that name them, and the ones that name nobody. */
onlyUserId?: number
}
/**
@@ -223,7 +226,10 @@ function planAssignments(assignments: AssignmentsMap, showServiceStops: boolean)
// `assignments` is normalised here once, to the plan's own list; every read below
// (and fetchPlacePhotos) relies on it being an object.
export async function downloadTripPDF({ trip, days, places, assignments: stored = {}, categories, dayNotes, reservations = [], t: _t, locale: _locale, timeFormat: _timeFormat, distanceUnit: _distanceUnit, showServiceStops = true, budgetItems, costsEnabled }: downloadTripPDFProps) {
export async function downloadTripPDF({ trip, days, places, assignments: allStored = {}, categories, dayNotes, reservations: allReservations = [], t: _t, locale: _locale, timeFormat: _timeFormat, distanceUnit: _distanceUnit, showServiceStops = true, budgetItems, costsEnabled, onlyUserId }: downloadTripPDFProps) {
const { assignments: stored, reservations } = onlyUserId != null
? onlyMyPlan(allStored, allReservations, onlyUserId)
: { assignments: allStored, reservations: allReservations }
const assignments = planAssignments(stored, showServiceStops)
const breaksPerDay = pageBreakPerDay()
const loc = _locale || undefined
@@ -0,0 +1,29 @@
// FE-PDF-SCOPE-001 to FE-PDF-SCOPE-002 (#2168)
import { describe, it, expect } from 'vitest'
import type { AssignmentsMap } from '../../types'
import { hasPersonalPlan, onlyMyPlan } from './pdfScope'
const assignments = {
1: [
{ id: 11, participants: [] },
{ id: 12, participants: [{ user_id: 7, username: 'me' }] },
{ id: 13, participants: [{ user_id: 8, username: 'anna' }] },
],
2: [{ id: 21 }],
} as unknown as AssignmentsMap
describe('pdfScope', () => {
it('FE-PDF-SCOPE-001: offers a personal plan only once somebody was given a part', () => {
expect(hasPersonalPlan(assignments, [])).toBe(true)
expect(hasPersonalPlan({ 1: [{ id: 1 }] } as unknown as AssignmentsMap, [{ travelers: [] }])).toBe(false)
expect(hasPersonalPlan({}, [{ travelers: [{ user_id: 1 }] }])).toBe(true)
})
it('FE-PDF-SCOPE-002: keeps what names me and what names nobody', () => {
const reservations = [{ id: 1, travelers: [{ user_id: 7 }] }, { id: 2, travelers: [{ user_id: 8 }] }, { id: 3 }]
const out = onlyMyPlan(assignments, reservations, 7)
expect(out.assignments[1].map(a => a.id)).toEqual([11, 12])
expect(out.assignments[2].map(a => a.id)).toEqual([21])
expect(out.reservations.map(r => r.id)).toEqual([1, 3])
})
})
+29
View File
@@ -0,0 +1,29 @@
import type { AssignmentsMap } from '../../types'
interface WithTravelers { travelers?: { user_id: number }[] | null }
/**
* Whether anyone on the trip has been given their own part of it (#2168): a stop
* with participants or a booking with travellers. Without either, "my plan" is
* the whole plan, and offering it would only be a second way to the same file.
*/
export function hasPersonalPlan(assignments: AssignmentsMap, reservations: WithTravelers[]): boolean {
return Object.values(assignments).some(list => (list || []).some(a => (a.participants || []).length > 0))
|| reservations.some(r => (r.travelers || []).length > 0)
}
/**
* The plan one member goes on (#2168). A stop or booking that names its people
* stays only when it names them; one that names nobody is everybody's and stays.
*/
export function onlyMyPlan<R extends WithTravelers>(
assignments: AssignmentsMap,
reservations: R[],
userId: number,
): { assignments: AssignmentsMap; reservations: R[] } {
const mine = (people: { user_id: number }[] | null | undefined) => !people?.length || people.some(p => p.user_id === userId)
return {
assignments: Object.fromEntries(Object.entries(assignments).map(([dayId, list]) => [dayId, (list || []).filter(a => mine(a.participants))])),
reservations: reservations.filter(r => mine(r.travelers)),
}
}
@@ -9,9 +9,10 @@ import {
MoreHorizontal, ChevronDown, ChevronRight,
} from 'lucide-react'
import type { PackingItem, PackingBag } from '../../types'
import { katColor } from './packingListPanel.helpers'
import { katColor, packedOf } from './packingListPanel.helpers'
import { PACKING_PLACEHOLDER_NAME } from './packingListPanel.constants'
import { QuantityInput } from './PackingListPanelQuantityInput'
import { PackedCounter } from './PackingPackedCounter'
import PackingShareControl from './PackingShareControl'
import { PopoverItem } from './PackingPopover'
import { POPOVER, POPOVER_CAPTION, POPOVER_DIVIDER } from './packingPopoverStyles'
@@ -65,7 +66,7 @@ export function ArtikelZeile({ item, tripId, categories, onCategoryChange: _onCa
const [bagInlineCreate, setBagInlineCreate] = useState(false)
const [bagInlineName, setBagInlineName] = useState('')
const itemMenuBtnRef = useRef<HTMLButtonElement>(null)
const { togglePackingItem, updatePackingItem, deletePackingItem } = useTripStore()
const { togglePackingItem, updatePackingItem, deletePackingItem, setPackedCount } = useTripStore()
const toast = useToast()
const { t } = useTranslation()
@@ -104,6 +105,9 @@ export function ArtikelZeile({ item, tripId, categories, onCategoryChange: _onCa
const canDrag = canEdit && !isPlaceholder && !!drag
const selectedBag = bags.find(b => b.id === item.bag_id)
const quantity = item.quantity || 1
// A multi-piece item counts its pieces into the bag (#2296).
const packed = packedOf(item)
const partlyPacked = !item.checked && packed > 0
const owner = tripMembers.find(m => m.id === item.owner_id)
const ownerAvatar = owner?.avatar_url ?? owner?.avatar ?? null
@@ -167,7 +171,7 @@ export function ArtikelZeile({ item, tripId, categories, onCategoryChange: _onCa
<button type="button" onClick={handleToggle} className="packing-check" aria-pressed={!!item.checked} style={{
flexShrink: 0, cursor: 'pointer', padding: 0, width: 20, height: 20, borderRadius: 6,
display: 'grid', placeItems: 'center',
border: `1.5px solid ${item.checked ? 'var(--accent)' : 'var(--text-faint)'}`,
border: `1.5px solid ${item.checked || partlyPacked ? 'var(--accent)' : 'var(--text-faint)'}`,
background: item.checked ? 'var(--accent)' : 'transparent',
color: 'var(--accent-text)',
transition: 'background 180ms cubic-bezier(0.23,1,0.32,1), border-color 180ms cubic-bezier(0.23,1,0.32,1)',
@@ -233,6 +237,9 @@ export function ArtikelZeile({ item, tripId, categories, onCategoryChange: _onCa
)}
<div className="packing-row-inline-actions" style={{ display: 'flex', alignItems: 'center', gap: 6, flexShrink: 0 }}>
{canEdit && !isPlaceholder && quantity > 1 && (
<PackedCounter packed={packed} quantity={quantity} onChange={count => setPackedCount(tripId, item.id, count)} style={reveal(active || partlyPacked)} />
)}
{/* Quantity */}
{canEdit && <QuantityInput value={quantity} onSave={qty => updatePackingItem(tripId, item.id, { quantity: qty })} style={badge(active || quantity > 1)} />}
@@ -0,0 +1,31 @@
// FE-COMP-PACKEDCOUNTER-001 to FE-COMP-PACKEDCOUNTER-003 (#2296)
import { vi } from 'vitest';
import { render, screen, fireEvent } from '../../../tests/helpers/render';
import { PackedCounter } from './PackingPackedCounter';
import { packedOf } from './packingListPanel.helpers';
describe('PackedCounter', () => {
it('FE-COMP-PACKEDCOUNTER-001: shows the count and steps it both ways', () => {
const onChange = vi.fn();
render(<PackedCounter packed={3} quantity={10} onChange={onChange} />);
expect(screen.getByText('3/10')).toBeInTheDocument();
expect(screen.getByRole('group', { name: '3 of 10 packed' })).toBeInTheDocument();
fireEvent.click(screen.getByRole('button', { name: 'Pack one more' }));
fireEvent.click(screen.getByRole('button', { name: 'Pack one less' }));
expect(onChange.mock.calls).toEqual([[4], [2]]);
});
it('FE-COMP-PACKEDCOUNTER-002: stops at nothing and at all of it', () => {
const { rerender } = render(<PackedCounter packed={0} quantity={2} onChange={() => {}} />);
expect(screen.getByRole('button', { name: 'Pack one less' })).toBeDisabled();
rerender(<PackedCounter packed={2} quantity={2} onChange={() => {}} />);
expect(screen.getByRole('button', { name: 'Pack one more' })).toBeDisabled();
});
it('FE-COMP-PACKEDCOUNTER-003: a ticked item reads as fully packed', () => {
expect(packedOf({ checked: 1, quantity: 4, packed_quantity: null })).toBe(4);
expect(packedOf({ checked: 0, quantity: 4, packed_quantity: 3 })).toBe(3);
expect(packedOf({ checked: 0 })).toBe(0);
});
});
@@ -0,0 +1,55 @@
import type { CSSProperties } from 'react'
import { Minus, Plus } from 'lucide-react'
import { useTranslation } from '../../i18n'
import { Tooltip } from '../shared/Tooltip'
interface PackedCounterProps {
packed: number
quantity: number
onChange: (count: number) => void
style?: CSSProperties
}
const stepStyle: CSSProperties = {
width: 18, height: 18, display: 'grid', placeItems: 'center', padding: 0, border: 'none',
borderRadius: 99, background: 'transparent', color: 'var(--text-muted)', cursor: 'pointer',
}
/**
* How many pieces of a multi-piece item are already in the bag (#2296): "7/10"
* between a minus and a plus. Reaching the quantity ticks the item off.
*/
export function PackedCounter({ packed, quantity, onChange, style }: PackedCounterProps) {
const { t } = useTranslation()
const partial = packed > 0 && packed < quantity
return (
<Tooltip label={t('packing.packedCount', { packed, total: quantity })}>
<div
role="group"
aria-label={t('packing.packedCount', { packed, total: quantity })}
style={{
display: 'inline-flex', alignItems: 'center', gap: 2, flexShrink: 0, height: 24, padding: '0 3px',
borderRadius: 99, border: '1px solid transparent',
background: partial ? 'color-mix(in srgb, var(--accent) 12%, transparent)' : 'var(--bg-tertiary)',
...style,
}}
>
<button type="button" aria-label={t('packing.packedLess')} disabled={packed <= 0} onClick={() => onChange(packed - 1)}
style={{ ...stepStyle, opacity: packed <= 0 ? 0.35 : 1, cursor: packed <= 0 ? 'default' : 'pointer' }}>
<Minus size={11} strokeWidth={2.5} />
</button>
<span style={{
minWidth: 30, textAlign: 'center', fontVariantNumeric: 'tabular-nums', fontWeight: 600,
fontSize: 'calc(11px * var(--fs-scale-caption, 1))',
color: partial ? 'var(--accent)' : 'var(--text-secondary)',
}}>
{packed}/{quantity}
</span>
<button type="button" aria-label={t('packing.packedMore')} disabled={packed >= quantity} onClick={() => onChange(packed + 1)}
style={{ ...stepStyle, opacity: packed >= quantity ? 0.35 : 1, cursor: packed >= quantity ? 'default' : 'pointer' }}>
<Plus size={11} strokeWidth={2.5} />
</button>
</div>
</Tooltip>
)
}
@@ -1,5 +1,5 @@
import { describe, it, expect } from 'vitest'
import { katColor, itemWeight, bagFillPct, bagTotalWeight, countsTowardsMyLoad, isMarkdownList, parseCsvLine, parseImportLines, sortItemsByName, unassignedTotalWeight } from './packingListPanel.helpers'
import { katColor, itemWeight, bagFillPct, bagTotalWeight, countsTowardsMyLoad, isMarkdownList, newItemSharing, parseCsvLine, parseImportLines, sortItemsByName, unassignedTotalWeight } from './packingListPanel.helpers'
import { KAT_COLORS } from './packingListPanel.constants'
describe('packingListPanel.helpers', () => {
@@ -242,3 +242,25 @@ describe('packingListPanel.helpers', () => {
})
})
})
describe('newItemSharing (#2241)', () => {
const mine = (id: number, category: string, recipients: number[]) =>
({ id, name: `item ${id}`, category, is_private: 1, owner_id: 7, recipients: recipients.map(user_id => ({ user_id, username: `u${user_id}` })) })
it('shares a new item the way every own item of the category is shared', () => {
const items = [mine(1, 'Beach', [9, 8]), mine(2, 'Beach', [8, 9]), mine(3, 'Other', [])]
expect(newItemSharing(items, 'Beach', 'personal', 7)).toEqual({ visibility: 'shared', recipient_ids: [8, 9] })
})
it('keeps it to me when the category disagrees, keeps something private or is new', () => {
expect(newItemSharing([mine(1, 'Beach', [8]), mine(2, 'Beach', [9])], 'Beach', 'personal', 7)).toEqual({ visibility: 'personal' })
expect(newItemSharing([mine(1, 'Beach', [8]), mine(2, 'Beach', [])], 'Beach', 'personal', 7)).toEqual({ visibility: 'personal' })
expect(newItemSharing([], 'Beach', 'personal', 7)).toEqual({ visibility: 'personal' })
// Items shared to me by somebody else say nothing about how I share mine.
expect(newItemSharing([{ ...mine(1, 'Beach', [7]), owner_id: 8 }], 'Beach', 'personal', 7)).toEqual({ visibility: 'personal' })
})
it('the shared list stays common', () => {
expect(newItemSharing([mine(1, 'Beach', [8])], 'Beach', 'common', 7)).toEqual({ visibility: 'common' })
})
})
@@ -196,3 +196,30 @@ export function sortItemsByName<T extends { name: string }>(items: T[], locale?:
return collator.compare(a.name.trim(), b.name.trim())
})
}
/** The packed count an item shows (#2296): all of it once ticked, its partial count before that. */
export function packedOf(item: { checked?: number | boolean; packed_quantity?: number | null; quantity?: number }): number {
return item.checked ? (item.quantity || 1) : (item.packed_quantity ?? 0)
}
type SharingItem = { category?: string | null; name: string; is_private?: number; owner_id?: number | null; recipients?: { user_id: number }[] }
/**
* The tier a new item takes in the given category (#2241). In "my list" a category
* whose own items are all shared with the very same people shares the next one with
* them too, so a list kept for two does not need every new line shared by hand. Any
* difference between the items, or one kept to myself, and the new item stays mine
* alone: sharing is only ever carried over when the category is unanimous about it.
*/
export function newItemSharing(
items: SharingItem[],
category: string,
view: 'common' | 'personal',
userId: number | null | undefined,
): { visibility: 'common' | 'personal' | 'shared'; recipient_ids?: number[] } {
if (view !== 'personal') return { visibility: 'common' }
const own = items.filter(i => i.category === category && i.name !== PACKING_PLACEHOLDER_NAME && !!i.is_private && i.owner_id === userId)
const sets = own.map(i => (i.recipients || []).map(r => r.user_id).sort((a, b) => a - b).join(','))
if (!own.length || !sets[0] || sets.some(set => set !== sets[0])) return { visibility: 'personal' }
return { visibility: 'shared', recipient_ids: sets[0].split(',').map(Number) }
}
@@ -11,7 +11,7 @@ import { useNetworkMode } from '../../hooks/useNetworkMode'
import { useBagTotalsPing } from './useBagTotalsPing'
import type { PackingItem, PackingBag } from '../../types'
import { BAG_COLORS, PACKING_PLACEHOLDER_NAME } from './packingListPanel.constants'
import { parseImportLines, sortItemsByName } from './packingListPanel.helpers'
import { newItemSharing, parseImportLines, sortItemsByName } from './packingListPanel.helpers'
const PACKING_SORT_KEY = 'trek:packing-sort'
@@ -153,8 +153,9 @@ export function usePackingList({ tripId, items, openImportSignal = 0, addCategor
if (placeholder) {
await updatePackingItem(tripId, placeholder.id, { name })
} else {
// New items inherit the active view's tier: Personal in "my list", Common otherwise.
await addPackingItem(tripId, { name, category, visibility: view === 'personal' ? 'personal' : 'common' } as Parameters<typeof addPackingItem>[1])
// New items inherit the active view's tier, and in "my list" the sharing the
// category's own items agree on (#2241).
await addPackingItem(tripId, { name, category, ...newItemSharing(items, category, view, currentUserId) } as Parameters<typeof addPackingItem>[1])
}
} catch { toast.error(t('packing.toast.addError')) }
}
@@ -3,6 +3,7 @@ import { MapPin } from 'lucide-react'
import { mapsApi } from '../../api/client'
import { useTranslation } from '../../i18n'
import { useLocationBias } from '../../hooks/useLocationBias'
import { usePlaceLanguage } from '../../hooks/usePlaceLanguage'
interface Props {
value: string
@@ -17,6 +18,7 @@ interface Props {
// is never lost, and picking a suggestion just replaces the text (#1496).
export default function AddressInput({ value, onChange, placeholder, className }: Props) {
const { t, locale } = useTranslation()
const placeLang = usePlaceLanguage()
// Ohne Reisekontext ist der Hinweis leer, und die Suche laeuft wie bisher.
const { point: locationBias } = useLocationBias()
const [open, setOpen] = useState(false)
@@ -50,7 +52,7 @@ export default function AddressInput({ value, onChange, placeholder, className }
const myReq = ++reqIdRef.current
setLoading(true)
try {
const data = await mapsApi.search(trimmed, locale, locationBias)
const data = await mapsApi.search(trimmed, placeLang, locationBias)
if (myReq !== reqIdRef.current) return
setResults(data.places || [])
setHighlight(-1)
@@ -2221,4 +2221,35 @@ describe('DayDetailPanel opening a booking', () => {
expect(screen.queryByRole('button', { name: /Grand Hotel Booking/ })).toBeNull();
expect(screen.getByRole('button', { name: '#XY99' })).toHaveAttribute('aria-pressed', 'false');
});
it('FE-PLANNER-DAYDETAIL-099: the arrival day shows only the check-in time and the middle night shows neither', async () => {
const middleDay = buildDay({ id: 2, trip_id: 1, date: '2025-06-16', title: 'Middle Day' });
server.use(
http.get('/api/trips/1/accommodations', () =>
HttpResponse.json({
accommodations: [{
id: 1, place_id: 5, place_name: 'Grand Hotel', place_address: 'Paris',
start_day_id: 1, end_day_id: 3, check_in: '14:00', check_out: '11:00', confirmation: null,
}],
})
),
);
const { unmount } = render(<DayDetailPanel {...defaultProps} />);
await screen.findByText('14:00');
expect(screen.queryByText('11:00')).toBeNull();
unmount();
render(<DayDetailPanel {...defaultProps} day={middleDay} days={[day, middleDay]} />);
await screen.findByText('Grand Hotel');
expect(screen.queryByText('14:00')).toBeNull();
expect(screen.queryByText('11:00')).toBeNull();
});
it('FE-PLANNER-DAYDETAIL-100: date-first headings lead with the date and name the day second', async () => {
seedStore(useSettingsStore, {
settings: { time_format: '24h', temperature_unit: 'celsius', day_date_first: true },
});
render(<DayDetailPanel {...defaultProps} />);
const title = await screen.findByText(/June 15/);
expect(title.className).toContain('font-bold');
});
});
@@ -11,6 +11,8 @@ import CustomTimePicker from '../shared/CustomTimePicker'
import { BlurredCode, BookingCodeInput } from '../shared/BookingCode'
import { Tooltip } from '../shared/Tooltip'
import { useSettingsStore } from '../../store/settingsStore'
import { dayHeadingParts } from '../../utils/dayLabel'
import { stayDayTimes } from './stayDayTimes'
import { useToast } from '../shared/Toast'
import { getLocaleForLanguage, useTranslation } from '../../i18n'
import type { Day, Place, Category, Reservation, AssignmentsMap, Accommodation } from '../../types'
@@ -82,6 +84,7 @@ export default function DayDetailPanel({ day, days, places, categories = [], tri
const canEditDays = can('day_edit', tripObj)
const isFahrenheit = useSettingsStore(s => s.settings.temperature_unit) === 'fahrenheit'
const is12h = useSettingsStore(s => s.settings.time_format) === '12h'
const dateFirst = useSettingsStore(s => s.settings.day_date_first === true)
const collapsed = collapsedProp
const toggleCollapse = () => onToggleCollapse?.()
@@ -147,7 +150,7 @@ export default function DayDetailPanel({ day, days, places, categories = [], tri
getLocaleForLanguage(language),
{ weekday: 'long', day: 'numeric', month: 'long', timeZone: 'UTC' }
) : null
const dayTitle = day.title || t('planner.dayN', { n: (days.indexOf(day) + 1) || '?' })
const heading = dayHeadingParts(day.title || t('planner.dayN', { n: (days.indexOf(day) + 1) || '?' }), formattedDate, dateFirst)
const showError = (err: unknown) => toast.error(apiErrorMessage(err) || t('common.unknownError'))
// Saving the picker: a new stay or the one being edited. Both say what went
@@ -199,12 +202,12 @@ export default function DayDetailPanel({ day, days, places, categories = [], tri
/>
) : collapsed ? (
<div className="truncate font-bold text-content" style={fs(13.5, 'body')}>
{dayTitle}
{formattedDate && <span className="ml-2 font-medium text-content-muted">{formattedDate}</span>}
{heading.primary}
{heading.secondary && <span className="ml-2 font-medium text-content-muted">{heading.secondary}</span>}
</div>
) : (
<div className="flex min-w-0 items-center gap-1">
<span className="truncate font-bold tracking-[-0.01em] text-content" style={fs(17, 'subtitle')}>{dayTitle}</span>
<span className="truncate font-bold tracking-[-0.01em] text-content" style={fs(17, 'subtitle')}>{heading.primary}</span>
{canEditDays && onUpdateDayTitle && (
<Tooltip label={t('common.edit')}>
<button type="button" onClick={startRename} aria-label={t('common.edit')}
@@ -215,7 +218,7 @@ export default function DayDetailPanel({ day, days, places, categories = [], tri
)}
</div>
)}
{!collapsed && formattedDate && <div className="mt-0.5 truncate font-geist text-content-muted" style={fs(12.5)}>{formattedDate}</div>}
{!collapsed && heading.secondary && <div className="mt-0.5 truncate font-geist text-content-muted" style={fs(12.5)}>{heading.secondary}</div>}
</div>
<HeadButton label={collapsed ? t('common.expand') : t('common.collapse')} expanded={!collapsed}
onClick={() => toggleCollapse()}>
@@ -457,8 +460,9 @@ function AccommodationCard({ acc, day, linked, canEdit, onEdit, onRemove, onOpen
const leaving = isCheckOutDay && !isCheckInDay
const tint = isCheckInDay ? tintOf('var(--success)') : isCheckOutDay ? tintOf('var(--danger)') : NEUTRAL_TINT
const cells: { label: string; value: ReactNode }[] = []
if (acc.check_in) cells.push({ label: t('day.checkIn'), value: `${fmtTime(acc.check_in)}${acc.check_in_end ? ` – ${fmtTime(acc.check_in_end)}` : ''}` })
if (acc.check_out) cells.push({ label: t('day.checkOut'), value: fmtTime(acc.check_out) })
const times = stayDayTimes(acc, day.id)
if (times.checkIn && acc.check_in) cells.push({ label: t('day.checkIn'), value: `${fmtTime(acc.check_in)}${acc.check_in_end ? ` – ${fmtTime(acc.check_in_end)}` : ''}` })
if (times.checkOut && acc.check_out) cells.push({ label: t('day.checkOut'), value: fmtTime(acc.check_out) })
if (acc.confirmation) cells.push({ label: t('day.confirmation'), value: <BlurredCode className="font-geist">{acc.confirmation}</BlurredCode> })
const confirmed = linked?.status === 'confirmed'
@@ -1,11 +1,11 @@
import {
FileText, Info, Clock, MapPin, Navigation, Train, Plane, Bus, Car, Ship,
Coffee, Ticket, Star, Heart, Camera, Flag, Lightbulb, AlertTriangle,
ShoppingBag, Bookmark, Hotel, Utensils, Users, Sailboat, Bike, CarTaxiFront, Route, TramFront,
ShoppingBag, Bookmark, Hotel, Utensils, Users, Sailboat, CableCar, Bike, CarTaxiFront, Route, TramFront,
Wine, ParkingSquare, Fuel, Footprints, Mountain, Waves, Sun, Umbrella, Music, Landmark, Gift,
} from 'lucide-react'
export const RES_ICONS = { flight: Plane, hotel: Hotel, restaurant: Utensils, train: Train, car: Car, cruise: Ship, bus: Bus, ferry: Sailboat, bicycle: Bike, taxi: CarTaxiFront, transit: TramFront, transport_other: Route, event: Ticket, tour: Users, parking: ParkingSquare, other: FileText }
export const RES_ICONS = { flight: Plane, hotel: Hotel, restaurant: Utensils, train: Train, car: Car, cruise: Ship, bus: Bus, ferry: Sailboat, cable_car: CableCar, bicycle: Bike, taxi: CarTaxiFront, transit: TramFront, transport_other: Route, event: Ticket, tour: Users, parking: ParkingSquare, other: FileText }
export const NOTE_ICONS = [
{ id: 'FileText', Icon: FileText },
@@ -46,8 +46,8 @@ export function getNoteIcon(iconId) { return NOTE_ICON_MAP[iconId] || FileText }
export const TYPE_ICONS = {
flight: '✈️', hotel: '🏨', restaurant: '🍽️', train: '🚆',
car: '🚗', cruise: '🚢', bus: '🚌', ferry: '⛴️', bicycle: '🚲', taxi: '🚕',
car: '🚗', cruise: '🚢', bus: '🚌', ferry: '⛴️', cable_car: '🚡', bicycle: '🚲', taxi: '🚕',
transport_other: '🧭', event: '🎫', parking: '🅿️', other: '📋',
}
export const TRANSPORT_DETAIL_COLORS = { flight: '#3b82f6', train: '#06b6d4', bus: '#059669', ferry: '#0d9488', bicycle: '#84cc16', taxi: '#ca8a04', car: '#6b7280', cruise: '#0ea5e9', transit: '#7c3aed', transport_other: '#6b7280' }
export const TRANSPORT_DETAIL_COLORS = { flight: '#3b82f6', train: '#06b6d4', bus: '#059669', ferry: '#0d9488', cable_car: '#dc2626', bicycle: '#84cc16', taxi: '#ca8a04', car: '#6b7280', cruise: '#0ea5e9', transit: '#7c3aed', transport_other: '#6b7280' }
@@ -850,9 +850,9 @@ describe('DayPlanSidebar', () => {
] },
trip: buildTrip({ id: 1, currency: 'NOK' }),
})} />)
// $25 + 250 NOK / 10 = $50, marked as approximate; footer and day header agree.
// $25 + 250 NOK / 10 = $50, marked as approximate. The day header carries no money.
await waitFor(() => expect(screen.getByText('≈ $50.00')).toBeInTheDocument())
expect(screen.getByText('≈ $50')).toBeInTheDocument()
expect(screen.queryByText('≈ $50')).toBeNull()
})
it('FE-PLANNER-DAYPLAN-037c: falls back to a per-currency breakdown when rates are unavailable (#1561)', async () => {
@@ -876,7 +876,7 @@ describe('DayPlanSidebar', () => {
expect(screen.queryByText(/5\s?230/)).toBeNull()
})
it('FE-PLANNER-DAYPLAN-037d: with Costs on, the day and the total follow the expenses, and drop one that is deleted (#2551)', async () => {
it('FE-PLANNER-DAYPLAN-037d: with Costs on, the total follows the expenses and drops one that is deleted (#2551)', async () => {
seedStore(useAddonStore, { addons: [{ id: 'budget', name: 'Costs', type: 'trip', icon: 'wallet', enabled: true }], loaded: true })
// The price on the place is not an expense: it stays out of both figures.
const place = buildPlace({ id: 1, name: 'Hanging Bridges', price: 999 })
@@ -890,8 +890,8 @@ describe('DayPlanSidebar', () => {
assignments: { '10': [buildAssignment({ id: 1, day_id: 10, order_index: 0, place })] },
trip: buildTrip({ id: 1, currency: 'EUR' }),
})} />)
// The ticket is on the day of its place; the insurance belongs to no day but to the trip.
expect(screen.getByText(/^60\s€$/)).toBeInTheDocument()
// Both count toward the trip; the day header itself shows no amount.
expect(screen.queryByText(/^60\s€$/)).toBeNull()
expect(screen.getByText(/^100,00\s€$/)).toBeInTheDocument()
expect(screen.queryByText(/999/)).toBeNull()
@@ -3925,7 +3925,7 @@ describe('DayPlanSidebar', () => {
expect(toggle).toHaveAccessibleName('Keep position during route optimization')
})
it('FE-PLANNER-DAYPLAN-167: the place context menu opens the website, maps and collection actions', async () => {
it('FE-PLANNER-DAYPLAN-167: the place context menu opens the website but offers no navigation apps', async () => {
const user = userEvent.setup()
const openSpy = vi.spyOn(window, 'open').mockReturnValue(null)
const place = buildPlace({ id: 1, name: 'Louvre', website: 'https://louvre.fr', google_place_id: 'abc' })
@@ -3936,10 +3936,9 @@ describe('DayPlanSidebar', () => {
await user.click(screen.getByRole('button', { name: 'Open Website' }))
expect(openSpy).toHaveBeenCalledWith('https://louvre.fr', '_blank', 'noopener,noreferrer')
// Navigation lives on the place's own Navigation button, not in this menu.
fireEvent.contextMenu(dragRow(screen.getByText('Louvre')))
await user.click(screen.getByRole('button', { name: 'Google Maps' }))
expect(openSpy).toHaveBeenCalledTimes(2)
expect(openSpy.mock.calls[1][0]).toContain('google.com/maps')
expect(screen.queryByRole('button', { name: 'Google Maps' })).not.toBeInTheDocument()
openSpy.mockRestore()
})
@@ -5055,4 +5054,14 @@ describe('the day route-tools row', () => {
/* The click itself is already pinned by FE-PLANNER-DAYPLAN-038 above, which
finds the button the same way. */
it('FE-PLANNER-DAYPLAN-235: with "date first" the day heading leads with its date', () => {
seedStore(useSettingsStore, { settings: { time_format: '24h', temperature_unit: 'celsius', day_date_first: true } } as any)
const day = buildDay({ id: 10, date: '2025-06-01', title: null })
render(<DayPlanSidebar {...makeDefaultProps({ days: [day], assignments: {} })} />)
const secondary = screen.getByText('Day 1')
expect(secondary.className).toContain('text-content-faint')
expect(secondary.previousElementSibling?.className).toContain('font-bold')
})
})
@@ -7,7 +7,7 @@ declare global { interface Window { __dragData: DragDataPayload | null } }
import React, { useState, useEffect, useLayoutEffect, useRef, useMemo, useCallback } from 'react'
import { avatarSrc } from '../../utils/avatarSrc'
import { safeHttpUrl } from '../../utils/safeUrl'
import { ChevronDown, ChevronRight, ChevronUp, Compass, Navigation, RotateCcw, ExternalLink, Pencil, GripVertical, Ticket, Plus, FileText, Trash2, Car, Lock, Hotel, Footprints, Route as RouteIcon, Bookmark, StickyNote, TramFront, Zap, MapPin, Wallet, Globe } from 'lucide-react'
import { ChevronDown, ChevronRight, ChevronUp, Compass, RotateCcw, ExternalLink, Pencil, GripVertical, Ticket, Plus, FileText, Trash2, Car, Lock, Hotel, Eraser, Route as RouteIcon, RouteOff, Bookmark, StickyNote, TramFront, Zap, MapPin, Globe } from 'lucide-react'
import { type PickedPlace } from './TransitSearchPanel'
import { buildTransitLeg, buildTransitNameIndex } from './transitLeg'
import { assignmentsApi, reservationsApi, daysApi } from '../../api/client'
@@ -26,10 +26,10 @@ import { useTripStore } from '../../store/tripStore'
import { useCanDo } from '../../store/permissionsStore'
import { useSettingsStore } from '../../store/settingsStore'
import { useAddonStore } from '../../store/addonStore'
import { usePluginStore } from '../../store/pluginStore'
import { useSaveToCollectionStore } from '../../store/saveToCollectionStore'
import { placeToSaveTarget } from '../Collections/saveTarget'
import { useTranslation } from '../../i18n'
import { routeModeIcon, useRouteModeOptions } from './routeModes'
import { Tooltip } from '../shared/Tooltip'
import { NEUTRAL_TINT, fs } from '../shared/DialogShell'
import { BarButton, MoreButton, SoftPill, TimePill, tintOf } from './planParts'
@@ -49,6 +49,8 @@ import {
} from '../../utils/dayMerge'
import { withinDriveRange } from '@trek/shared/roadtrip'
import { formatDate, formatTime, formatMoneySum, splitReservationDateTime } from '../../utils/formatters'
import { dayHeadingParts } from '../../utils/dayLabel'
import { pendingStayIds } from '../../utils/pendingStays'
import { planCosts } from './planCosts'
import { useDayNotes } from '../../hooks/useDayNotes'
import { useExchangeRates } from '../../hooks/useExchangeRates'
@@ -69,7 +71,6 @@ import { DayPlanSidebarFooter } from './DayPlanSidebarFooter'
import type { DayAddControls } from '../../utils/dayAdd'
import type { DayDeleteQuestion } from '../../utils/dayImpactLines'
import type { Trip, Day, Place, Category, Assignment, Accommodation, Reservation, AssignmentsMap, RouteResult, RouteSegment, DayNote } from '../../types'
import { getNavigationTargets, openNavigationTarget } from './placeNavigation'
interface DayPlanSidebarProps {
tripId: number
@@ -94,6 +95,8 @@ interface DayPlanSidebarProps {
onDeleteDay?: (dayId: number) => void
/** The open delete question, which the reorder dialog asks in place of its list. */
deleteDayQuestion?: DayDeleteQuestion | null
/** Asks to take every place off a day (#2470); the planner owns the question. */
onClearDay?: (dayId: number) => void
/** Renaming lives in the day-detail panel (#1065); the sidebar only forwards the prop. */
onUpdateDayTitle: (dayId: number, title: string) => void
/** The day route is computed by the planner page itself; kept for the existing call sites. */
@@ -651,6 +654,8 @@ function useDayPlanSidebar(props: DayPlanSidebarProps) {
// flight, not a drive — and surface it as a bogus connector distance (#1394).
let curHasPlace = false
for (const it of merged) {
// A stop out of the route (#2532) is passed by, like a note.
if (it.type === 'place' && it.data.route_excluded) continue
if (it.type === 'place' && it.data.place?.lat && it.data.place?.lng) {
// Mirror of the guard below: the open run may hold nothing but a far-away
// arrival endpoint, which is no more drivable in this direction (#2133).
@@ -709,7 +714,7 @@ function useDayPlanSidebar(props: DayPlanSidebarProps) {
// is one you flew out of or landed at rather than drove between (#2133).
const wayPts: { lat: number; lng: number; isPlace: boolean; time: string | null; carrierEdge?: CarrierEdge; leg_transport_mode?: string | null; incoming_leg_transport_mode?: string | null; id?: number }[] = []
for (const it of merged) {
if (it.type === 'place' && it.data.place?.lat && it.data.place?.lng) {
if (it.type === 'place' && it.data.place?.lat && it.data.place?.lng && !it.data.route_excluded) {
wayPts.push({ lat: it.data.place.lat, lng: it.data.place.lng, isPlace: true, time: it.data.place?.place_time ?? null, leg_transport_mode: it.data.leg_transport_mode ?? null, incoming_leg_transport_mode: it.data.incoming_leg_transport_mode ?? null, id: it.data.id })
} else if (it.type === 'transport') {
const { from, to } = getTransportRouteEndpoints(it.data, dayId)
@@ -1164,14 +1169,12 @@ function useDayPlanSidebar(props: DayPlanSidebarProps) {
window.__dragData = null
}
// The day pills and Total Cost read the expenses in Costs when it is on (#2551).
// Total Cost reads the expenses in Costs when it is on (#2551).
const budgetItems = useTripStore(s => s.budgetItems)
const costsEnabled = useAddonStore(s => s.isEnabled('budget'))
const { totalCostLabel, dayCostLabels } = useMemo(() => {
const totalCostLabel = useMemo(() => {
const costs = planCosts({ days, assignments, reservations, budgetItems, costsEnabled, tripCurrency: currency })
const labels = new Map<number, string | null>()
for (const [dayId, entries] of costs.byDay) labels.set(dayId, formatMoneySum(entries, costBase, locale, fxRates, { decimals: 0 }))
return { totalCostLabel: formatMoneySum(costs.total, costBase, locale, fxRates), dayCostLabels: labels }
return formatMoneySum(costs.total, costBase, locale, fxRates)
}, [days, assignments, reservations, budgetItems, costsEnabled, currency, costBase, locale, fxRates])
return {
@@ -1316,7 +1319,6 @@ function useDayPlanSidebar(props: DayPlanSidebarProps) {
handleOptimize,
handleDropOnDay,
totalCostLabel,
dayCostLabels,
expandedRouteDayIds,
setExpandedRouteDayIds,
}
@@ -1334,6 +1336,7 @@ const NARROW_PLAN_PX = 320
const DayPlanSidebar = React.memo(function DayPlanSidebar(props: DayPlanSidebarProps) {
const S = useDayPlanSidebar(props)
const onClearDay = props.onClearDay
// The plan sidebar is resizable. Below this the route button's own word is the
// first thing that stops fitting beside the two export buttons — the icon says
// the same thing, and the accessible name still spells it out.
@@ -1359,27 +1362,14 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar(props: DayPlanSidebarP
// Needed by the route-tools visibility gate in the render below (#1330); the hook
// keeps its own copy, so read it reactively here in the component scope too.
const optimizeFromAccommodation = useSettingsStore(s => s.settings.optimize_from_accommodation)
const dateFirst = useSettingsStore(s => s.settings.day_date_first === true)
const collectionsEnabled = useAddonStore(s => s.isEnabled('collections'))
// Route-profile picker entries: the two built-ins plus every profile an active
// routeProvider plugin declared (keyed 'plugin:<id>/<profile>', labeled by the
// plugin's manifest). The feed only lists granted providers.
const activePlugins = usePluginStore(s => s.plugins)
// Plugin time contributions in the day plan (dayScheduleProvider hook).
const daySchedule = usePluginDaySchedule(S.tripId)
// Per-day colours from the dayTintProvider hook — e.g. which leg of the trip a
// day belongs to. Empty unless a granted plugin provides them.
const dayTints = usePluginDayTints(S.tripId)
const tr = S.t
const routeProfileOptions = useMemo(() => {
const opts: Array<{ key: string; label: string }> = [
{ key: 'driving', label: tr('mobileTrip.profileDriving') },
{ key: 'walking', label: tr('mobileTrip.profileWalking') },
]
for (const p of activePlugins) {
for (const prof of p.routeProfiles ?? []) opts.push({ key: `plugin:${p.id}/${prof.id}`, label: prof.label })
}
return opts
}, [activePlugins, tr])
const routeProfileOptions = useRouteModeOptions()
const {
tripId,
trip,
@@ -1522,17 +1512,16 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar(props: DayPlanSidebarP
handleOptimize,
handleDropOnDay,
totalCostLabel,
dayCostLabels,
expandedRouteDayIds,
setExpandedRouteDayIds,
} = S
// Stays still waiting for their booking to be confirmed, for the day pills (#2281).
const pendingStays = useMemo(() => pendingStayIds(reservations), [reservations])
// Rows that can be dragged lead with a grip; the rest keep its room so every tile lines up.
const gripsShown = canEditDays && !dragDisabled
// ── Per-segment / per-day travel mode (#1281) ──────────────────────────────
// Icon per mode, matching the route picker (driving→Car, walking→Footprints,
// any plugin profile→Zap).
const modeIcon = (key: string) => (key === 'walking' ? Footprints : key.startsWith('plugin:') ? Zap : Car)
const modeIcon = routeModeIcon
// Set the mode of the leg LEAVING this stop. Optimistic (the connector + map
// recompute from the store), then persisted; null clears the override so the leg
@@ -1679,8 +1668,8 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar(props: DayPlanSidebarP
}
const isExpanded = expandedDays.has(day.id)
const da = getDayAssignments(day.id)
const cost = dayCostLabels.get(day.id) ?? null
const formattedDate = formatDate(day.date, locale)
const heading = dayHeadingParts(day.title || t('dayplan.dayN', { n: index + 1 }), formattedDate, dateFirst)
const loc = da.find(a => a.place?.lat && a.place?.lng)
// Route tools normally need 2+ stops, but a single located place is still
// routable when accommodation optimization can bookend it with a hotel
@@ -1798,6 +1787,14 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar(props: DayPlanSidebarP
onPlanTransit && { label: t('transit.title'), icon: TramFront, onClick: () => onPlanTransit(day.id) },
{ label: t('dayplan.addNote'), icon: FileText, onClick: () => openAddNote(day.id, undefined) },
].filter(Boolean)
// Right-click on the head: what can be added, and emptying the day (#2470).
const dayMenuItems = [
...addItems,
...(onClearDay && da.length > 0 ? [
{ divider: true },
{ label: t('dayplan.clearDay'), icon: Eraser, danger: true, onClick: () => onClearDay(day.id) },
] : []),
]
return (
// One card per day, in the language of the booking cards: a head band
@@ -1830,6 +1827,7 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar(props: DayPlanSidebarP
onDragOver={e => { e.preventDefault(); if (dragOverDayId !== day.id) setDragOverDayId(day.id) }}
onDragLeave={e => { if (!e.currentTarget.contains(e.relatedTarget as Node | null)) setDragOverDayId(null) }}
onDrop={e => handleDropOnDay(e, day.id)}
onContextMenu={canEditDays ? e => ctxMenu.open(e, dayMenuItems) : undefined}
style={{
display: 'flex', alignItems: 'flex-start', gap: 10,
padding: '10px 8px 10px 10px',
@@ -1878,15 +1876,15 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar(props: DayPlanSidebarP
<div className="min-w-0 flex-1 pt-px">
<div className="flex min-w-0 items-baseline gap-2">
<span className="min-w-0 truncate font-bold text-content" style={fs(13.5, 'body')}>
{day.title || t('dayplan.dayN', { n: index + 1 })}
{heading.primary}
</span>
{formattedDate && (
{heading.secondary && (
<span className="flex-none whitespace-nowrap text-content-faint" style={fs(11)}>
{formattedDate}
{heading.secondary}
</span>
)}
</div>
{(dayAccs.length > 0 || activeRentals.length > 0 || cost || dayTint?.label) && (
{(dayAccs.length > 0 || activeRentals.length > 0 || dayTint?.label) && (
<div className="mt-1.5 flex min-w-0 flex-wrap items-center gap-1">
{dayAccs.map(acc => {
const isCheckIn = acc.start_day_id === day.id
@@ -1896,11 +1894,15 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar(props: DayPlanSidebarP
// check-out red, a stay in between quiet.
const state = isCheckOut && !isCheckIn ? t('day.checkOut') : isCheckIn ? t('day.checkIn') : null
const iconTone = isCheckOut && !isCheckIn ? 'text-danger' : isCheckIn ? 'text-success' : 'text-content-faint'
// Not confirmed yet: a dashed ring says so at a glance, the tooltip in words (#2281).
const pending = pendingStays.has(acc.id)
const named = state ? `${state}: ${accName}` : accName
const tip = pending ? `${named} (${t('reservations.pending')})` : named
return (
<Tooltip key={acc.id} label={state ? `${state}: ${accName}` : accName}>
<button type="button" data-dp="day-pill"
<Tooltip key={acc.id} label={tip}>
<button type="button" data-dp="day-pill" data-pending={pending || undefined}
onClick={e => { e.stopPropagation(); if ((acc as any).place_id) onPlaceClick((acc as any).place_id) }}
className="inline-flex min-w-0 max-w-full items-center gap-1 rounded-full bg-surface-card px-2 py-[2px] font-geist font-normal text-content-secondary shadow-sm hover:text-content"
className={`inline-flex min-w-0 max-w-full items-center gap-1 rounded-full px-2 py-[2px] font-geist font-normal text-content-secondary hover:text-content ${pending ? 'border border-dashed border-warning bg-warning-soft' : 'bg-surface-card shadow-sm'}`}
style={{ ...fs(10.5), cursor: (acc as any).place_id ? 'pointer' : 'default' }}>
<Hotel size={11} strokeWidth={2} className={`flex-none ${iconTone}`} />
<span className="truncate">{accName}</span>
@@ -1917,7 +1919,6 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar(props: DayPlanSidebarP
<span className="truncate">{r.title}</span>
</button>
))}
{cost && <SoftPill tone="success" icon={<Wallet size={10} strokeWidth={2.2} />}>{cost}</SoftPill>}
{/* A plugin's name for this day (which leg of the trip it belongs to). */}
{dayTint?.label && <SoftPill>{dayTint.label}</SoftPill>}
</div>
@@ -2110,13 +2111,15 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar(props: DayPlanSidebarP
const isLocked = lockedIds.has(assignment.id)
// The actions a right-click offers; the row's "…" offers the same, so a
// mouse that never right-clicks and the keyboard reach them too.
// Flat entries, one per navigation app: a submenu would need a
// component that does not exist here.
const placeMenu = () => [
canEditPlaces && onEditPlace && { label: t('common.edit'), icon: Pencil, onClick: () => onEditPlace(place, assignment.id) },
canEditDays && onRemoveAssignment && { label: t('planner.removeFromDay'), icon: Trash2, onClick: () => onRemoveAssignment(day.id, assignment.id) },
canEditDays && place.lat != null && place.lng != null && {
label: assignment.route_excluded ? t('dayplan.includeInRoute') : t('dayplan.excludeFromRoute'),
icon: assignment.route_excluded ? RouteIcon : RouteOff,
onClick: () => { tripActions.setAssignmentRouteExcluded(tripId, day.id, assignment.id, !assignment.route_excluded).catch((err: unknown) => toast.error(err instanceof Error ? err.message : t('common.unknownError'))) },
},
safeHttpUrl(place.website) && { label: t('inspector.website'), icon: ExternalLink, onClick: () => window.open(safeHttpUrl(place.website)!, '_blank', 'noopener,noreferrer') },
...getNavigationTargets(place).map(target => ({ label: target.label, icon: Navigation, onClick: () => openNavigationTarget(target) })),
collectionsEnabled && { label: t('inspector.saveToCollection'), icon: Bookmark, onClick: () => useSaveToCollectionStore.getState().open(placeToSaveTarget(place)) },
{ divider: true },
canEditPlaces && onDeletePlace && { label: t('common.delete'), icon: Trash2, danger: true, onClick: () => onDeletePlace(place.id) },
@@ -2245,6 +2248,15 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar(props: DayPlanSidebarP
<span className="min-w-0 truncate font-semibold text-content" style={{ ...fs(13, 'body'), lineHeight: 1.25 }}>
{place.name}
</span>
{assignment.route_excluded && (
// Kept on the day but passed by (#2532): said once, beside the name.
<Tooltip label={t('dayplan.offRouteHint')}>
<span className="inline-flex flex-none items-center gap-1 rounded-full bg-surface-secondary px-1.5 py-[1px] font-geist font-semibold text-content-muted" style={fs(9.5)}>
<RouteOff size={10} strokeWidth={2.2} />
{t('dayplan.offRoute')}
</span>
</Tooltip>
)}
</div>
{(place.place_time || place.description || place.address || cat?.name) && (
<div className="mt-0.5 flex min-w-0 items-center gap-1.5">
@@ -2848,7 +2860,7 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar(props: DayPlanSidebarP
{/* The day's travel mode as one segmented control. */}
<div className={`inline-flex h-8 flex-none items-center gap-0.5 rounded-[10px] p-[3px] ${ROUTE_TOOL}`}>
{routeProfileOptions.map(p => {
const ModeIcon = p.key === 'driving' ? Car : p.key === 'walking' ? Footprints : Zap
const ModeIcon = routeModeIcon(p.key)
const active = (day.default_transport_mode ?? routeProfile) === p.key
return (
<Tooltip key={p.key} label={p.label} placement="top">
@@ -1,22 +1,14 @@
import { Car, Footprints, Hotel, Zap } from 'lucide-react'
import { Hotel } from 'lucide-react'
import type { RouteSegment } from '../../types'
import { fs } from '../shared/DialogShell'
// Walking gets the foot icon; a plugin route profile ('plugin:…') gets the bolt —
// its legs carry the profile-true durationText anyway, the icon just signals that
// the time came from a plugin router (e.g. EV routing with charge time folded in).
export function profileIcon(profile: string) {
if (profile === 'walking') return Footprints
if (profile.startsWith('plugin:')) return Zap
return Car
}
import { routeModeIcon } from './routeModes'
/** The leg's figures as one quiet pill: mode, time, distance and a router note, side by side. */
function LegPill({ seg, profile }: { seg: RouteSegment; profile: string }) {
// The leg's own mode (#1281) wins over the day-wide fallback for icon + text.
const effProfile = seg.mode ?? profile
const driving = effProfile !== 'walking'
const Icon = profileIcon(effProfile)
const Icon = routeModeIcon(effProfile)
return (
<span className="inline-flex flex-none items-center gap-1.5 whitespace-nowrap rounded-full bg-surface-secondary px-2 py-[2px] font-geist font-semibold tabular-nums text-content-muted" style={fs(10)}>
<Icon size={11} strokeWidth={2} className="flex-none" />
@@ -5,6 +5,8 @@ import { useTranslation } from '../../i18n'
import { useToast } from '../shared/Toast'
import { placesApi } from '../../api/client'
import { useTripStore } from '../../store/tripStore'
import { useAuthStore } from '../../store/authStore'
import ToggleSwitch from '../Settings/ToggleSwitch'
import {
DialogButton, DialogFooter, DialogHeader, DialogSection, DialogShell, DialogTile, FooterSpacer, NEUTRAL_TINT, fs,
} from '../shared/DialogShell'
@@ -57,6 +59,10 @@ export default function FileImportModal({ isOpen, onClose, tripId, pushUndo, ini
const [summary, setSummary] = useState<PlacesImportSummary | null>(null)
const [gpxOpts, setGpxOpts] = useState({ waypoints: true, routes: true, tracks: true })
const [kmlOpts, setKmlOpts] = useState({ points: true, paths: true })
// The Google pass a list import offers, for the points of a file too (#2536).
const canEnrichImport = useAuthStore(s => s.hasMapsKey)
const [enrich, setEnrich] = useState(false)
const enrichHintId = useId()
const validateFile = (f: File): string | null => {
const ext = f.name.toLowerCase().split('.').pop()
@@ -168,13 +174,13 @@ export default function FileImportModal({ isOpen, onClose, tripId, pushUndo, ini
try {
if (ext === 'gpx') {
importedGpx = true
const result = await placesApi.importGpx(tripId, f, gpxOpts)
const result = await placesApi.importGpx(tripId, f, { ...gpxOpts, enrich: enrich && canEnrichImport })
totalCreated += result.count ?? 0
totalSkipped += result.skipped ?? 0
if (result.places?.length > 0) createdIds.push(...result.places.map((p: { id: number }) => p.id))
} else {
importedKml = true
const result = await placesApi.importMapFile(tripId, f, kmlOpts)
const result = await placesApi.importMapFile(tripId, f, { ...kmlOpts, enrich: enrich && canEnrichImport })
totalCreated += result.count ?? 0
if (result.places?.length > 0) createdIds.push(...result.places.map((p: { id: number }) => p.id))
const s = result.summary as PlacesImportSummary | undefined
@@ -327,6 +333,16 @@ export default function FileImportModal({ isOpen, onClose, tripId, pushUndo, ini
</DialogSection>
)}
{canEnrichImport && ((isGpx && gpxOpts.waypoints) || (isKml && kmlOpts.points)) && (
<div className="flex items-start gap-3 rounded-[14px] border border-edge-faint bg-surface-secondary p-3">
<div className="min-w-0 flex-1">
<div className="font-semibold text-content" style={fs(12.5, 'body')}>{t('places.enrichOnImport')}</div>
<div id={enrichHintId} className="mt-0.5 text-content-faint" style={fs(12, 'body')}>{t('places.enrichOnImportFileHint')}</div>
</div>
<ToggleSwitch on={enrich} onToggle={() => setEnrich(v => !v)} label={t('places.enrichOnImport')} describedBy={enrichHintId} />
</div>
)}
{summary && (
<div className="flex flex-col gap-2 rounded-[14px] border border-edge-faint bg-surface-secondary px-3 py-2.5">
<div className="text-content-muted" style={fs(12, 'body')}>
@@ -3,6 +3,7 @@ import { MapPin, X } from 'lucide-react'
import { mapsApi } from '../../api/client'
import { useTranslation } from '../../i18n'
import { useLocationBias } from '../../hooks/useLocationBias'
import { usePlaceLanguage } from '../../hooks/usePlaceLanguage'
export interface LocationPoint {
name: string
@@ -26,6 +27,7 @@ interface Props {
export default function LocationSelect({ value, onChange, placeholder, style, places }: Props) {
const { t, locale } = useTranslation()
const placeLang = usePlaceLanguage()
// Ohne Reisekontext ist der Hinweis leer, und die Suche laeuft wie bisher.
const { point: locationBias } = useLocationBias()
const [query, setQuery] = useState(value?.name || '')
@@ -58,7 +60,7 @@ export default function LocationSelect({ value, onChange, placeholder, style, pl
debounceRef.current = setTimeout(async () => {
setLoading(true)
try {
const data = await mapsApi.search(trimmed, locale, locationBias)
const data = await mapsApi.search(trimmed, placeLang, locationBias)
setResults(data.places || [])
setHighlight(-1)
} catch {
@@ -3,6 +3,7 @@ import {
DEFAULT_FORM, endsBeforeStart, findDuplicatePlace, isAmapUrl, isGoogleMapsUrl, isMapUrl, parseCoordinatePair,
timeCollisions,
} from './PlaceFormModal.helpers'
import { formPin } from './PlaceFormModal.helpers'
describe('isGoogleMapsUrl', () => {
it('accepts the short share hosts', () => {
@@ -146,3 +147,14 @@ describe('timeCollisions', () => {
expect(timeCollisions(99, day, '12:00', '13:00')).toEqual([])
})
})
describe('formPin', () => {
it('FE-PLACEFORM-PIN-001: reads a pin only from two real coordinates', () => {
expect(formPin({ lat: '52.5163', lng: '13.3777' })).toEqual({ lat: 52.5163, lng: 13.3777 })
expect(formPin({ lat: '0', lng: '0' })).toEqual({ lat: 0, lng: 0 })
expect(formPin({ lat: '', lng: '13.4' })).toBeNull()
expect(formPin({ lat: '52.5', lng: ' ' })).toBeNull()
expect(formPin({ lat: 'abc', lng: '13.4' })).toBeNull()
expect(formPin({ lat: '91', lng: '13.4' })).toBeNull()
})
})
@@ -233,3 +233,15 @@ export function timeCollisions(
return s1 < (e2 || '23:59') && s2 < (e1 || '23:59') && s1 !== e2 && s2 !== e1
})
}
/**
* The pin the form holds, when both fields are real coordinates: what "places
* near here" asks about (#976). Empty or half-typed fields are no pin.
*/
export function formPin(form: Pick<PlaceFormData, 'lat' | 'lng'>): { lat: number; lng: number } | null {
if (!form.lat.trim() || !form.lng.trim()) return null
const lat = Number(form.lat)
const lng = Number(form.lng)
if (!Number.isFinite(lat) || !Number.isFinite(lng) || Math.abs(lat) > 90 || Math.abs(lng) > 180) return null
return { lat, lng }
}
@@ -310,6 +310,38 @@ describe('PlaceFormModal', () => {
expect(await screen.findByText('Eiffel Tower')).toBeInTheDocument();
});
it('FE-PLANNER-PLACEFORM-018c: a pin lists the places around it, nearest first with their distance, and one pick fills the form', async () => {
const user = userEvent.setup();
seedStore(useAuthStore, { user: buildUser(), isAuthenticated: true, hasMapsKey: true });
const bodies: Record<string, unknown>[] = [];
server.use(
http.post('/api/maps/nearby', async ({ request }) => {
bodies.push((await request.json()) as Record<string, unknown>);
return HttpResponse.json({
places: [{ name: 'Café am Tor', address: 'Pariser Platz 1', lat: 52.5163, lng: 13.378, distance_m: 40 }],
source: 'trek-places',
});
}),
);
render(<PlaceFormModal {...defaultProps} prefillCoords={{ lat: 52.5163, lng: 13.3777 }} />);
await user.click(screen.getByRole('button', { name: 'Places near this pin' }));
expect(bodies).toEqual([{ lat: 52.5163, lng: 13.3777 }]);
expect(await screen.findByText('Café am Tor')).toBeInTheDocument();
expect(screen.getByText('40 m')).toBeInTheDocument();
// No typed query, so nothing to send to Google instead.
expect(screen.queryByText('Not the right place? Search Google instead')).not.toBeInTheDocument();
await user.click(screen.getByText('Café am Tor'));
expect(screen.getByDisplayValue('Pariser Platz 1')).toBeInTheDocument();
});
it('FE-PLANNER-PLACEFORM-018d: without a pin there is nothing to look around', () => {
render(<PlaceFormModal {...defaultProps} />);
expect(screen.queryByRole('button', { name: 'Places near this pin' })).not.toBeInTheDocument();
});
it('FE-PLANNER-PLACEFORM-018b: a list the index answered offers Google instead, and the link sends the same query there alone', async () => {
const user = userEvent.setup();
seedStore(useAuthStore, { user: buildUser(), isAuthenticated: true, hasMapsKey: true });
@@ -12,13 +12,16 @@ import CollectionPicker from '../Collections/CollectionPicker'
import PlaceDetailsColumn, { type PlaceDetailsSelection } from './PlaceDetailsColumn'
import { useToast } from '../shared/Toast'
import { Tooltip } from '../shared/Tooltip'
import { Search, Paperclip, X, AlertTriangle, Loader2, Plus, RotateCcw, MapPin } from 'lucide-react'
import { Search, Paperclip, X, AlertTriangle, Loader2, Plus, RotateCcw, MapPin, Navigation, LocateFixed } from 'lucide-react'
import { useTranslation } from '../../i18n'
import CustomTimePicker from '../shared/CustomTimePicker'
import {
DEFAULT_FORM, endsBeforeStart, findDuplicatePlace, isMapUrl, mergeResult, parseCoordinatePair, timeCollisions,
DEFAULT_FORM, endsBeforeStart, findDuplicatePlace, formPin, isMapUrl, mergeResult, parseCoordinatePair, timeCollisions,
type PlaceFormData, type ResultField,
} from './PlaceFormModal.helpers'
import { guessCategoryId } from './placeCategoryGuess'
import { getNavigationTargets, openNavigationTarget } from './placeNavigation'
import { NavigationMenu } from '../shared/NavigationMenu'
import { getApiErrorMessage } from '../../utils/apiError'
import { corePickRank, offersGoogleRetry, selectGoogleHoldsSlot } from '../../utils/placeSource'
import { safeHexColor } from '../../utils/safeColor'
@@ -28,6 +31,7 @@ import { BookingCostsSection } from './BookingCostsSection'
import type { BookingExpenseRequest } from './BookingCostsSection.types'
import type { Place, Category, Assignment, BudgetItem } from '../../types'
import { NumericInput } from '../shared/NumericInput'
import { formatDistance } from '../../utils/units'
import { PlacesSession } from '../../utils/placesSession'
import ServiceStopSection from '../Roadtrip/ServiceStopSection'
import { DEFAULT_SERVICE_KIND, serviceStopChoice, type ServiceStopMode } from '../Roadtrip/manualStop'
@@ -39,6 +43,7 @@ import {
import { EditorField, GRID_2, INPUT, LABEL, PANEL, PillSelect, TEXTAREA } from '../shared/dialogParts'
import { SoftPill, tintOf } from './planParts'
import { WHITE_BUTTON } from './placeDialogParts'
import { usePlaceLanguage } from '../../hooks/usePlaceLanguage'
// The submit payload mirrors the form, but lat/lng are parsed to numbers and
// category_id is normalised, plus any files chosen before the place existed.
@@ -60,7 +65,7 @@ interface PlaceFormModalProps {
onClose: () => void
onSave: (data: PlaceSubmitData, files?: File[]) => Promise<{ id: number } | void> | void
place: Place | null
prefillCoords?: { lat: number; lng: number; name?: string; address?: string; website?: string; phone?: string; osm_id?: string; stop_type?: RoadtripStopType | null; duration_minutes?: number } | null
prefillCoords?: { lat: number; lng: number; name?: string; address?: string; website?: string; phone?: string; osm_id?: string; stop_type?: RoadtripStopType | null; duration_minutes?: number; category?: string } | null
tripId: number
categories: Category[]
onCategoryCreated: (category: { name: string; color?: string; icon?: string }) => Promise<Category> | undefined
@@ -124,6 +129,9 @@ function usePlaceFormModal(props: PlaceFormModalProps) {
const [mapsResults, setMapsResults] = useState([])
/** What answered the last full search. Only a fallback: a merged list carries the source per place. */
const [searchSource, setSearchSource] = useState<string>('')
// The list on screen answers "what is near the pin" rather than a typed query (#976).
const [nearbyList, setNearbyList] = useState(false)
const distanceUnit = useSettingsStore(s => s.settings.distance_unit) || 'metric'
/**
* What produced the list currently on screen, kept for the shadow log: the
* query as typed and the provider the envelope named. A ref rather than
@@ -146,6 +154,14 @@ function usePlaceFormModal(props: PlaceFormModalProps) {
// to that place and goes when another is picked; anything outside it is the
// user's and survives. See mergeResult.
const autoFilledRef = useRef<Set<ResultField>>(new Set())
// Whether the category was preselected from a search result rather than chosen, so
// the next pick may replace it and a hand choice is never overwritten (#2282).
const categoryGuessedRef = useRef(false)
const guessedCategory = (result: Record<string, unknown>): string => {
const id = guessCategoryId(result, categories || [])
categoryGuessedRef.current = id != null
return id != null ? String(id) : ''
}
const [pendingFiles, setPendingFiles] = useState([])
/**
* The leg of the drive the traveller picked, or empty while the projection's own
@@ -170,7 +186,9 @@ function usePlaceFormModal(props: PlaceFormModalProps) {
// billing session (see utils/placesSession).
const placesSessionRef = useRef(new PlacesSession())
const toast = useToast()
const { t, language, locale } = useTranslation()
const { t, locale } = useTranslation()
// Place names in the language the user picked for them, the app's otherwise (#1799).
const language = usePlaceLanguage()
const { placesEnrichEnabled } = useAuthStore()
const googleAnswers = useAuthStore(selectGoogleHoldsSlot)
const can = useCanDo()
@@ -185,6 +203,7 @@ function usePlaceFormModal(props: PlaceFormModalProps) {
const expenseIntentRef = useRef<{ editItem?: BudgetItem; create?: boolean } | null>(null)
useEffect(() => {
categoryGuessedRef.current = false
if (place) {
// Times are stored per day-assignment, not on the pool place. When an
// assignment is in context (itinerary edit, or a single-assignment pool
@@ -222,6 +241,7 @@ function usePlaceFormModal(props: PlaceFormModalProps) {
website: prefillCoords.website || '',
phone: prefillCoords.phone || '',
osm_id: prefillCoords.osm_id,
category_id: guessedCategory(prefillCoords),
stop_type: prefillCoords.stop_type ?? null,
duration_minutes: prefillCoords.duration_minutes,
})
@@ -387,6 +407,7 @@ function usePlaceFormModal(props: PlaceFormModalProps) {
const handleChange = (field: string, value: string) => {
// Typed by hand, so the next pick must not clear it.
autoFilledRef.current.delete(field as ResultField)
if (field === 'category_id') categoryGuessedRef.current = false
setForm(prev => ({ ...prev, [field]: value }))
}
@@ -423,6 +444,7 @@ function usePlaceFormModal(props: PlaceFormModalProps) {
const result = await mapsApi.search(trimmed, language, locationBiasPoint, provider)
if (epoch !== searchEpochRef.current) return
searchMetaRef.current = { query: trimmed, source: result.source || 'unknown' }
setNearbyList(false)
setMapsResults(result.places || [])
setSearchSource(result.source || '')
} catch (err: unknown) {
@@ -433,6 +455,30 @@ function usePlaceFormModal(props: PlaceFormModalProps) {
}
}
// What is around the pin the form holds (#976): the list a search would show,
// nearest first. Not logged as a search pick, because no query was typed.
const pin = formPin(form)
const handleNearby = async () => {
if (!pin) return
const epoch = searchEpochRef.current
setIsSearchingMaps(true)
setAcSuggestions([])
try {
const result = await mapsApi.nearby(pin.lat, pin.lng, language)
if (epoch !== searchEpochRef.current) return
searchMetaRef.current = null
setNearbyList(true)
setMapsResults(result.places || [])
setSearchSource(result.source || '')
if (!result.places?.length) toast.info(t('places.nearbyNone'))
} catch (err: unknown) {
if (epoch !== searchEpochRef.current) return
toast.error(getApiErrorMessage(err, t('places.mapsSearchError')))
} finally {
if (epoch === searchEpochRef.current) setIsSearchingMaps(false)
}
}
/**
* `pick` is present only when the click came from a ranked list. The
* collection picker and the autocomplete detour reach this function with a
@@ -440,7 +486,11 @@ function usePlaceFormModal(props: PlaceFormModalProps) {
* worse than no row at all.
*/
const handleSelectMapsResult = (result, pick?: { mode: 'search' | 'autocomplete'; rank: number; count: number }) => {
setForm(prev => mergeResult(prev, result, autoFilledRef.current))
setForm(prev => {
const merged = mergeResult(prev, result, autoFilledRef.current)
if (prev.category_id && !categoryGuessedRef.current) return merged
return { ...merged, category_id: guessedCategory(result) }
})
// The same name the row's badge carried. A suggestion's pick overrides it
// with its own row's (handleSelectSuggestion); a saved place names none.
setPickedSource(sourceLabel(result, pick?.mode === 'search' ? searchSource : ''))
@@ -782,6 +832,10 @@ function usePlaceFormModal(props: PlaceFormModalProps) {
sourceLabel,
handleChange,
handleMapsSearch,
handleNearby,
pin,
nearbyList,
distanceUnit,
handleSelectMapsResult,
handleSelectSuggestion,
handleSearchKeyDown,
@@ -837,6 +891,7 @@ export default function PlaceFormModal(props: PlaceFormModalProps) {
mapsSearch, setMapsSearch, mapsResults, isSearchingMaps, acSuggestions, setAcSuggestions, acSource,
searchSource, acHighlight, setAcHighlight, t, language, googleAnswers, placesEnrichEnabled,
canUploadFiles, fetchSuggestions, sourceLabel, handleChange, handleMapsSearch, handleSelectMapsResult,
handleNearby, pin, nearbyList, distanceUnit,
handleSelectSuggestion, handleSearchKeyDown, hasTimeError, handleSubmit, isSaving, duplicateWarning,
detailsSelection, serviceStop, serviceStopDuplicate,
} = S
@@ -910,6 +965,10 @@ export default function PlaceFormModal(props: PlaceFormModalProps) {
}}
pickedSource={S.pickedSource}
busy={isSearchingMaps}
navPlace={form.lat && form.lng ? {
name: form.name, address: form.address, lat: Number(form.lat), lng: Number(form.lng),
google_place_id: form.google_place_id || null, google_ftid: form.google_ftid || null,
} : null}
t={t}
/>
)}
@@ -956,6 +1015,7 @@ export default function PlaceFormModal(props: PlaceFormModalProps) {
/>
)}
<form onSubmit={handleSubmit} className={FORM_COLUMN}>
{/* Place search: typed-ahead suggestions, a full search, and a pasted map link. */}
<div className={PANEL}>
<EditorField label={t('common.search')} htmlFor={`${fieldId}-search`}>
@@ -991,6 +1051,19 @@ export default function PlaceFormModal(props: PlaceFormModalProps) {
{isSearchingMaps ? '...' : <Search size={15} strokeWidth={2.2} aria-hidden="true" />}
</button>
</Tooltip>
{pin && (
<Tooltip label={t('places.nearby')}>
<button
type="button"
onClick={handleNearby}
disabled={isSearchingMaps}
aria-label={t('places.nearby')}
className={`${SIDE_BUTTON} border border-edge bg-surface-card text-content-secondary hover:bg-surface-hover`}
>
<LocateFixed size={15} strokeWidth={2.2} aria-hidden="true" />
</button>
</Tooltip>
)}
</div>
{/* Autocomplete dropdown. Capped and scrolling, because plugin rows can
@@ -1035,6 +1108,9 @@ export default function PlaceFormModal(props: PlaceFormModalProps) {
<span className="block truncate font-medium text-content" style={fs(13, 'body')}>{result.name}</span>
{result.address && <span className="block truncate text-content-muted" style={fs(11.5)}>{result.address}</span>}
</span>
{nearbyList && typeof result.distance_m === 'number' && (
<SoftPill>{formatDistance(result.distance_m / 1000, distanceUnit)}</SoftPill>
)}
<SourceBadge label={sourceLabel(result, searchSource)} />
</button>
))}
@@ -1045,7 +1121,7 @@ export default function PlaceFormModal(props: PlaceFormModalProps) {
quiet line under the list sends the same query there, on an instance
where Google holds the key slot and for a list Google did not
already produce. */}
{mapsResults.length > 0 && offersGoogleRetry(searchSource, googleAnswers) && (
{mapsResults.length > 0 && !nearbyList && offersGoogleRetry(searchSource, googleAnswers) && (
<button
type="button"
onClick={() => handleMapsSearch('google')}
@@ -1124,6 +1200,7 @@ export default function PlaceFormModal(props: PlaceFormModalProps) {
</div>
)}
{/* The day in context: its times and its own note. Both live on the
day-assignment, not on the pool place (#2163), so they are only shown
when a single assignment is in context (itinerary edit, or a
@@ -1238,7 +1315,7 @@ interface NewCategoryRow {
* place of both. Then where the picked place came from, and a spinner while a
* pick is still being looked up.
*/
function HeaderPills({ kind, categories, categoryId, onCategory, newCategory, pickedSource, busy, t }: {
function HeaderPills({ kind, categories, categoryId, onCategory, newCategory, pickedSource, busy, navPlace, t }: {
kind?: StopKind
categories: Category[] | null
categoryId: string
@@ -1246,6 +1323,8 @@ function HeaderPills({ kind, categories, categoryId, onCategory, newCategory, pi
newCategory: NewCategoryRow
pickedSource: string | null
busy: boolean
/** The place as the form holds it, once it has a position: opens it in a map app (#2178). */
navPlace: NavigablePlace | null
t: Translate
}) {
// When the new-category field closes, the field or the button that closed it
@@ -1299,6 +1378,7 @@ function HeaderPills({ kind, categories, categoryId, onCategory, newCategory, pi
{pickedSource}
</span>
)}
{navPlace && <OpenInMapsPill place={navPlace} t={t} />}
{busy && (
<span className={PILL} role="status" aria-label={t('places.loadingDetails')}>
<Loader2 size={13} className="animate-spin text-content-faint" aria-hidden="true" />
@@ -1309,6 +1389,33 @@ function HeaderPills({ kind, categories, categoryId, onCategory, newCategory, pi
)
}
type NavigablePlace = Pick<Place, 'name' | 'address' | 'lat' | 'lng' | 'google_place_id' | 'google_ftid'>
/**
* A look at the picked place in a real map before it is saved (#2178): its reviews, its
* photos, whether it is the right branch. The same apps the inspector offers, one tap
* straight away when only one applies.
*/
function OpenInMapsPill({ place, t }: { place: NavigablePlace; t: Translate }) {
const [open, setOpen] = useState(false)
const anchorRef = useRef<HTMLSpanElement>(null)
const targets = getNavigationTargets(place)
if (targets.length === 0) return null
return (
<span ref={anchorRef} className="inline-flex">
<Tooltip label={t('places.openInMaps')}>
<button type="button" aria-label={t('places.openInMaps')} aria-haspopup={targets.length > 1 ? 'menu' : undefined}
aria-expanded={targets.length > 1 ? open : undefined}
onClick={() => { if (targets.length === 1) openNavigationTarget(targets[0]); else setOpen(o => !o) }}
className={ROUND_PILL_BUTTON}>
<Navigation size={13} strokeWidth={2.2} />
</button>
</Tooltip>
{open && <NavigationMenu targets={targets} anchor={anchorRef.current} onClose={() => setOpen(false)} />}
</span>
)
}
/** The new category's name, typed where the category pill was, with its OK and Cancel beside it. */
function NewCategoryField({ inputId, name, setName, create, setOpen, t }: NewCategoryRow & { t: Translate }) {
return (
@@ -8,7 +8,7 @@ import remarkBreaks from 'remark-breaks'
import { markdownLinkComponents } from '../shared/markdownLink'
import { X, Clock, MapPin, ExternalLink, Phone, Banknote, Pencil, Plus, Minus, ChevronDown, ChevronUp, ChevronRight, FileText, Upload, File, FileImage, Star, Navigation, Mountain, Bookmark, BookmarkCheck, Copy } from 'lucide-react'
import PlaceAvatar from '../shared/PlaceAvatar'
import PlaceAvatarUpload from '../shared/PlaceAvatarUpload'
import PlaceAvatarUpload, { pickableImages } from '../shared/PlaceAvatarUpload'
import { BlurredCode } from '../shared/BookingCode'
import PlaceRating from '../shared/StarRating'
import TrackColorPicker from '../shared/TrackColorPicker'
@@ -33,7 +33,7 @@ import { splitReservationDateTime, formatTime, formatMoney } from '../../utils/f
import { useTripStore } from '../../store/tripStore'
import { useCanDo } from '../../store/permissionsStore'
import { formatDistance, formatElevation } from '../../utils/units'
import { getNavigationTargets, openNavigationTarget } from './placeNavigation'
import { navigationTargetLabel, getNavigationTargets, openNavigationTarget } from './placeNavigation'
import { TRANSPORT_TYPES, getAssignmentReservations } from '../../utils/dayMerge'
import { NavigationMenu } from '../shared/NavigationMenu'
import { resolveOpenNow, resolvePlaceTimeZone, placeWeekdayIndex, type OpeningPeriod } from './placeOpenState'
@@ -44,6 +44,7 @@ import { DialogButton, DialogSection, DeleteButton, FooterSpacer, NEUTRAL_TINT,
import { BOX, Field, TypeTile, toneOf, toneTint, useOpenFile } from './bookings/bookingParts'
import { parseMeta } from './bookings/bookingsModel'
import { SoftPill, TimePill, tintOf } from './planParts'
import { usePlaceLanguage } from '../../hooks/usePlaceLanguage'
const detailsCache = new Map()
@@ -189,6 +190,8 @@ interface PlaceInspectorProps {
onUpdatePlace?: (placeId: number, data: Partial<Place>) => void
/** Upload a custom thumbnail (#1136); enables the click-to-change avatar in trip mode. */
onUploadImage?: (placeId: number, file: File) => Promise<void>
/** Takes a picture already attached to the place as its image (#1242). */
onImageFromFile?: (placeId: number, fileId: number) => Promise<void>
/** Cast/clear the current user's star vote (#1435); enables the rating row. */
onRate?: (placeId: number, rating: number | null) => Promise<void> | void
leftWidth?: number
@@ -213,7 +216,7 @@ export default function PlaceInspector({
place, categories, mode = 'trip', days = [], selectedDayId = null, selectedAssignmentId = null,
assignments = {}, reservations = [], onEditTransport, onEditReservation, onOpenBooking,
onClose, onEdit: editPlace, onDelete: deletePlace, onAssignToDay, onRemoveAssignment,
files = [], onFileUpload, tripMembers = [], onSetParticipants, onUpdatePlace: updatePlace, onUploadImage, onRate,
files = [], onFileUpload, tripMembers = [], onSetParticipants, onUpdatePlace: updatePlace, onUploadImage, onImageFromFile, onRate,
leftWidth = 0, rightWidth = 0,
collectionStatus, onCopyToTrip, onSetStatus, onRemoveFromList, roadtripEndDay, roadtripStay, roadtripActive,
}: PlaceInspectorProps) {
@@ -245,6 +248,7 @@ export default function PlaceInspector({
return () => { cancelled = true }
}, [placeIdForDetails])
const { t, locale, language } = useTranslation()
const placeLang = usePlaceLanguage()
// Currency-less prices mean "the trip's currency"; null in collection mode (EUR fallback below).
const tripCurrency = useTripStore(s => s.trip?.currency)
// The day list handed in is the one the planner shows, and in the day view that
@@ -267,7 +271,7 @@ export default function PlaceInspector({
const [nameValue, setNameValue] = useState('')
const nameInputRef = useRef<HTMLInputElement>(null)
const fileInputRef = useRef<HTMLInputElement>(null)
const googleDetails = usePlaceDetails(place?.google_place_id, place?.osm_id, language)
const googleDetails = usePlaceDetails(place?.google_place_id, place?.osm_id, placeLang)
// Library-wide "is this place already saved anywhere I can see?" indicator for
// the trip-planner footer bookmark. Re-checks when the place changes or after
@@ -391,7 +395,7 @@ export default function PlaceInspector({
place ? { ...place, google_ftid: place.google_ftid || googleDetails?.google_ftid || null } : null,
googleDetails?.google_maps_url,
)
const navigationLabel = navigationTargets.length === 1 ? navigationTargets[0].label : t('inspector.navigation')
const navigationLabel = navigationTargets.length === 1 ? navigationTargetLabel(navigationTargets[0], t) : t('inspector.navigation')
const selectedDay = days?.find(d => d.id === selectedDayId)
const weekdayIndex = getWeekdayIndex(selectedDay?.date, placeTimeZone)
@@ -424,6 +428,8 @@ export default function PlaceInspector({
commitNameEdit={commitNameEdit} handleNameKeyDown={handleNameKeyDown} startNameEdit={startNameEdit}
onUpdatePlace={onUpdatePlace}
onUploadImage={mode === 'trip' && onUpdatePlace ? onUploadImage : undefined}
attachedImages={pickableImages(placeFiles)}
onImageFromFile={mode === 'trip' && onUpdatePlace ? onImageFromFile : undefined}
onClose={onClose} />
{/* The body scrolls inside the capped card; each block keeps its natural height (#1195). */}
@@ -626,6 +632,8 @@ interface InspectorHeadProps {
startNameEdit: () => void
onUpdatePlace?: (placeId: number, data: Partial<Place>) => void
onUploadImage?: (placeId: number, file: File) => Promise<void>
attachedImages: TripFile[]
onImageFromFile?: (placeId: number, fileId: number) => Promise<void>
onClose: () => void
}
@@ -634,7 +642,7 @@ interface InspectorHeadProps {
* name (a double-click renames it), the address, and the facts as pills.
*/
function InspectorHead({ place, category, openNow, phone, rating, ratingCount, price, time, editingName, nameInputRef,
nameValue, setNameValue, commitNameEdit, handleNameKeyDown, startNameEdit, onUpdatePlace, onUploadImage, onClose }: InspectorHeadProps) {
nameValue, setNameValue, commitNameEdit, handleNameKeyDown, startNameEdit, onUpdatePlace, onUploadImage, attachedImages, onImageFromFile, onClose }: InspectorHeadProps) {
const { t, locale } = useTranslation()
const ring = openNow === true ? 'bg-success' : openNow === false ? 'bg-danger' : 'bg-surface-card shadow-sm'
const hasCoords = !!(place.lat && place.lng)
@@ -647,7 +655,9 @@ function InspectorHead({ place, category, openNow, phone, rating, ratingCount, p
{onUploadImage
? <PlaceAvatarUpload place={place} category={category} size={52}
onUpload={(file: File) => onUploadImage(place.id, file)}
onRemove={() => onUpdatePlace(place.id, { image_url: null })} />
onRemove={() => onUpdatePlace(place.id, { image_url: null })}
attachedImages={attachedImages}
onPickAttached={onImageFromFile ? (fileId: number) => onImageFromFile(place.id, fileId) : undefined} />
: <PlaceAvatar place={place} category={category} size={52} />}
</div>
<PhotoCredit imageUrl={place.image_url} />
@@ -1,5 +1,5 @@
import { useEffect, useRef, useState, type MouseEvent as ReactMouseEvent, type ReactNode } from 'react'
import { Search, Plus, X, Upload, FileDown, ListPlus, Check, Star, CalendarPlus, CalendarDays, ChevronDown, Tag } from 'lucide-react'
import { Search, Plus, X, Upload, FileDown, ListPlus, Check, Star, CalendarPlus, CalendarDays, ChevronDown, Tag, Globe2 } from 'lucide-react'
import { Tooltip } from '../shared/Tooltip'
import { ContextMenu, useContextMenu } from '../shared/ContextMenu'
import { NEUTRAL_TINT, fs } from '../shared/DialogShell'
@@ -90,6 +90,7 @@ export function PlacesHeader(S: SidebarState) {
<ShowDropdown {...S} />
<CategoryFilter {...S} />
<RatingFilter {...S} />
<LocalityFilterDropdown {...S} />
</div>
<ActiveFilterChips {...S} />
</div>
@@ -325,6 +326,47 @@ function RatingFilter(S: SidebarState) {
)
}
/**
* Country, or one region in it, from each place's resolved position (#2537). Shown once
* the trip spans more than one country or region; a single one has nothing to narrow.
*/
function LocalityFilterDropdown(S: SidebarState) {
const { t, localities, localityFilter, setLocalityFilter } = S
const [open, setOpen] = useState(false)
const regionCount = localities.reduce((n, g) => n + g.regions.length, 0)
if (localities.length < 2 && regionCount < 2) return null
const pick = (country: string, region: string | null) => { setLocalityFilter({ country, region }); setOpen(false) }
const row = (key: string, label: string, count: number, on: boolean, onClick: () => void, indent: boolean) => (
<button type="button" key={key} onClick={onClick} aria-pressed={on}
className={`flex w-full items-center gap-2 rounded-[9px] py-1.5 pr-2 text-left transition-colors ${indent ? 'pl-5' : 'pl-2'} ${on ? 'bg-surface-tertiary' : 'hover:bg-surface-hover'}`}
style={fs(indent ? 12 : 12.5, 'body')}>
<span className={`min-w-0 flex-1 truncate ${indent ? 'text-content-secondary' : 'font-semibold text-content'}`}>{label}</span>
<span className="rounded-full bg-surface-secondary px-1.5 font-geist tabular-nums text-content-faint" style={fs(10.5)}>{count}</span>
<span className="grid w-3.5 flex-none place-items-center">{on && <Check size={13} strokeWidth={2.4} className="text-content-muted" />}</span>
</button>
)
const label = localityFilter ? (localityFilter.region ?? localityFilter.country) : t('places.allLocations')
return (
<FilterDropdown name={t('places.filterByLocation')} label={label} active={!!localityFilter} badge={1}
icon={<Globe2 size={14} strokeWidth={2} />} open={open} setOpen={setOpen}>
{localities.map(g => (
<div key={g.country} className="flex flex-col gap-px">
{row(`c:${g.country}`, g.country, g.count, localityFilter?.country === g.country && localityFilter.region == null, () => pick(g.country, null), false)}
{g.regions.map(r => row(`c:${g.country}:${r.region}`, r.region, r.count, localityFilter?.country === g.country && localityFilter.region === r.region, () => pick(g.country, r.region), true))}
</div>
))}
{localityFilter && (
<button type="button" onClick={() => { setLocalityFilter(null); setOpen(false) }}
className="mt-1 flex w-full items-center gap-1.5 rounded-[9px] border-t border-edge-faint px-1.5 pb-1 pt-2 text-left font-semibold text-content-muted hover:text-content"
style={fs(12, 'body')}>
<X size={12} strokeWidth={2.4} />
{t('places.clearFilter')}
</button>
)}
</FilterDropdown>
)
}
function AddRow(S: SidebarState) {
const { onAddPlace, onAddPlaceToSelectedDay, selectedDayId, t, setFileImportOpen, setListImportOpen, hasMultipleListImportProviders } = S
const dayOpen = selectedDayId != null
@@ -22,6 +22,8 @@ import BookingsTimeline from './bookings/BookingsTimeline'
import BookingDetailHost from './bookings/BookingDetailHost'
import { useReservationDetailPlugins } from './bookings/useReservationDetailPlugins'
import { useBookingActions } from './bookings/useBookingActions'
import { bookingsCsv, bookingsFileName } from './bookings/bookingsExport'
import { downloadBlob } from '../../utils/fileDownload'
interface ReservationsPanelProps {
tripId: number
@@ -94,6 +96,20 @@ export default function ReservationsPanel({
[reservations, v.types, v.status, v.travelers, v.query, labelOf],
)
const sorted = useMemo(() => sortReservations(filtered, days, v.sort.by, v.sort.dir, labelOf, v.transitApart), [filtered, days, v.sort.by, v.sort.dir, labelOf, v.transitApart])
// What is on screen, in its order, as a spreadsheet (#1360).
const exportCsv = () => {
const csv = bookingsCsv(sorted, {
type: labelOf,
status: s => (s === 'confirmed' ? t('reservations.confirmed') : s === 'pending' ? t('reservations.pending') : s),
headers: {
type: t('reservations.export.type'), title: t('reservations.export.title'), status: t('reservations.status'),
start: t('reservations.export.start'), end: t('reservations.export.end'), from: t('reservations.export.from'),
to: t('reservations.export.to'), location: t('reservations.export.location'),
confirmation: t('reservations.export.confirmation'), notes: t('reservations.export.notes'),
},
})
downloadBlob(new Blob([csv], { type: 'text/csv;charset=utf-8' }), bookingsFileName(trip?.title, kind))
}
const groups = useMemo(() => groupReservations(sorted, v.group, days, trip?.start_date, trip?.end_date, {
confirmed: t('reservations.confirmed'), pending: t('reservations.pending'), transit: t('transit.sectionTitle'),
before: t('reservations.group.before'), after: t('reservations.group.after'), undated: t('reservations.group.undated'),
@@ -298,6 +314,7 @@ export default function ReservationsPanel({
onTransitApart={v.view === 'cards' && v.group === 'status' && reservations.some(r => r.type === 'transit') ? v.toggleTransitApart : undefined}
viewIsDefault={v.viewIsDefault}
onResetView={v.resetView}
onExport={exportCsv}
/>
</div>
@@ -7,7 +7,7 @@ import { useAuthStore } from '../../store/authStore'
import { useSettingsStore } from '../../store/settingsStore'
import { buildUser, buildDay, buildPlace } from '../../../tests/helpers/factories'
import type { Accommodation, Day } from '../../types'
import TransitSearchPanel from './TransitSearchPanel'
import TransitSearchPanel, { buildQuickPicks, dayBookingStops } from './TransitSearchPanel'
const { transitApiMock, toastErrors } = vi.hoisted(() => ({
transitApiMock: { geocode: vi.fn(), plan: vi.fn() },
@@ -663,3 +663,39 @@ describe('TransitSearchPanel', () => {
expect(screen.getByText('Aachen, Bushof')).toBeInTheDocument()
})
})
describe('quick picks from the bookings of the day (#1506)', () => {
const day = buildDay({ id: 2, date: '2026-07-08' }) as Day
const flight = {
id: 5, type: 'flight', day_id: 2, end_day_id: 2, status: 'confirmed', title: 'LH 190',
endpoints: [
{ role: 'from', name: 'FRA', lat: 50.03, lng: 8.56, local_date: '2026-07-08', local_time: '09:00', sequence: 0 },
{ role: 'to', name: 'BER Airport', lat: 52.36, lng: 13.5, local_date: '2026-07-08', local_time: '10:10', sequence: 1 },
],
}
const nextDayTrain = {
id: 6, type: 'train', day_id: 3, end_day_id: 3, status: 'confirmed', title: 'ICE',
endpoints: [{ role: 'from', name: 'Berlin Hbf', lat: 52.52, lng: 13.37, local_date: '2026-07-09', local_time: null, sequence: 0 }],
}
const undatedFerry = {
id: 7, type: 'ferry', day_id: 1, end_day_id: 2, status: 'confirmed', title: 'Ferry',
endpoints: [
{ role: 'from', name: 'Kiel', lat: 54.3, lng: 10.1, local_date: null, local_time: null, sequence: 0 },
{ role: 'to', name: 'Oslo', lat: 59.9, lng: 10.7, local_date: null, local_time: null, sequence: 1 },
],
}
const taxi = { id: 8, type: 'taxi', day_id: 2, status: 'confirmed', title: 'Taxi', endpoints: [{ role: 'from', name: 'Home', lat: 1, lng: 1, local_date: '2026-07-08', sequence: 0 }] }
it('FE-PLANNER-TRANSIT-031: the airports and stations of the day are offered, other days and non-carriers are not', () => {
const stops = dayBookingStops(day, [flight, nextDayTrain, undatedFerry, taxi] as any)
expect(stops.map(s => s.name)).toEqual(['FRA', 'BER Airport', 'Oslo'])
})
it('FE-PLANNER-TRANSIT-032: they come right after the stays and are never cut by the cap', () => {
const places = Array.from({ length: 12 }, (_, i) => buildPlace({ id: 100 + i, name: `P${i}`, lat: 52 + i / 100, lng: 13 }))
const picks = buildQuickPicks(day, [day], places as any, [], dayBookingStops(day, [flight] as any))
expect(picks.slice(0, 2).map(p => p.name)).toEqual(['FRA', 'BER Airport'])
expect(picks).toHaveLength(2 + 8)
})
})
@@ -1,4 +1,5 @@
import React, { useEffect, useMemo, useRef, useState } from 'react'
import { createPortal } from 'react-dom'
import tzlookup from 'tz-lookup'
import { ArrowLeftRight, ArrowRight, Bus, CableCar, ChevronDown, ChevronUp, Clock, Footprints, MapPin, Sailboat, Search, TramFront, TrainFront, TrainFrontTunnel } from 'lucide-react'
import CustomTimePicker from '../shared/CustomTimePicker'
@@ -8,7 +9,9 @@ import { useSettingsStore } from '../../store/settingsStore'
import { useToast } from '../shared/Toast'
import { useTranslation } from '../../i18n'
import { getDayBookendHotels } from '../../utils/dayOrder'
import type { Day, Place, Accommodation } from '../../types'
import type { Day, Place, Accommodation, Reservation } from '../../types'
import { useTripStore } from '../../store/tripStore'
import { isCarrierTransport } from '../../utils/dayMerge'
import type { TransitProvider } from '@trek/shared'
/**
@@ -110,13 +113,36 @@ function stayPick(a: Accommodation): PickedPlace | null {
return a.place_lat != null && a.place_lng != null && a.place_name ? { name: a.place_name, lat: a.place_lat, lng: a.place_lng } : null
}
/**
* The airports, stations and ports the day's flights, trains, buses and ferries leave
* from or arrive at (#1506): the way to the hotel after landing starts at one of them.
* An endpoint belongs to the day by its own local date, or, without one, by the leg's
* day: departures on the booking's first day, arrivals on its last.
*/
export function dayBookingStops(day: Pick<Day, 'id' | 'date'>, reservations: Reservation[]): PickedPlace[] {
const stops: PickedPlace[] = []
for (const r of reservations) {
if (!isCarrierTransport(r)) continue
const endDay = r.end_day_id ?? r.day_id
for (const ep of r.endpoints ?? []) {
if (ep.lat == null || ep.lng == null || !ep.name) continue
const onDay = ep.local_date
? !!day.date && ep.local_date.slice(0, 10) === day.date.slice(0, 10)
: (ep.role === 'from' && r.day_id === day.id) || (ep.role === 'to' && endDay === day.id)
if (onDay) stops.push({ name: ep.name, lat: ep.lat, lng: ep.lng })
}
}
return stops
}
/**
* What the from/to fields offer before anything is typed. The stay the day
* starts in and the one it ends in come first and are never cut, since most
* connections of a day begin or end there (#2538); then the day's own located
* connections of a day begin or end there (#2538); the day's airports and
* stations follow and are not cut either (#1506); then the day's own located
* places and the trip's other stays, up to MAX_QUICK_PICKS.
*/
function buildQuickPicks(day: Day, days: Day[], places: Place[], accommodations: Accommodation[]): PickedPlace[] {
export function buildQuickPicks(day: Day, days: Day[], places: Place[], accommodations: Accommodation[], bookingStops: PickedPlace[] = []): PickedPlace[] {
const { morning, evening } = getDayBookendHotels(day, days, accommodations)
const dayStays = [morning, evening].filter((a): a is Accommodation => a != null)
@@ -130,15 +156,55 @@ function buildQuickPicks(day: Day, days: Day[], places: Place[], accommodations:
}
const stays = dayStays.map(stayPick).filter(unique)
const stations = bookingStops.filter(unique)
const rest = [
...places.map(p => (p.lat != null && p.lng != null ? { name: p.name, lat: p.lat, lng: p.lng } : null)),
...accommodations.filter(a => !dayStays.includes(a)).map(stayPick),
].filter(unique)
return [...stays, ...rest.slice(0, MAX_QUICK_PICKS)]
return [...stays, ...stations, ...rest.slice(0, MAX_QUICK_PICKS)]
}
// ── from/to stop picker ──────────────────────────────────────────────────────
const LIST_GAP = 4
const LIST_MAX = 240
const VIEW_EDGE = 12
/**
* Where the suggestion list sits on screen: under the field, or above it when the
* window has no room below. Measured again on scroll and resize while it is open.
*/
function useFloatingList(anchorRef: React.RefObject<HTMLElement | null>, open: boolean) {
const [pos, setPos] = useState<{ left: number; width: number; top: number; bottom: number; above: boolean; maxHeight: number } | null>(null)
useEffect(() => {
if (!open) { setPos(null); return }
const place = () => {
const el = anchorRef.current
if (!el) return
const r = el.getBoundingClientRect()
const below = window.innerHeight - r.bottom - VIEW_EDGE
const aboveRoom = r.top - VIEW_EDGE
const above = below < 160 && aboveRoom > below
setPos({
left: r.left,
width: r.width,
top: r.bottom + LIST_GAP,
bottom: window.innerHeight - r.top + LIST_GAP,
above,
maxHeight: Math.max(120, Math.min(LIST_MAX, (above ? aboveRoom : below) - LIST_GAP)),
})
}
place()
window.addEventListener('resize', place)
window.addEventListener('scroll', place, true)
return () => {
window.removeEventListener('resize', place)
window.removeEventListener('scroll', place, true)
}
}, [open, anchorRef])
return pos
}
function StopPicker({ label, value, onPick, quickPicks, near, placeholder }: {
label: string
value: PickedPlace | null
@@ -153,9 +219,15 @@ function StopPicker({ label, value, onPick, quickPicks, near, placeholder }: {
const [open, setOpen] = useState(false)
const debounceRef = useRef<ReturnType<typeof setTimeout> | null>(null)
const rootRef = useRef<HTMLDivElement>(null)
const fieldRef = useRef<HTMLDivElement>(null)
const listRef = useRef<HTMLDivElement>(null)
const listPos = useFloatingList(fieldRef, open)
useEffect(() => {
const close = (e: MouseEvent) => { if (!rootRef.current?.contains(e.target as Node)) setOpen(false) }
const close = (e: MouseEvent) => {
const target = e.target as Node
if (!rootRef.current?.contains(target) && !listRef.current?.contains(target)) setOpen(false)
}
document.addEventListener('mousedown', close)
return () => document.removeEventListener('mousedown', close)
}, [])
@@ -179,7 +251,7 @@ function StopPicker({ label, value, onPick, quickPicks, near, placeholder }: {
return (
<div ref={rootRef} style={{ position: 'relative', flex: 1, minWidth: 0 }}>
<label className="block text-[11px] font-semibold text-content-faint mb-[5px] uppercase tracking-[0.03em]">{label}</label>
<div className="bg-surface-input border border-edge" style={{ display: 'flex', alignItems: 'center', gap: 7, borderRadius: 10, padding: '0 10px', height: 38 }}>
<div ref={fieldRef} className="bg-surface-input border border-edge" style={{ display: 'flex', alignItems: 'center', gap: 7, borderRadius: 10, padding: '0 10px', height: 38 }}>
<MapPin size={14} className="text-content-faint" style={{ flexShrink: 0 }} />
<input
value={display}
@@ -190,8 +262,10 @@ function StopPicker({ label, value, onPick, quickPicks, near, placeholder }: {
style={{ border: 0, background: 'none', outline: 'none', fontSize: 'calc(13px * var(--fs-scale-body, 1))', width: '100%', fontFamily: 'inherit' }}
/>
</div>
{open && (results.length > 0 || (!value && text.trim().length < 2 && quickPicks.length > 0)) && (
<div className="bg-surface-card border border-edge" style={{ position: 'absolute', top: '100%', left: 0, right: 0, marginTop: 4, borderRadius: 10, boxShadow: '0 8px 32px rgba(0,0,0,0.14)', zIndex: 30, overflow: 'hidden', maxHeight: 240, overflowY: 'auto' }}>
{open && listPos && (results.length > 0 || (!value && text.trim().length < 2 && quickPicks.length > 0)) && createPortal(
// On the page rather than inside the dialog, so a dialog that scrolls or clips
// its content cannot cut the list off before anything is typed.
<div ref={listRef} className="bg-surface-card border border-edge z-[var(--z-toast)]" style={{ position: 'fixed', left: listPos.left, width: listPos.width, ...(listPos.above ? { bottom: listPos.bottom } : { top: listPos.top }), borderRadius: 10, boxShadow: 'var(--shadow-dropdown)', overflow: 'hidden', maxHeight: listPos.maxHeight, overflowY: 'auto' }}>
{results.length > 0
? results.map((r, i) => (
<button type="button" key={i} onClick={() => { onPick({ name: r.name, lat: r.lat, lng: r.lng }); setText(''); setResults([]); setOpen(false) }}
@@ -202,7 +276,7 @@ function StopPicker({ label, value, onPick, quickPicks, near, placeholder }: {
<MapPin size={13} className="text-content-faint" style={{ flexShrink: 0 }} />
<span style={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
{r.name}
{r.area && <span className="text-content-faint"> · {r.area}</span>}
{r.area && <span className="ml-1.5 text-content-faint">{r.area}</span>}
</span>
</button>
))
@@ -216,7 +290,8 @@ function StopPicker({ label, value, onPick, quickPicks, near, placeholder }: {
<span style={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{p.name}</span>
</button>
))}
</div>
</div>,
document.body,
)}
</div>
)
@@ -402,7 +477,11 @@ export default function TransitSearchPanel({ day, days, places, accommodations =
const [addingIdx, setAddingIdx] = useState<number | null>(null)
const [provider, setProvider] = useState<TransitProvider | null>(null)
const quickPicks = useMemo(() => buildQuickPicks(day, days, places, accommodations), [day, days, places, accommodations])
const reservations = useTripStore(s => s.reservations)
const quickPicks = useMemo(
() => buildQuickPicks(day, days, places, accommodations, dayBookingStops(day, reservations)),
[day, days, places, accommodations, reservations],
)
const near = quickPicks.length > 0 ? `${quickPicks[0].lat},${quickPicks[0].lng}` : null
@@ -1,6 +1,6 @@
import { useState, useEffect, useRef, useMemo, type ReactNode } from 'react'
import { useParams } from 'react-router'
import { Plane, Train, Car, Ship, Bus, Sailboat, Bike, CarTaxiFront, Route, X, Trash2, ChevronUp, ChevronDown, CalendarDays, type LucideIcon } from 'lucide-react'
import { Plane, Train, Car, Ship, Bus, Sailboat, CableCar, Bike, CarTaxiFront, Route, X, Trash2, ChevronUp, ChevronDown, CalendarDays, type LucideIcon } from 'lucide-react'
import ConfirmDialog from '../shared/ConfirmDialog'
import CustomSelect from '../shared/CustomSelect'
import { BookingCodeInput } from '../shared/BookingCode'
@@ -31,7 +31,7 @@ import { fs, Eyebrow, type StatusTone } from './bookings/bookingParts'
import { typeInfo } from './bookings/bookingsModel'
import { typeToCostCategory } from '@trek/shared'
const TRANSPORT_TYPES = ['flight', 'train', 'bus', 'car', 'taxi', 'bicycle', 'cruise', 'ferry', 'transit', 'transport_other'] as const
const TRANSPORT_TYPES = ['flight', 'train', 'bus', 'car', 'taxi', 'bicycle', 'cruise', 'ferry', 'cable_car', 'transit', 'transport_other'] as const
type TransportType = typeof TRANSPORT_TYPES[number]
interface EndpointPick {
@@ -142,6 +142,7 @@ const TYPE_OPTIONS: { value: TransportType; labelKey: string; Icon: LucideIcon }
{ value: 'bicycle', labelKey: 'reservations.type.bicycle', Icon: Bike },
{ value: 'cruise', labelKey: 'reservations.type.cruise', Icon: Ship },
{ value: 'ferry', labelKey: 'reservations.type.ferry', Icon: Sailboat },
{ value: 'cable_car', labelKey: 'reservations.type.cable_car', Icon: CableCar },
{ value: 'transport_other', labelKey: 'reservations.type.transport_other', Icon: Route },
]
@@ -1,9 +1,12 @@
// FE-PLANNER-EXPORTMODAL-001 to FE-PLANNER-EXPORTMODAL-017
// FE-PLANNER-EXPORTMODAL-001 to FE-PLANNER-EXPORTMODAL-018
import { render, screen, waitFor } from '../../../tests/helpers/render'
import userEvent from '@testing-library/user-event'
import { downloadTripPDF } from '../PDF/TripPDF'
import { buildDay, buildDayNote, buildTrip } from '../../../tests/helpers/factories'
import { buildDay, buildDayNote, buildTrip, buildUser } from '../../../tests/helpers/factories'
import { TripExportModal } from './TripExportModal'
import { resetAllStores, seedStore } from '../../../tests/helpers/store'
import { useAuthStore } from '../../store/authStore'
import type { AssignmentsMap } from '../../types'
vi.mock('../PDF/TripPDF', () => ({ downloadTripPDF: vi.fn().mockResolvedValue(undefined) }))
@@ -274,4 +277,18 @@ describe('TripExportModal', () => {
await user.click(screen.getByText('dayplan.gpxAll'))
await waitFor(() => expect(toast.error).toHaveBeenCalledWith('dayplan.gpxFailed'))
})
it('FE-PLANNER-EXPORTMODAL-018: "My plan" appears once people are assigned, and prints only mine (#2168)', async () => {
const user = userEvent.setup()
resetAllStores()
seedStore(useAuthStore, { user: buildUser({ id: 7 }) })
const { rerender } = render(<TripExportModal {...makeProps()} />)
expect(screen.queryByText('dayplan.pdfMine')).toBeNull()
const assignments = { 10: [{ id: 1, participants: [{ user_id: 7, username: 'me' }] }] } as unknown as AssignmentsMap
rerender(<TripExportModal {...makeProps({ assignments })} />)
await user.click(screen.getByText('dayplan.pdfMine'))
await waitFor(() => expect(downloadTripPDF).toHaveBeenCalledTimes(1))
expect(vi.mocked(downloadTripPDF).mock.calls[0][0]).toMatchObject({ onlyUserId: 7 })
})
})
@@ -1,5 +1,5 @@
import { useId, useState } from 'react'
import { CalendarDays, CalendarPlus, ChevronRight, Download, FileText, Loader2, MapPin, Route as RouteIcon } from 'lucide-react'
import { CalendarDays, CalendarPlus, ChevronRight, Download, FileText, Loader2, MapPin, Route as RouteIcon, UserRound } from 'lucide-react'
import type { LucideIcon } from 'lucide-react'
import { DialogHeader, DialogSection, DialogShell, DialogTile, NEUTRAL_TINT, fs } from '../shared/DialogShell'
import { IcsSubscribeModal } from './IcsSubscribeModal'
@@ -8,6 +8,8 @@ import { useToast } from '../shared/Toast'
import type { Trip, Day, Place, Category, AssignmentsMap, Reservation, DayNote } from '../../types'
import { useSettingsStore } from '../../store/settingsStore'
import { useRoadtripSettings } from '../../hooks/useRoadtripSettings'
import { useAuthStore } from '../../store/authStore'
import { hasPersonalPlan } from '../PDF/pdfScope'
/**
* What a GPX download can carry. Worded by what someone wants on their device
@@ -81,9 +83,13 @@ export function TripExportModal({
setTimeout(() => { URL.revokeObjectURL(url); a.remove() }, 100)
}
const exportPdf = async () => {
const myId = useAuthStore(s => s.user?.id)
// "My plan" (#2168) only once somebody has been given a part of the trip.
const offerMine = myId != null && hasPersonalPlan(assignments, reservations)
const exportPdf = async (mine = false) => {
if (busy) return
setBusy('pdf')
setBusy(mine ? 'pdf:mine' : 'pdf')
const flatNotes = Object.entries(dayNotes).flatMap(([dayId, notes]) =>
notes.map(n => ({ ...n, day_id: Number(dayId) })),
)
@@ -93,7 +99,7 @@ export function TripExportModal({
// exported. A missing chunk lands in the catch and shows the same error
// the export already had.
const { downloadTripPDF } = await import('../PDF/TripPDF')
await downloadTripPDF({ trip, days, places, assignments, categories, dayNotes: flatNotes, reservations, t, locale, timeFormat, distanceUnit, showServiceStops })
await downloadTripPDF({ trip, days, places, assignments, categories, dayNotes: flatNotes, reservations, t, locale, timeFormat, distanceUnit, showServiceStops, onlyUserId: mine ? myId : undefined })
onClose()
} catch (e) {
console.error('PDF error:', e)
@@ -154,14 +160,26 @@ export function TripExportModal({
its own: one key press closes only the dialog in front. */}
<DialogShell open={isOpen} onClose={onClose} labelledBy={titleId} width="narrow" blocked={subscribeOpen} header={header}>
<DialogSection label={t('dayplan.exportDocument')}>
<ExportRow
icon={FileText}
title={t('dayplan.pdf')}
sub={t('dayplan.pdfTooltip')}
busy={busy === 'pdf'}
disabled={busy != null}
onClick={exportPdf}
/>
<div className="flex flex-col gap-1.5">
<ExportRow
icon={FileText}
title={t('dayplan.pdf')}
sub={t('dayplan.pdfTooltip')}
busy={busy === 'pdf'}
disabled={busy != null}
onClick={() => exportPdf()}
/>
{offerMine && (
<ExportRow
icon={UserRound}
title={t('dayplan.pdfMine')}
sub={t('dayplan.pdfMineSub')}
busy={busy === 'pdf:mine'}
disabled={busy != null}
onClick={() => exportPdf(true)}
/>
)}
</div>
</DialogSection>
<DialogSection label={t('dayplan.exportCalendar')}>
@@ -1,6 +1,6 @@
import { useCallback, useRef, useState, type ReactNode } from 'react'
import {
ArrowDownUp, CalendarDays, Check, Download, Filter, GanttChart, Layers, LayoutGrid, Plane, Plus, RotateCcw, Rows3,
ArrowDownUp, CalendarDays, Check, Download, FileSpreadsheet, Filter, GanttChart, Layers, LayoutGrid, Plane, Plus, RotateCcw, Rows3,
Search, SlidersHorizontal, Tag, X,
} from 'lucide-react'
import { useTranslation } from '../../../i18n'
@@ -53,6 +53,8 @@ export interface BookingsHeaderProps {
onTransitApart?: () => void
viewIsDefault: boolean
onResetView: () => void
/** Downloads what is shown as a CSV (#1360). */
onExport?: () => void
}
/** A 36 px control on the bar, in the card colour so it reads on the bar's tint. */
@@ -118,6 +120,11 @@ export default function BookingsHeader(p: BookingsHeaderProps) {
))}
</div>
<ViewOptions {...p} />
{p.onExport && (
<Tooltip label={t('reservations.exportCsv')}>
<button type="button" onClick={p.onExport} aria-label={t('reservations.exportCsv')} className={BAR_BTN}><FileSpreadsheet size={15} strokeWidth={2} /></button>
</Tooltip>
)}
</>
)}
@@ -0,0 +1,42 @@
// FE-BOOKINGS-EXPORT-001 to FE-BOOKINGS-EXPORT-003 (#1360)
import { describe, it, expect } from 'vitest'
import type { Reservation } from '../../../types'
import { bookingsCsv, bookingsFileName, csvCell } from './bookingsExport'
const labels = {
type: (t: string) => `T:${t}`,
status: (s: string) => `S:${s}`,
headers: { type: 'Type', title: 'Title', status: 'Status', start: 'Start', end: 'End', from: 'From', to: 'To', location: 'Location', confirmation: 'Confirmation', notes: 'Notes' },
}
describe('bookingsExport', () => {
it('FE-BOOKINGS-EXPORT-001: quotes what needs it and defuses formulas', () => {
expect(csvCell('plain')).toBe('plain')
expect(csvCell('a;b')).toBe('"a;b"')
expect(csvCell('say "hi"')).toBe('"say ""hi"""')
expect(csvCell('=SUM(A1)')).toBe("'=SUM(A1)")
expect(csvCell('-5')).toBe("'-5")
expect(csvCell(null)).toBe('')
})
it('FE-BOOKINGS-EXPORT-002: writes a header and one row per booking, route from its stops', () => {
const rows = [
{ id: 1, type: 'flight', title: 'LH 190', status: 'confirmed', reservation_time: '2026-07-07T09:15', reservation_end_time: '2026-07-07T10:25', location: null, confirmation_number: 'ABC123', notes: 'Window',
endpoints: [{ sequence: 1, code: 'BER', name: 'Berlin' }, { sequence: 0, code: 'FRA', name: 'Frankfurt' }] },
{ id: 2, type: 'hotel', title: 'Redo XXL', status: 'pending', reservation_time: null, reservation_end_time: null, location: 'Berlin', confirmation_number: null, notes: null },
] as unknown as Reservation[]
const csv = bookingsCsv(rows, labels)
expect(csv.startsWith('\uFEFF')).toBe(true)
const lines = csv.slice(1).trim().split('\r\n')
expect(lines[0]).toBe('Type;Title;Status;Start;End;From;To;Location;Confirmation;Notes')
expect(lines[1]).toBe('T:flight;LH 190;S:confirmed;2026-07-07T09:15;2026-07-07T10:25;FRA Frankfurt;BER Berlin;;ABC123;Window')
expect(lines[2]).toBe('T:hotel;Redo XXL;S:pending;;;;;Berlin;;')
})
it('FE-BOOKINGS-EXPORT-003: names the file after the trip', () => {
expect(bookingsFileName('Berlin 2026', 'transports')).toBe('berlin-2026-transports.csv')
expect(bookingsFileName('Zürich & Côte', 'bookings')).toBe('zurich-cote-bookings.csv')
expect(bookingsFileName(undefined, 'bookings')).toBe('trip-bookings.csv')
expect(bookingsFileName('!!!', 'bookings')).toBe('trip-bookings.csv')
})
})
@@ -0,0 +1,54 @@
import type { Reservation } from '../../../types'
interface ExportLabels {
type: (type: string) => string
status: (status: string) => string
headers: {
type: string; title: string; status: string; start: string; end: string
from: string; to: string; location: string; confirmation: string; notes: string
}
}
/**
* A spreadsheet cell: quoted when it holds a separator, a quote or a line break,
* and prefixed with an apostrophe when it would otherwise open as a formula, so a
* booking titled "=HYPERLINK(...)" stays text in Excel or Sheets.
*/
export function csvCell(value: string | null | undefined): string {
let text = value ?? ''
if (/^[=+\-@\t\r]/.test(text)) text = `'${text}`
return /[",;\n\r]/.test(text) ? `"${text.replace(/"/g, '""')}"` : text
}
/** Where a booking starts and ends, from its ordered stops when it has them (#1360). */
function fromTo(r: Reservation): [string, string] {
const stops = (r.endpoints || []).slice().sort((a, b) => (a.sequence ?? 0) - (b.sequence ?? 0))
if (stops.length < 2) return ['', '']
const name = (s: (typeof stops)[number]) => [s.code, s.name].filter(Boolean).join(' ')
return [name(stops[0]), name(stops[stops.length - 1])]
}
/**
* The bookings as a CSV for a spreadsheet (#1360), one row each in the order they
* are shown, with a header row. Times stay as TREK stores them (ISO, local to the
* booking), which every spreadsheet sorts correctly.
*/
export function bookingsCsv(reservations: Reservation[], labels: ExportLabels): string {
const h = labels.headers
const rows = [[h.type, h.title, h.status, h.start, h.end, h.from, h.to, h.location, h.confirmation, h.notes].map(csvCell).join(';')]
for (const r of reservations) {
const [from, to] = fromTo(r)
rows.push([
labels.type(r.type), r.title, labels.status(r.status), r.reservation_time, r.reservation_end_time,
from, to, r.location, r.confirmation_number, r.notes,
].map(v => csvCell(v)).join(';'))
}
// A byte order mark, so Excel reads the umlauts as UTF-8.
return `\uFEFF${rows.join('\r\n')}\r\n`
}
/** "berlin-2026-bookings.csv": the trip's name, made safe for a file name. */
export function bookingsFileName(tripTitle: string | undefined, section: string): string {
const slug = (tripTitle || 'trip').toLowerCase().normalize('NFKD').replace(/[\u0300-\u036f]/g, '').replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '')
return `${slug || 'trip'}-${section}.csv`
}
@@ -1,5 +1,5 @@
import {
Plane, Hotel, Utensils, Train, Car, Ship, Bus, Sailboat, Bike, CarTaxiFront, Route, Ticket, FileText,
Plane, Hotel, Utensils, Train, Car, Ship, Bus, Sailboat, CableCar, Bike, CarTaxiFront, Route, Ticket, FileText,
TramFront, Users, ParkingSquare, type LucideIcon,
} from 'lucide-react'
import type { AssignmentsMap, Day, Reservation } from '../../../types'
@@ -27,7 +27,7 @@ export interface ReservationTypeInfo {
// models so the two shells can never drift apart on what a flight looks like.
const TYPES: Record<string, { Icon: LucideIcon; chipKey?: string }> = {
flight: { Icon: Plane }, train: { Icon: Train }, bus: { Icon: Bus }, car: { Icon: Car },
taxi: { Icon: CarTaxiFront }, bicycle: { Icon: Bike }, cruise: { Icon: Ship }, ferry: { Icon: Sailboat },
taxi: { Icon: CarTaxiFront }, bicycle: { Icon: Bike }, cruise: { Icon: Ship }, ferry: { Icon: Sailboat }, cable_car: { Icon: CableCar },
transit: { Icon: TramFront, chipKey: 'reservations.typeShort.transit' }, transport_other: { Icon: Route },
hotel: { Icon: Hotel }, restaurant: { Icon: Utensils }, event: { Icon: Ticket }, tour: { Icon: Users },
parking: { Icon: ParkingSquare }, other: { Icon: FileText },
@@ -45,7 +45,7 @@ export function typeInfo(type: string): ReservationTypeInfo {
/** The order the type pills appear in, whichever types a trip uses. */
export const TYPE_ORDER = [
'flight', 'train', 'bus', 'car', 'taxi', 'bicycle', 'cruise', 'ferry', 'transit', 'transport_other',
'flight', 'train', 'bus', 'car', 'taxi', 'bicycle', 'cruise', 'ferry', 'cable_car', 'transit', 'transport_other',
'hotel', 'restaurant', 'event', 'tour', 'parking', 'other',
]
@@ -43,7 +43,7 @@ export function parsedItemToDraft(item: BookingImportPreviewItem): BookingReview
}
/** Transport types route to the TransportModal; everything else to the ReservationModal. */
const TRANSPORT_TYPES = new Set(['flight', 'train', 'bus', 'car', 'taxi', 'bicycle', 'cruise', 'ferry', 'transit', 'transport_other'])
const TRANSPORT_TYPES = new Set(['flight', 'train', 'bus', 'car', 'taxi', 'bicycle', 'cruise', 'ferry', 'cable_car', 'transit', 'transport_other'])
/** The types the booking form can express — its own chip list. */
const BOOKING_TYPES = new Set(['hotel', 'restaurant', 'event', 'tour', 'activity', 'parking', 'other'])
@@ -0,0 +1,53 @@
// FE-PLANNER-CATGUESS-001 to FE-PLANNER-CATGUESS-006
import { describe, it, expect } from 'vitest'
import { guessCategoryId, kindOfResult } from './placeCategoryGuess'
const seeded = [
{ id: 1, name: 'Hotel', icon: '🏨' },
{ id: 2, name: 'Restaurant', icon: '🍽️' },
{ id: 3, name: 'Attraction', icon: '🏛️' },
{ id: 4, name: 'Shopping', icon: '🛍️' },
{ id: 5, name: 'Transport', icon: '🚌' },
{ id: 6, name: 'Activity', icon: '🎯' },
{ id: 7, name: 'Bar/Cafe', icon: '☕' },
{ id: 8, name: 'Beach', icon: '🏖️' },
{ id: 9, name: 'Nature', icon: '🌿' },
{ id: 10, name: 'Other', icon: '📍' },
]
describe('guessCategoryId', () => {
it('FE-PLANNER-CATGUESS-001: an OSM hotel lands in the Hotel category', () => {
expect(guessCategoryId({ category: 'hotel' }, seeded)).toBe(1)
expect(guessCategoryId({ category: 'guest_house' }, seeded)).toBe(1)
})
it('FE-PLANNER-CATGUESS-002: Google types are read in order, so a restaurant bar is still a bar', () => {
expect(guessCategoryId({ types: ['restaurant', 'food', 'point_of_interest'] }, seeded)).toBe(2)
expect(guessCategoryId({ types: ['bar', 'restaurant'] }, seeded)).toBe(7)
expect(guessCategoryId({ types: ['museum', 'tourist_attraction'] }, seeded)).toBe(3)
})
it('FE-PLANNER-CATGUESS-003: a shop and a station find their categories', () => {
expect(guessCategoryId({ category: 'shop_bakery' }, seeded)).toBe(7)
expect(guessCategoryId({ category: 'shop_clothes' }, seeded)).toBe(4)
expect(guessCategoryId({ types: ['train_station', 'transit_station'] }, seeded)).toBe(5)
})
it('FE-PLANNER-CATGUESS-004: renamed categories are still found, by a local name or their icon', () => {
expect(guessCategoryId({ category: 'hotel' }, [{ id: 42, name: 'Unterkunft', icon: 'X' }])).toBe(42)
expect(guessCategoryId({ category: 'hotel' }, [{ id: 43, name: 'Schlafen', icon: 'BedDouble' }])).toBe(43)
})
it('FE-PLANNER-CATGUESS-005: nothing to go on, or nothing that fits, leaves the field empty', () => {
expect(guessCategoryId({}, seeded)).toBeNull()
expect(guessCategoryId({ types: ['point_of_interest'] }, seeded)).toBeNull()
expect(guessCategoryId({ category: 'hotel' }, [{ id: 1, name: 'Food', icon: '🍴' }])).toBeNull()
})
it('FE-PLANNER-CATGUESS-006: the kind is read from any source vocabulary', () => {
expect(kindOfResult({ category: 'italian_restaurant' })).toBe('food')
expect(kindOfResult({ category: 'beach' })).toBe('beach')
expect(kindOfResult({ types: ['park'] })).toBe('nature')
expect(kindOfResult({ category: 'swimming_pool' })).toBe('activity')
})
})
@@ -0,0 +1,72 @@
import type { Category } from '../../types'
type Kind = 'lodging' | 'food' | 'drinks' | 'sight' | 'shopping' | 'transport' | 'beach' | 'nature' | 'activity'
/**
* What a search result says it is, read from whichever vocabulary its source speaks:
* Google `types`, the OpenStreetMap tag value, or the TREK Places (Overture) category.
* Checked in order, first hit wins, so the specific kinds sit before the broad ones
* ("bar" before "restaurant" would misfile a "restaurant_bar" otherwise).
*/
const KIND_TERMS: [Kind, string[]][] = [
['lodging', ['hotel', 'hostel', 'motel', 'guest_house', 'lodging', 'resort', 'camp_site', 'campground', 'campsite', 'chalet', 'bed_and_breakfast', 'alpine_hut', 'apartment', 'caravan_site']],
['beach', ['beach']],
['drinks', ['cafe', 'coffee', 'bar', 'pub', 'biergarten', 'night_club', 'nightclub', 'bakery', 'ice_cream', 'wine_bar', 'brewery', 'tea_house']],
['food', ['restaurant', 'fast_food', 'food', 'meal_', 'bistro', 'diner', 'pizzeria', 'steakhouse']],
['transport', ['station', 'airport', 'aerodrome', 'ferry_terminal', 'bus_stop', 'terminal', 'car_rental', 'parking']],
['shopping', ['shop', 'store', 'mall', 'supermarket', 'market', 'department_store', 'boutique']],
['sight', ['museum', 'attraction', 'viewpoint', 'monument', 'memorial', 'castle', 'artwork', 'gallery', 'church', 'cathedral', 'place_of_worship', 'historic', 'ruins', 'landmark', 'zoo', 'aquarium', 'palace', 'tower']],
['nature', ['park', 'nature_reserve', 'national_park', 'peak', 'waterfall', 'forest', 'garden', 'natural_feature', 'lake', 'hiking']],
['activity', ['amusement_park', 'theme_park', 'stadium', 'sports', 'spa', 'gym', 'cinema', 'theatre', 'theater', 'bowling', 'swimming', 'golf', 'climbing', 'ski']],
]
/** How a trip's category of that kind is recognised: words in its name, then its icon. */
const KIND_CATEGORY: Record<Kind, { names: string[]; icons: string[] }> = {
lodging: { names: ['hotel', 'accommodation', 'lodging', 'unterkunft', 'hébergement', 'alojamiento', 'alloggio', 'stay', 'hostel', 'camping'], icons: ['🏨', '🛏️', '🏠', 'BedDouble', 'Home', 'Tent'] },
food: { names: ['restaurant', 'food', 'essen', 'ristorante', 'restaurante', 'eat'], icons: ['🍽️', '🍴', 'UtensilsCrossed', 'Utensils'] },
drinks: { names: ['cafe', 'café', 'bar', 'coffee', 'kaffee', 'pub', 'drinks'], icons: ['☕', '🍺', '🍸', 'Coffee', 'Beer', 'Wine'] },
sight: { names: ['attraction', 'sight', 'sehenswürdigkeit', 'museum', 'attraktion', 'culture', 'kultur'], icons: ['🏛️', '📸', 'Landmark', 'Library', 'Camera', 'Church'] },
shopping: { names: ['shopping', 'shop', 'einkauf', 'store', 'markt'], icons: ['🛍️', 'ShoppingBag', 'Store'] },
transport: { names: ['transport', 'verkehr', 'transit', 'station'], icons: ['🚌', '🚆', '✈️', 'Bus', 'Train', 'Plane'] },
beach: { names: ['beach', 'strand', 'plage', 'playa', 'spiaggia'], icons: ['🏖️', 'Waves'] },
nature: { names: ['nature', 'natur', 'park', 'outdoor', 'hiking', 'wandern'], icons: ['🌿', '🌲', '⛰️', 'TreePine', 'Leaf', 'Mountain'] },
activity: { names: ['activity', 'aktivität', 'activité', 'actividad', 'attività', 'sport'], icons: ['🎯', 'Activity', 'Dumbbell', 'Ticket'] },
}
/** The words a result carries about itself, lower-cased, whatever the source. */
function resultTerms(result: Record<string, unknown>): string[] {
const terms: string[] = []
if (Array.isArray(result.types)) for (const t of result.types) if (typeof t === 'string') terms.push(t.toLowerCase())
if (typeof result.category === 'string') terms.push(result.category.toLowerCase())
return terms
}
export function kindOfResult(result: Record<string, unknown>): Kind | null {
const terms = resultTerms(result)
if (terms.length === 0) return null
for (const [kind, words] of KIND_TERMS) {
if (terms.some(term => words.some(word => term.includes(word)))) return kind
}
return null
}
/**
* The trip category a picked search result most likely belongs in (#2282), or null.
*
* Categories are the admin's own, freely named and freely iconed, so there is no key to
* look up; they are recognised by their name, then by their icon. When nothing matches
* the answer is null and the field stays empty, because a wrong category that looks
* deliberate is worse than none.
*/
export function guessCategoryId(result: Record<string, unknown>, categories: Pick<Category, 'id' | 'name' | 'icon'>[]): number | null {
const kind = kindOfResult(result)
if (!kind) return null
const { names, icons } = KIND_CATEGORY[kind]
const byName = categories.find(c => {
const name = (c.name || '').toLowerCase()
return names.some(word => name.includes(word))
})
if (byName) return byName.id
const byIcon = categories.find(c => c.icon != null && icons.includes(c.icon))
return byIcon ? byIcon.id : null
}
@@ -0,0 +1,29 @@
// FE-PLANNER-LOCFILTER-001 to FE-PLANNER-LOCFILTER-002
import { describe, it, expect } from 'vitest'
import { localityGroups, matchesLocality } from './placeLocalityFilter'
describe('localityGroups', () => {
it('FE-PLANNER-LOCFILTER-001: groups by country and region, most places first, unknown countries left out', () => {
const groups = localityGroups([
{ country: 'Germany', region: 'Berlin' },
{ country: 'Germany', region: 'Berlin' },
{ country: 'Germany', region: 'Brandenburg' },
{ country: 'France', region: 'Île-de-France' },
{ country: null, region: null },
])
expect(groups).toEqual([
{ country: 'Germany', count: 3, regions: [{ region: 'Berlin', count: 2 }, { region: 'Brandenburg', count: 1 }] },
{ country: 'France', count: 1, regions: [{ region: 'Île-de-France', count: 1 }] },
])
})
})
describe('matchesLocality', () => {
it('FE-PLANNER-LOCFILTER-002: a country filter takes all its regions, a region filter only that one', () => {
const berlin = { country: 'Germany', region: 'Berlin' }
expect(matchesLocality(berlin, { country: 'Germany', region: null })).toBe(true)
expect(matchesLocality(berlin, { country: 'Germany', region: 'Brandenburg' })).toBe(false)
expect(matchesLocality(berlin, { country: 'France', region: null })).toBe(false)
expect(matchesLocality(undefined, { country: 'Germany', region: null })).toBe(false)
})
})
@@ -0,0 +1,42 @@
import type { PlaceLocality } from '../../utils/placeLocality'
/** A country on its own, or one region in it. */
export interface LocalityFilter {
country: string
region: string | null
}
export interface LocalityGroup {
country: string
count: number
regions: { region: string; count: number }[]
}
/**
* The trip's places by country and region, for the filter's list (#2537). Countries by
* how many places they hold, then by name; the same for the regions inside each. Places
* whose address names no country are left out, they have nothing to be filtered by.
*/
export function localityGroups(localities: PlaceLocality[]): LocalityGroup[] {
const byCountry = new Map<string, { count: number; regions: Map<string, number> }>()
for (const { country, region } of localities) {
if (!country) continue
const entry = byCountry.get(country) ?? { count: 0, regions: new Map<string, number>() }
entry.count++
if (region) entry.regions.set(region, (entry.regions.get(region) ?? 0) + 1)
byCountry.set(country, entry)
}
const byCountThenName = <T extends { count: number }>(a: T, b: T, na: string, nb: string) => b.count - a.count || na.localeCompare(nb)
return [...byCountry.entries()]
.map(([country, { count, regions }]) => ({
country,
count,
regions: [...regions.entries()].map(([region, n]) => ({ region, count: n })).sort((a, b) => byCountThenName(a, b, a.region, b.region)),
}))
.sort((a, b) => byCountThenName(a, b, a.country, b.country))
}
export function matchesLocality(locality: PlaceLocality | undefined, filter: LocalityFilter): boolean {
if (!locality || locality.country !== filter.country) return false
return filter.region == null || locality.region === filter.region
}
@@ -1,5 +1,5 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
import { getNavigationTargets, openNavigationTarget, showsAppleMaps } from './placeNavigation'
import { getNavigationTargets, navigationTargetLabel, openNavigationTarget, showsAppleMaps, showsGeoUri } from './placeNavigation'
import type { Place } from '../../types'
// FE-PLANNER-NAV-001 to FE-PLANNER-NAV-008
@@ -58,7 +58,7 @@ describe('getNavigationTargets', () => {
it('FE-PLANNER-NAV-004b: an Android phone does not, because nobody there wants it', () => {
const restore = withUserAgent('Mozilla/5.0 (Linux; Android 15; Pixel 9)')
try {
expect(getNavigationTargets(place()).map(t => t.id)).toEqual(['google', 'waze', 'osm', 'comaps'])
expect(getNavigationTargets(place()).map(t => t.id)).toEqual(['google', 'waze', 'osm', 'comaps', 'geo'])
} finally { restore() }
})
@@ -94,6 +94,25 @@ describe('getNavigationTargets', () => {
expect(targets.map(t => t.id)).not.toContain('comaps')
})
it('FE-PLANNER-NAV-104: Android gets a geo: link for any installed map app, labeled in the UI language (#1406)', () => {
const restore = withUserAgent('Mozilla/5.0 (Linux; Android 15; Pixel 9)')
try {
expect(showsGeoUri()).toBe(true)
const geo = getNavigationTargets(place()).find(t => t.id === 'geo')!
expect(geo.url).toBe('geo:48.2038,16.3616?q=48.2038,16.3616(Stephansdom)')
expect(navigationTargetLabel(geo, key => `t:${key}`)).toBe('t:inspector.otherMapApp')
} finally { restore() }
})
it('FE-PLANNER-NAV-105: no geo: entry where nothing would answer it', () => {
const restore = withUserAgent('Mozilla/5.0 (iPhone; CPU iPhone OS 18_0 like Mac OS X)')
try {
expect(getNavigationTargets(place()).map(t => t.id)).not.toContain('geo')
const google = getNavigationTargets(place())[0]
expect(navigationTargetLabel(google, key => `t:${key}`)).toBe('Google Maps')
} finally { restore() }
})
it('FE-PLANNER-NAV-008: no place at all yields nothing', () => {
expect(getNavigationTargets(null)).toEqual([])
expect(getNavigationTargets(place({ lat: null, lng: null, name: '' }))).toEqual([])
@@ -7,15 +7,34 @@ import { getOpenStreetMapUrlForPlace } from './placeOpenStreetMap'
type PlaceLike = Pick<Place | AssignmentPlace, 'name' | 'address' | 'lat' | 'lng' | 'google_place_id' | 'google_ftid'>
export type NavigationAppId = 'google' | 'waze' | 'apple' | 'osm' | 'comaps' | 'amap'
export type NavigationAppId = 'google' | 'waze' | 'apple' | 'osm' | 'comaps' | 'amap' | 'geo'
export interface NavigationTarget {
id: NavigationAppId
/** Product name. Not translated in any language, so it carries no i18n key. */
label: string
/** Set for the one entry that is no product (#1406): its label is this key's translation. */
labelKey?: string
url: string
}
type Translate = (key: string) => string
/** What a target is called on screen: its product name, or the translated name of the generic entry. */
export function navigationTargetLabel(target: NavigationTarget, t: Translate): string {
return target.labelKey ? t(target.labelKey) : target.label
}
/**
* Whether the generic `geo:` link is worth offering (#1406). Android hands it to
* whichever map app the traveller installed (OsmAnd, Organic Maps, Magic Earth…);
* desktop browsers and iOS have no handler, so there the entry would lead nowhere.
*/
export function showsGeoUri(): boolean {
if (typeof navigator === 'undefined') return false
return /Android/i.test(navigator.userAgent)
}
/**
* Whether Apple Maps is worth offering.
*
@@ -105,6 +124,16 @@ export function getNavigationTargets(
if (amapUrl) targets.push({ id: 'amap', label: '高德地图', url: amapUrl })
}
if (place.lat != null && place.lng != null && showsGeoUri()) {
const label = name ? `(${encodeURIComponent(name)})` : ''
targets.push({
id: 'geo',
label: 'geo:',
labelKey: 'inspector.otherMapApp',
url: `geo:${place.lat},${place.lng}?q=${place.lat},${place.lng}${label}`,
})
}
return targets
}
@@ -120,7 +149,8 @@ export function getNavigationTargets(
* shell was never replaced by the time the platform switched away.
*/
export function openNavigationTarget(target: NavigationTarget): void {
if (isInstalledApp()) window.location.href = target.url
// A geo: link is handed to the system, which asks for the app; a new tab would stay blank.
if (target.id === 'geo' || isInstalledApp()) window.location.href = target.url
else window.open(target.url, '_blank', 'noopener,noreferrer')
}
@@ -0,0 +1,38 @@
import { useMemo } from 'react'
import { Bike, Car, Footprints, Zap, type LucideIcon } from 'lucide-react'
import { useTranslation } from '../../i18n'
import { usePluginStore } from '../../store/pluginStore'
export interface RouteModeOption {
key: string
label: string
}
/** The icon a route profile key is drawn with, wherever a leg or a day names its mode. */
export function routeModeIcon(mode: string | null | undefined): LucideIcon {
if (mode === 'walking') return Footprints
if (mode === 'cycling') return Bike
if (mode?.startsWith('plugin:')) return Zap
return Car
}
/**
* Every mode a day or a single leg can be given: the three built-in profiles plus
* each profile a granted routeProvider plugin declared, keyed 'plugin:<id>/<profile>'
* and labeled by the plugin's manifest.
*/
export function useRouteModeOptions(): RouteModeOption[] {
const { t } = useTranslation()
const activePlugins = usePluginStore(s => s.plugins)
return useMemo(() => {
const opts: RouteModeOption[] = [
{ key: 'driving', label: t('mobileTrip.profileDriving') },
{ key: 'walking', label: t('mobileTrip.profileWalking') },
{ key: 'cycling', label: t('mobileTrip.profileCycling') },
]
for (const p of activePlugins) {
for (const prof of p.routeProfiles ?? []) opts.push({ key: `plugin:${p.id}/${prof.id}`, label: prof.label })
}
return opts
}, [activePlugins, t])
}
@@ -0,0 +1,24 @@
// FE-PLANNER-STAYTIMES-001 to FE-PLANNER-STAYTIMES-004
import { describe, it, expect } from 'vitest'
import { stayDayTimes } from './stayDayTimes'
const stay = { start_day_id: 1, end_day_id: 3, check_in: '15:00', check_out: '10:00' }
describe('stayDayTimes', () => {
it('FE-PLANNER-STAYTIMES-001: the arrival day keeps only the check-in', () => {
expect(stayDayTimes(stay, 1)).toEqual({ checkIn: true, checkOut: false })
})
it('FE-PLANNER-STAYTIMES-002: the departure day keeps only the check-out', () => {
expect(stayDayTimes(stay, 3)).toEqual({ checkIn: false, checkOut: true })
})
it('FE-PLANNER-STAYTIMES-003: a night in the middle shows neither time', () => {
expect(stayDayTimes(stay, 2)).toEqual({ checkIn: false, checkOut: false })
})
it('FE-PLANNER-STAYTIMES-004: a stay within one day shows both, and an unset time never shows', () => {
expect(stayDayTimes({ ...stay, end_day_id: 1 }, 1)).toEqual({ checkIn: true, checkOut: true })
expect(stayDayTimes({ ...stay, check_in: null }, 1)).toEqual({ checkIn: false, checkOut: false })
})
})
@@ -0,0 +1,25 @@
interface StayRange {
start_day_id: number | null
end_day_id: number | null
check_in?: string | null
check_out?: string | null
}
export interface StayDayTimes {
/** The check-in time belongs on this day: it is the arrival day and a time is set. */
checkIn: boolean
/** The check-out time belongs on this day: it is the departure day and a time is set. */
checkOut: boolean
}
/**
* Which of a stay's two times matter on a given day (#2393). The arrival day shows the
* check-in, the departure day the check-out, a one-night stop on a single day both; a
* night in the middle of the stay shows neither, since nothing happens at the desk then.
*/
export function stayDayTimes(stay: StayRange, dayId: number): StayDayTimes {
return {
checkIn: stay.start_day_id === dayId && !!stay.check_in,
checkOut: stay.end_day_id === dayId && !!stay.check_out,
}
}
@@ -1,4 +1,6 @@
import type React from 'react'
import { placeLocality } from '../../utils/placeLocality'
import { localityGroups, matchesLocality, type LocalityFilter } from './placeLocalityFilter'
import { useState, useMemo, useEffect, useLayoutEffect, useRef, useCallback } from 'react'
import { Pencil, Trash2, ExternalLink, Navigation, CalendarDays, Bookmark } from 'lucide-react'
import { useTranslation } from '../../i18n'
@@ -73,7 +75,7 @@ export function usePlacesSidebar(props: PlacesSidebarProps) {
tripId, places, assignments, selectedDayId, days, accommodations = NO_ACCOMMODATIONS,
pushUndo, initialScrollTop, onScrollTopChange, onEditPlace, onAssignToDay, onDeletePlace,
} = props
const { t } = useTranslation()
const { t, language } = useTranslation()
const toast = useToast()
const ctxMenu = useContextMenu()
const trip = useTripStore((s) => s.trip)
@@ -184,6 +186,11 @@ export function usePlacesSidebar(props: PlacesSidebarProps) {
// which put the best first but still left everything else on the list — no
// help at all when the point is to see only what the group actually rated.
const [ratingFilter, setRatingFilter] = useState<number | 'all'>('all')
// Country, or country and region, from each place's resolved position (#2537). List-only,
// like the rating floor.
const [localityFilter, setLocalityFilter] = useState<LocalityFilter | null>(null)
const localityOf = useMemo(() => new Map(places.map(p => [p.id, placeLocality(p, language)])), [places, language])
const localities = useMemo(() => localityGroups(places.map(p => localityOf.get(p.id)!)), [places, localityOf])
const [selectedIds, setSelectedIds] = useState<Set<number>>(new Set())
const [pendingDeleteIds, setPendingDeleteIds] = useState<number[] | null>(null)
const [categoryPickerOpen, setCategoryPickerOpen] = useState(false)
@@ -286,10 +293,11 @@ export function usePlacesSidebar(props: PlacesSidebarProps) {
if (search && !p.name.toLowerCase().includes(search.toLowerCase()) &&
!(p.address || '').toLowerCase().includes(search.toLowerCase())) return false
if (ratingFilter !== 'all' && (p.rating_avg == null || p.rating_avg < ratingFilter)) return false
if (localityFilter && !matchesLocality(localityOf.get(p.id), localityFilter)) return false
return true
})
return list
}, [places, filter, categoryFilters, search, plannedIds, plannedFilterIds, ratingFilter])
}, [places, filter, categoryFilters, search, plannedIds, plannedFilterIds, ratingFilter, localityFilter, localityOf])
/**
* How many places each "show" choice would leave, under the category and search
@@ -399,6 +407,7 @@ export function usePlacesSidebar(props: PlacesSidebarProps) {
search, setSearch, filter, setFilter, pickFilter, filterCounts,
categoryFilters, setCategoryFilters,
ratingFilter, setRatingFilter,
localityFilter, setLocalityFilter, localities,
selectMode, setSelectMode, selectedIds, setSelectedIds, pendingDeleteIds, setPendingDeleteIds,
categoryPickerOpen, setCategoryPickerOpen,
saveToListOpen, setSaveToListOpen, collectionsEnabled, tripId,
@@ -1,4 +1,4 @@
// FE-COMP-DISPLAY-001 to FE-COMP-DISPLAY-054
// FE-COMP-DISPLAY-001 to FE-COMP-DISPLAY-055
import { render, screen, within, fireEvent } from '../../../tests/helpers/render';
import userEvent from '@testing-library/user-event';
import { http, HttpResponse } from 'msw';
@@ -193,6 +193,21 @@ function optionBlock(label: RegExp): HTMLElement {
}
describe('DisplaySettingsTab – Display currency', () => {
it('FE-COMP-DISPLAY-055: place names follow the app until another language is picked for them (#1799)', async () => {
const user = userEvent.setup();
const updateSetting = vi.fn().mockResolvedValue(undefined);
seedStore(useSettingsStore, { settings: buildSettings({ language: 'de' }), updateSetting });
render(<DisplaySettingsTab />);
await user.click(screen.getByRole('button', { name: 'Same as the app' }));
// The language grid shows the same name, so the option is the last one on screen.
const options = await screen.findAllByText('English');
await user.click(options[options.length - 1]);
expect(updateSetting).toHaveBeenCalledWith('place_language', 'en');
expect(updateSetting).not.toHaveBeenCalledWith('language', expect.anything());
});
it('FE-COMP-DISPLAY-033: picking "Trip currency" clears the personal display currency', async () => {
const user = userEvent.setup();
const updateSetting = vi.fn().mockResolvedValue(undefined);
@@ -38,6 +38,11 @@ export default function DisplaySettingsTab(): React.ReactElement {
setDistanceUnit(settings.distance_unit || DEFAULT_SETTINGS.distance_unit)
}, [settings.distance_unit])
const saveOnOff = async (key: OnOffKey, value: boolean) => {
try { await updateSetting(key, value) }
catch (e: unknown) { toast.error(e instanceof Error ? e.message : t('common.error')) }
}
const startPage = settings.start_page === 'active_trip' ? 'active_trip' : DEFAULT_START_PAGE
const startTripTab = settings.start_trip_tab || DEFAULT_START_TRIP_TAB
@@ -195,6 +200,24 @@ export default function DisplaySettingsTab(): React.ReactElement {
</div>
</div>
{/* Place names (#1799): searches can answer in another language than the app. */}
<div>
<label className="block text-sm font-medium mb-2 text-content-secondary">{t('settings.placeLanguage')}</label>
<CustomSelect
value={settings.place_language || ''}
onChange={async v => {
try { await updateSetting('place_language', String(v)) }
catch (e: unknown) { toast.error(e instanceof Error ? e.message : t('common.error')) }
}}
options={[
{ value: '', label: t('settings.placeLanguageApp') },
...SUPPORTED_LANGUAGES.map(opt => ({ value: opt.value, label: opt.label })),
]}
searchable
/>
<p className="text-xs text-content-faint mt-2">{t('settings.placeLanguageHint')}</p>
</div>
{/* Temperature */}
<div>
<label className="block text-sm font-medium mb-2 text-content-secondary">{t('settings.temperature')}</label>
@@ -304,160 +327,62 @@ export default function DisplaySettingsTab(): React.ReactElement {
</Section>
<Section title={t('settings.general.travelMap')} icon={Map}>
{/* Date first in day headings (#1953) */}
<OnOffSetting label={t('settings.dayDateFirst')} hint={t('settings.dayDateFirstHint')} on={settings.day_date_first === true} onChange={value => saveOnOff('day_date_first', value)} />
{/* Booking route labels */}
<div>
<label className="block text-sm font-medium mb-2 text-content-secondary">{t('settings.bookingLabels')}</label>
<div className="flex gap-3">
{[
{ value: true, label: t('settings.on') || 'On' },
{ value: false, label: t('settings.off') || 'Off' },
].map(opt => (
<button type="button"
key={String(opt.value)}
onClick={async () => {
try { await updateSetting('map_booking_labels', opt.value) }
catch (e: unknown) { toast.error(e instanceof Error ? e.message : t('common.error')) }
}}
style={{
display: 'flex', alignItems: 'center', gap: 8,
padding: '10px 20px', borderRadius: 10, cursor: 'pointer',
fontFamily: 'inherit', fontSize: 'calc(14px * var(--fs-scale-body, 1))', fontWeight: 500,
border: (settings.map_booking_labels === true) === opt.value ? '2px solid var(--text-primary)' : '2px solid var(--border-primary)',
background: (settings.map_booking_labels === true) === opt.value ? 'var(--bg-hover)' : 'var(--bg-card)',
color: 'var(--text-primary)',
transition: 'all 0.15s',
}}
>
{opt.label}
</button>
))}
</div>
<p className="text-xs mt-1 text-content-faint">{t('settings.bookingLabelsHint')}</p>
</div>
<OnOffSetting label={t('settings.bookingLabels')} hint={t('settings.bookingLabelsHint')} on={settings.map_booking_labels === true} onChange={value => saveOnOff('map_booking_labels', value)} />
{/* Always show booking routes */}
<div>
<label className="block text-sm font-medium mb-2 text-content-secondary">{t('settings.alwaysShowRoutes')}</label>
<div className="flex gap-3">
{[
{ value: true, label: t('settings.on') || 'On' },
{ value: false, label: t('settings.off') || 'Off' },
].map(opt => (
<button type="button"
key={String(opt.value)}
onClick={async () => {
try { await updateSetting('map_always_show_routes', opt.value) }
catch (e: unknown) { toast.error(e instanceof Error ? e.message : t('common.error')) }
}}
style={{
display: 'flex', alignItems: 'center', gap: 8,
padding: '10px 20px', borderRadius: 10, cursor: 'pointer',
fontFamily: 'inherit', fontSize: 'calc(14px * var(--fs-scale-body, 1))', fontWeight: 500,
border: (settings.map_always_show_routes === true) === opt.value ? '2px solid var(--text-primary)' : '2px solid var(--border-primary)',
background: (settings.map_always_show_routes === true) === opt.value ? 'var(--bg-hover)' : 'var(--bg-card)',
color: 'var(--text-primary)',
transition: 'all 0.15s',
}}
>
{opt.label}
</button>
))}
</div>
<p className="text-xs mt-1 text-content-faint">{t('settings.alwaysShowRoutesHint')}</p>
</div>
<OnOffSetting label={t('settings.alwaysShowRoutes')} hint={t('settings.alwaysShowRoutesHint')} on={settings.map_always_show_routes === true} onChange={value => saveOnOff('map_always_show_routes', value)} />
{/* Unplanned places as small markers (#2024) */}
<OnOffSetting label={t('settings.compactUnplanned')} hint={t('settings.compactUnplannedHint')} on={settings.map_compact_unplanned === true} onChange={value => saveOnOff('map_compact_unplanned', value)} />
{/* Explore places on the map (POI category pill) */}
<div>
<label className="block text-sm font-medium mb-2 text-content-secondary">{t('settings.mapPoiPill')}</label>
<div className="flex gap-3">
{[
{ value: true, label: t('settings.on') || 'On' },
{ value: false, label: t('settings.off') || 'Off' },
].map(opt => (
<button type="button"
key={String(opt.value)}
onClick={async () => {
try { await updateSetting('map_poi_pill_enabled', opt.value) }
catch (e: unknown) { toast.error(e instanceof Error ? e.message : t('common.error')) }
}}
style={{
display: 'flex', alignItems: 'center', gap: 8,
padding: '10px 20px', borderRadius: 10, cursor: 'pointer',
fontFamily: 'inherit', fontSize: 'calc(14px * var(--fs-scale-body, 1))', fontWeight: 500,
border: (settings.map_poi_pill_enabled !== false) === opt.value ? '2px solid var(--text-primary)' : '2px solid var(--border-primary)',
background: (settings.map_poi_pill_enabled !== false) === opt.value ? 'var(--bg-hover)' : 'var(--bg-card)',
color: 'var(--text-primary)',
transition: 'all 0.15s',
}}
>
{opt.label}
</button>
))}
</div>
<p className="text-xs mt-1 text-content-faint">{t('settings.mapPoiPillHint')}</p>
</div>
<OnOffSetting label={t('settings.mapPoiPill')} hint={t('settings.mapPoiPillHint')} on={settings.map_poi_pill_enabled !== false} onChange={value => saveOnOff('map_poi_pill_enabled', value)} />
{/* Blur Booking Codes */}
<div>
<label className="block text-sm font-medium mb-2 text-content-secondary">{t('settings.blurBookingCodes')}</label>
<div className="flex gap-3">
{[
{ value: true, label: t('settings.on') || 'On' },
{ value: false, label: t('settings.off') || 'Off' },
].map(opt => (
<button type="button"
key={String(opt.value)}
onClick={async () => {
try { await updateSetting('blur_booking_codes', opt.value) }
catch (e: unknown) { toast.error(e instanceof Error ? e.message : t('common.error')) }
}}
style={{
display: 'flex', alignItems: 'center', gap: 8,
padding: '10px 20px', borderRadius: 10, cursor: 'pointer',
fontFamily: 'inherit', fontSize: 'calc(14px * var(--fs-scale-body, 1))', fontWeight: 500,
border: (!!settings.blur_booking_codes) === opt.value ? '2px solid var(--text-primary)' : '2px solid var(--border-primary)',
background: (!!settings.blur_booking_codes) === opt.value ? 'var(--bg-hover)' : 'var(--bg-card)',
color: 'var(--text-primary)',
transition: 'all 0.15s',
}}
>
{opt.label}
</button>
))}
</div>
</div>
<OnOffSetting label={t('settings.blurBookingCodes')} on={!!settings.blur_booking_codes} onChange={value => saveOnOff('blur_booking_codes', value)} />
{/* Optimize route from accommodation */}
<div>
<label className="block text-sm font-medium mb-2 text-content-secondary">{t('settings.optimizeFromAccommodation')}</label>
<div className="flex gap-3">
{[
{ value: true, label: t('settings.on') || 'On' },
{ value: false, label: t('settings.off') || 'Off' },
].map(opt => (
<button type="button"
key={String(opt.value)}
onClick={async () => {
try { await updateSetting('optimize_from_accommodation', opt.value) }
catch (e: unknown) { toast.error(e instanceof Error ? e.message : t('common.error')) }
}}
style={{
display: 'flex', alignItems: 'center', gap: 8,
padding: '10px 20px', borderRadius: 10, cursor: 'pointer',
fontFamily: 'inherit', fontSize: 'calc(14px * var(--fs-scale-body, 1))', fontWeight: 500,
border: (settings.optimize_from_accommodation !== false) === opt.value ? '2px solid var(--text-primary)' : '2px solid var(--border-primary)',
background: (settings.optimize_from_accommodation !== false) === opt.value ? 'var(--bg-hover)' : 'var(--bg-card)',
color: 'var(--text-primary)',
transition: 'all 0.15s',
}}
>
{opt.label}
</button>
))}
</div>
<p className="text-xs mt-1 text-content-faint">{t('settings.optimizeFromAccommodationHint')}</p>
</div>
<OnOffSetting label={t('settings.optimizeFromAccommodation')} hint={t('settings.optimizeFromAccommodationHint')} on={settings.optimize_from_accommodation !== false} onChange={value => saveOnOff('optimize_from_accommodation', value)} />
</Section>
</>
)
}
type OnOffKey = 'day_date_first' | 'map_compact_unplanned' | 'map_booking_labels' | 'map_always_show_routes' | 'map_poi_pill_enabled' | 'blur_booking_codes' | 'optimize_from_accommodation'
/** One preference as a pair of On / Off buttons, the way this tab asks every yes-or-no question. */
function OnOffSetting({ label, hint, on, onChange }: { label: string; hint?: string; on: boolean; onChange: (value: boolean) => void }) {
const { t } = useTranslation()
return (
<div>
<label className="block text-sm font-medium mb-2 text-content-secondary">{label}</label>
<div className="flex gap-3">
{[
{ value: true, label: t('settings.on') || 'On' },
{ value: false, label: t('settings.off') || 'Off' },
].map(opt => (
<button type="button"
key={String(opt.value)}
onClick={() => onChange(opt.value)}
style={{
display: 'flex', alignItems: 'center', gap: 8,
padding: '10px 20px', borderRadius: 10, cursor: 'pointer',
fontFamily: 'inherit', fontSize: 'calc(14px * var(--fs-scale-body, 1))', fontWeight: 500,
border: on === opt.value ? '2px solid var(--text-primary)' : '2px solid var(--border-primary)',
background: on === opt.value ? 'var(--bg-hover)' : 'var(--bg-card)',
color: 'var(--text-primary)',
transition: 'all 0.15s',
}}
>
{opt.label}
</button>
))}
</div>
{hint && <p className="text-xs mt-1 text-content-faint">{hint}</p>}
</div>
)
}
+15 -1
View File
@@ -97,6 +97,9 @@ export function StudioCanvas({
const select = useStudioStore(s => s.select)
const removeElements = useStudioStore(s => s.removeElements)
const duplicate = useStudioStore(s => s.duplicate)
const copy = useStudioStore(s => s.copy)
const paste = useStudioStore(s => s.paste)
const hasClipboard = useStudioStore(s => !!s.clipboard)
const raise = useStudioStore(s => s.raise)
const commit = useStudioStore(s => s.commit)
const addElement = useStudioStore(s => s.addElement)
@@ -155,10 +158,21 @@ export function StudioCanvas({
const ids = deletable(spread, selection)
if (ids.length) removeElements(spreadIndex, ids)
}
// Copy and paste across pages (#2316). Text selected on the page is the
// browser's to copy, not the book's.
if (!(e.metaKey || e.ctrlKey) || e.altKey) return
const key = e.key.toLowerCase()
if (key === 'c' && selection.length && !window.getSelection()?.toString()) {
e.preventDefault()
copy(spreadIndex, selection)
} else if (key === 'v' && hasClipboard) {
e.preventDefault()
paste(spreadIndex)
}
}
window.addEventListener('keydown', onKey)
return () => window.removeEventListener('keydown', onKey)
}, [selection, spread, spreadIndex, removeElements])
}, [selection, spread, spreadIndex, removeElements, copy, paste, hasClipboard])
if (!spread) return null
@@ -1,7 +1,7 @@
import type { BookElement, BookPageNumbers, BookPageSetup, BookShapeId, JourneyStats } from '@trek/shared'
import {
AlignCenter, AlignJustify, AlignLeft, AlignRight, ArrowDown, ArrowUp,
ChevronsDown, ChevronsUp, Copy, Italic, Lock, Trash2, Unlock,
ChevronsDown, ChevronsUp, ClipboardCopy, Copy, Italic, Lock, Trash2, Unlock,
} from 'lucide-react'
import { useMemo, useRef, useState } from 'react'
import { useStudioStore } from '../../store/studioStore'
@@ -15,6 +15,7 @@ import { formatBookCoords } from './entryText'
import { iconComponent, iconLabel, searchIcons } from './iconLibrary'
import { Choice, Line, NumField, Section, Switch } from './StudioControls'
import type { JourneySource } from './StudioSidebar'
import { Tooltip } from '../shared/Tooltip'
/** How many icons the swap grid offers at once. A search narrows it below this. */
const ICON_CHOICES = 60
@@ -78,6 +79,7 @@ export function StudioInspector({
const commit = useStudioStore(s => s.commit)
const raise = useStudioStore(s => s.raise)
const duplicate = useStudioStore(s => s.duplicate)
const copy = useStudioStore(s => s.copy)
const removeElements = useStudioStore(s => s.removeElements)
const spread = doc?.spreads[spreadIndex]
@@ -232,6 +234,15 @@ export function StudioInspector({
{sel.length > 1 && <em>{sel.length}</em>}
</span>
<span className="st-head-acts">
<Tooltip label={t('journey.studio.copyToPage')}>
<button type="button"
className="st-act"
onClick={() => copy(spreadIndex, selection)}
aria-label={t('journey.studio.copyToPage')}
>
<ClipboardCopy size={14} />
</button>
</Tooltip>
<button type="button"
className="st-act"
onClick={() => duplicate(spreadIndex, selection)}
@@ -0,0 +1,84 @@
import type { BookPageSetup } from '@trek/shared'
import { MAX_BOOK_LAYOUTS } from '@trek/shared'
import { BookmarkPlus, Trash2 } from 'lucide-react'
import { useStudioStore } from '../../store/studioStore'
import { Tooltip } from '../shared/Tooltip'
import { SpreadView } from './SpreadView'
import { layoutPreview, layoutRole, nextLayoutName } from './savedLayouts'
/**
* The book's own layouts, above the built-in ones (#2316).
*
* One button keeps the page on screen as a layout; each card lays the page on
* screen out that way, keeping its pictures and words. The cards draw the
* layout with the same renderer as the page, so what is kept is what is shown.
*/
export function StudioSavedLayouts({
page, width, pxPerMm, t,
}: { page: BookPageSetup; width: number; pxPerMm: number; t: (k: string) => string }) {
const doc = useStudioStore(s => s.doc)
const active = useStudioStore(s => s.activeSpread)
const saveLayout = useStudioStore(s => s.saveLayout)
const removeLayout = useStudioStore(s => s.removeLayout)
const applyLayout = useStudioStore(s => s.applyLayout)
const spread = doc?.spreads[active]
if (!doc || !spread) return null
const all = doc.layouts ?? []
const role = layoutRole(spread)
const fitting = all.filter(l => l.role === role)
const full = all.length >= MAX_BOOK_LAYOUTS
const canSave = spread.elements.length > 0 && !full
const single = role !== 'inner'
const wMm = page.pageWidth * (single ? 1 : 2)
const scale = width / (wMm * pxPerMm)
return (
<div className="st-saved-layouts">
<div className="st-section-label">{t('journey.studio.myLayouts')}</div>
{fitting.length === 0 && <p className="st-hint">{t('journey.studio.myLayoutsEmpty')}</p>}
<div className="st-thumbs">
{fitting.map(layout => (
<div className="st-thumb-row" key={layout.id}>
<button type="button" className="st-thumb" onClick={() => applyLayout(active, layout.id)}>
<div className="st-thumb-sheet" style={{ width, height: page.pageHeight * pxPerMm * scale }}>
<div
style={{
position: 'absolute', left: 0, top: 0,
width: `${wMm}mm`, height: `${page.pageHeight}mm`,
transform: `scale(${scale})`, transformOrigin: 'top left',
}}
>
<SpreadView spread={layoutPreview(layout, page)} page={page} />
</div>
</div>
<span className="st-thumb-label">{layout.name}</span>
</button>
<div className="st-thumb-actions">
<Tooltip label={t('journey.studio.deleteLayout')}>
<button type="button"
className="is-danger"
onClick={() => removeLayout(layout.id)}
aria-label={t('journey.studio.deleteLayout')}
>
<Trash2 size={12} />
</button>
</Tooltip>
</div>
</div>
))}
</div>
<Tooltip label={t(full ? 'journey.studio.saveLayoutFull' : 'journey.studio.saveLayoutHint')}>
<button type="button"
className="st-add-page"
disabled={!canSave}
onClick={() => saveLayout(active, nextLayoutName(all, t('journey.studio.layoutName')))}
>
<BookmarkPlus size={14} />
{t('journey.studio.saveLayout')}
</button>
</Tooltip>
<div className="st-section-label st-builtin-label">{t('journey.studio.builtInLayouts')}</div>
</div>
)
}
+27 -1
View File
@@ -2,7 +2,7 @@ import { useEffect, useRef, useState } from 'react'
import { createPortal } from 'react-dom'
import { useNavigate } from 'react-router'
import {
ArrowLeft, BookOpen, Check, ChevronDown, Download, Maximize2, Minus, Plus,
ArrowLeft, BookOpen, Check, ChevronDown, ClipboardPaste, Download, Maximize2, Minus, Plus,
Redo2, Sparkles, Undo2,
} from 'lucide-react'
import { useJourneyStudio } from '../../pages/journeyStudio/useJourneyStudio'
@@ -17,6 +17,8 @@ import { downloadSpread } from './spreadFile'
import { StudioExport } from './StudioExport'
import { PeerBadges } from './PeerBadges'
import { TrimField } from './TrimField'
import { Tooltip } from '../shared/Tooltip'
import { useStudioStore } from '../../store/studioStore'
import '../../styles/dashboard.css'
import '../../styles/studio.css'
import './bookFontFaces'
@@ -193,6 +195,7 @@ function StudioBar({
>
<Redo2 size={15} />
</button>
<PasteButton s={s} />
<div className="st-sep" />
@@ -221,6 +224,29 @@ function StudioBar({
* book is behind the chevron, where it cannot be hit by accident, and both are
* ordinary undo steps.
*/
/**
* Paste what was copied onto the page on screen (#2316). Beside undo and redo
* because it is the same kind of thing, and there so the keyboard shortcut is
* not the only way to find it.
*/
function PasteButton({ s }: { s: Studio }) {
const clipboard = useStudioStore(st => st.clipboard)
const paste = useStudioStore(st => st.paste)
const label = s.t('journey.studio.paste')
return (
<Tooltip label={clipboard ? label : s.t('journey.studio.pasteEmpty')}>
<button type="button"
className="st-tool is-icon"
disabled={!clipboard || !s.canEdit}
onClick={() => paste(s.activeSpread)}
aria-label={label}
>
<ClipboardPaste size={15} />
</button>
</Tooltip>
)
}
function AutoLayoutButton({ s }: { s: Studio }) {
const [open, setOpen] = useState(false)
const box = useRef<HTMLDivElement>(null)
@@ -19,6 +19,7 @@ import { MOOD_CONFIG, WEATHER_CONFIG } from '../../pages/journeyDetail/JourneyDe
import { PanelHead as Head } from './StudioPanelHead'
import { StudioElementsPanel } from './StudioElementsPanel'
import { StudioTravelPanel } from './StudioTravelPanel'
import { StudioSavedLayouts } from './StudioSavedLayouts'
import { MAX_SPREAD_FILE_BYTES, importSpread } from './spreadFile'
import { StudioContentFilter } from './StudioContentFilter'
import { emptyKeyFor, matchingEntries, matchingPhotos, photosFor, type PhotoFilter } from './photoFilter'
@@ -776,6 +777,7 @@ function TemplatesPanel({
<>
<Head label={t('journey.studio.templates')} count={templates.length} />
<div className="st-panel-scroll">
<StudioSavedLayouts page={page} width={CARD_W} pxPerMm={pxPerMm} t={t} />
{(
<div className="st-thumbs" ref={list.ref}>
{templates.map(tpl => {
@@ -0,0 +1,131 @@
import type { BookElement, BookLayout, BookPageSetup, BookSpread } from '@trek/shared'
import { MAX_SPREAD_ELEMENTS } from '@trek/shared'
import { elementId } from './bookIds'
/**
* A book's own layouts (#2316): a page somebody arranged, kept to lay other
* pages out the same way.
*
* The built-in layouts in templates.ts are frames only. A saved one is the whole
* design, panels and shapes and type included, with the pictures taken out; the
* page it is applied to brings its own.
*/
/** Which saved layouts fit a spread: a double spread's design does not fit a single page. */
export const layoutRole = (spread: BookSpread): BookLayout['role'] => (spread.role === 'inner' ? 'inner' : 'single')
/** The arrangement of a spread, as a layout to keep. */
export function layoutFromSpread(spread: BookSpread, page: BookPageSetup, name: string): BookLayout {
return {
id: elementId('ly'),
name,
role: layoutRole(spread),
pageWidth: page.pageWidth,
pageHeight: page.pageHeight,
background: spread.background,
// The design, not the pictures: every frame comes out empty.
elements: spread.elements.map(el => (el.kind === 'photo' ? { ...el, photoId: null } : el)),
}
}
/** The next free "Layout n" name, so a save needs no dialog. */
export function nextLayoutName(existing: readonly BookLayout[], base: string): string {
const taken = new Set(existing.map(l => l.name))
let n = existing.length + 1
while (taken.has(`${base} ${n}`)) n += 1
return `${base} ${n}`
}
const textSize = (el: BookElement) => (el.kind === 'text' ? el.size : 0)
/**
* Lay a spread out on a saved layout, keeping what the spread holds.
*
* The same contract as applying a built-in layout: the spread's pictures go into
* the layout's frames in order, its words into the layout's text boxes (the
* largest into the largest, so the title stays the title), and whatever does not
* fit is parked rather than lost. The layout's own type keeps its look and takes
* the spread's words; a text box that was filled from a journal entry and finds
* nothing to take is left out, because its words belonged to the page it was
* saved from. The small marks and the pros and cons list are content too: each
* takes the values of the spread's own mark of the same kind, in order (the
* spread's day, its coordinates), and keeps the layout's look. Everything else
* is the design and is copied as drawn, scaled from the page it was drawn on to
* this one.
*/
export function applySavedLayout(spread: BookSpread, layout: BookLayout, page: BookPageSetup): BookSpread {
const sx = page.pageWidth / layout.pageWidth
const sy = page.pageHeight / layout.pageHeight
const pool = [...spread.elements, ...(spread.parked ?? [])]
const photos = pool.filter(e => e.kind === 'photo' && e.photoId != null)
const texts = pool.filter(e => e.kind === 'text' && e.text.trim()).sort((a, b) => textSize(b) - textSize(a))
// Which of the layout's text boxes takes which of the spread's words.
const slots = layout.elements.filter(e => e.kind === 'text').sort((a, b) => textSize(b) - textSize(a))
const wordsFor = new Map<BookElement, BookElement>()
slots.forEach((slot, i) => { if (texts[i]) wordsFor.set(slot, texts[i]) })
// The spread's own marks and lists, queued per kind, for the layout's to take their values from.
const marks = new Map<string, BookElement[]>()
for (const el of spread.elements) {
const key = el.kind === 'badge' ? `badge:${el.variant}` : el.kind === 'list' ? 'list' : null
if (key) marks.set(key, [...(marks.get(key) ?? []), el])
}
const takeMark = (key: string) => marks.get(key)?.shift()
let pi = 0
const out: BookElement[] = []
for (const el of layout.elements) {
const frame = { x: el.frame.x * sx, y: el.frame.y * sy, w: el.frame.w * sx, h: el.frame.h * sy }
const base = { ...el, id: elementId(el.kind[0]), frame } as BookElement
if (base.kind === 'photo') {
const src = photos[pi++]
out.push(src && src.kind === 'photo'
? { ...base, photoId: src.photoId, focalX: src.focalX, focalY: src.focalY }
: base)
continue
}
if (base.kind === 'text' && el.kind === 'text') {
const src = wordsFor.get(el)
if (src && src.kind === 'text') {
out.push({ ...base, text: src.text, binding: src.binding, overridden: src.overridden })
} else if (!el.binding) {
out.push(base)
}
continue
}
if (base.kind === 'badge') {
const own = takeMark(`badge:${base.variant}`)
out.push(own && own.kind === 'badge' ? { ...base, text: own.text, sub: own.sub, code: own.code } : base)
continue
}
if (base.kind === 'list') {
const own = takeMark('list')
out.push(own && own.kind === 'list' ? { ...base, items: own.items } : base)
continue
}
out.push(base)
}
const used = new Set(wordsFor.values())
const parked = [
...photos.slice(pi),
...texts.filter(t => !used.has(t)),
].slice(0, MAX_SPREAD_ELEMENTS)
return { ...spread, background: layout.background, elements: out, parked }
}
/** A saved layout drawn as a spread on this book's page, for its card. Nothing is filled in. */
export function layoutPreview(layout: BookLayout, page: BookPageSetup): BookSpread {
const sx = page.pageWidth / layout.pageWidth
const sy = page.pageHeight / layout.pageHeight
return {
id: layout.id,
role: layout.role === 'inner' ? 'inner' : 'first',
background: layout.background,
elements: layout.elements.map(el => ({ ...el, frame: { x: el.frame.x * sx, y: el.frame.y * sy, w: el.frame.w * sx, h: el.frame.h * sy } }) as BookElement),
parked: [],
entryId: null,
}
}
@@ -0,0 +1,40 @@
import type { BookElement, BookPageSetup, BookSpread } from '@trek/shared'
import { elementId } from './bookIds'
/**
* What was copied, and from where (#2316).
*
* The elements as they stood, in the millimetres of the spread they came from.
* The source spread's id is kept so a paste back onto the same page can step
* the copy aside rather than drop it exactly over the original.
*/
export interface StudioClipboard {
fromSpreadId: string
elements: BookElement[]
}
/** How far a paste onto its own page is stepped aside, the same step Duplicate takes. */
const OFFSET = 4
const spreadWidth = (spread: BookSpread, page: BookPageSetup) =>
page.pageWidth * (spread.role === 'inner' ? 2 : 1)
/**
* The clipboard's elements, ready to go onto `target`.
*
* Fresh ids every time, because the book is edited by several people at once
* and two elements sharing an id would merge into one on the next save. An
* element copied from a double spread onto a single page is pulled back inside
* it: its place on the right-hand page would otherwise be off the paper.
*/
export function pastedElements(clip: StudioClipboard, target: BookSpread, page: BookPageSetup): BookElement[] {
const width = spreadWidth(target, page)
const step = clip.fromSpreadId === target.id ? OFFSET : 0
return clip.elements.map(el => {
const w = Math.min(el.frame.w, width)
const h = Math.min(el.frame.h, page.pageHeight)
const x = Math.min(Math.max(el.frame.x + step, 0), width - w)
const y = Math.min(Math.max(el.frame.y + step, 0), page.pageHeight - h)
return { ...el, id: elementId(el.kind[0]), locked: false, frame: { x, y, w, h } } as BookElement
})
}
+3 -2
View File
@@ -2,6 +2,7 @@ import type { CSSProperties } from 'react'
import { Check, ChevronRight, Flag, Calendar, GripVertical, UserRound } from 'lucide-react'
import { avatarSrc } from '../../utils/avatarSrc'
import type { TodoItem } from '../../types'
import MarkdownText from '../shared/MarkdownText'
import { katColor, PRIO_CONFIG, type Member } from './todoListModel'
/** A quiet pill for the row's facts: filled, no frame, the packing list's badge. */
@@ -113,8 +114,8 @@ export default function TodoRow({ item, members, categories, today, isSelected,
</div>
{/* Description preview */}
{item.description && (
<div style={{ fontSize: 'calc(12px * var(--fs-scale-body, 1))', color: 'var(--text-faint)', marginTop: 2, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', lineHeight: 1.4 }}>
{item.description}
<div style={{ fontSize: 'calc(12px * var(--fs-scale-body, 1))', color: 'var(--text-faint)', marginTop: 2, minWidth: 0 }}>
<MarkdownText clamp>{item.description}</MarkdownText>
</div>
)}
{/* Priority, date and list as quiet badges */}
@@ -6,6 +6,7 @@ import CustomSelect from '../shared/CustomSelect'
import { CustomDatePicker } from '../shared/CustomDateTimePicker'
import NameDialog from '../shared/NameDialog'
import { Tooltip } from '../shared/Tooltip'
import MarkdownEditable from '../shared/MarkdownEditable'
import { GRID_2, LABEL, TEXTAREA } from '../shared/dialogParts'
import { PRIO_CONFIG, katColor, taskInputStyle, taskLabelClass, type Member, type TaskFieldValues } from './todoListModel'
@@ -92,9 +93,14 @@ export default function TodoTaskFields({ values, onChange, categories, members,
<textarea value={desc} onChange={e => onChange({ desc: e.target.value })} disabled={!canEdit} rows={3}
placeholder={t('todo.descriptionPlaceholder')} className={TEXTAREA} />
) : (
<textarea value={desc} onChange={e => onChange({ desc: e.target.value })} disabled={!canEdit} rows={4}
placeholder={t('todo.descriptionPlaceholder')}
style={{ ...taskInputStyle, resize: 'vertical', minHeight: 84 }} />
<MarkdownEditable value={desc} canEdit={canEdit} editLabel={t('todo.editDescription')}
className="text-content-secondary"
style={{ ...taskInputStyle, minHeight: 84, lineHeight: 1.5, overflowWrap: 'break-word' }}
renderEditor={editor => (
<textarea value={desc} onChange={e => onChange({ desc: e.target.value })} rows={4} {...editor}
placeholder={t('todo.descriptionPlaceholder')}
style={{ ...taskInputStyle, resize: 'vertical', minHeight: 84 }} />
)} />
)}
</div>
@@ -602,6 +602,23 @@ describe('TripMembersModal', () => {
}));
});
it('FE-COMP-MEMBERS-033b: the two narrowing options post with the link (#1712)', async () => {
const user = userEvent.setup();
asShareOwner();
let postedPerms: Record<string, unknown> | null = null;
server.use(
http.get('/api/trips/1/share-link', () => HttpResponse.json({ token: 'tok77', share_hide_images: true })),
http.post('/api/trips/1/share-link', async ({ request }) => {
postedPerms = (await request.json()) as Record<string, unknown>;
return HttpResponse.json({ token: 'tok77' });
}),
);
render(<TripMembersModal {...defaultProps} />);
await user.click(await screen.findByText('Travel & stays only'));
await waitFor(() => expect(postedPerms).toMatchObject({ share_travel_only: true, share_hide_images: true }));
});
it('FE-COMP-MEMBERS-034: a failing permission update is reported', async () => {
const user = userEvent.setup();
asShareOwner();
@@ -5,7 +5,7 @@ import CustomSelect from '../shared/CustomSelect'
import { useIsPhone } from '../../mobile/useIsPhone'
import { TripMemberAvatar as Avatar } from './TripMemberAvatar'
import TripShareDialog from './TripShareDialog'
import { useShareLink, useTripInviteLink, useTripMembers } from './useTripShare'
import { SHARE_OPTIONS, SHARE_SECTIONS, useShareLink, useTripInviteLink, useTripMembers } from './useTripShare'
function ShareLinkSection({ tripId, t }: { tripId: number; t: (key: string, params?: Record<string, string | number>) => string }) {
const { loading, url: shareUrl, perms, copied, create: handleCreate, setPerm, remove: handleDelete, copy: handleCopy } = useShareLink(tripId)
@@ -23,13 +23,7 @@ function ShareLinkSection({ tripId, t }: { tripId: number; t: (key: string, para
{/* Permission checkboxes */}
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 6, marginBottom: 12 }}>
{[
{ key: 'share_map', label: t('share.permMap'), always: true },
{ key: 'share_bookings', label: t('share.permBookings') },
{ key: 'share_packing', label: t('share.permPacking') },
{ key: 'share_budget', label: t('share.permBudget') },
{ key: 'share_collab', label: t('share.permCollab') },
].map(opt => (
{[...SHARE_SECTIONS, ...SHARE_OPTIONS].map(opt => ({ ...opt, label: t(opt.label), always: 'always' in opt && !!opt.always })).map(opt => (
<button type="button" key={opt.key} onClick={() => !opt.always && handleUpdatePerms(opt.key, !perms[opt.key])}
style={{
display: 'flex', alignItems: 'center', gap: 5, padding: '4px 10px', borderRadius: 20,
+26 -20
View File
@@ -5,9 +5,9 @@ import CustomSelect from '../shared/CustomSelect'
import ConfirmDialog from '../shared/ConfirmDialog'
import { Tooltip } from '../shared/Tooltip'
import { DialogButton, DialogHeader, DialogSection, DialogShell, DialogTile, NEUTRAL_TINT, PILL, fs } from '../shared/DialogShell'
import { AddRowButton, INPUT, PANEL } from '../shared/dialogParts'
import { AddRowButton, INPUT, LABEL, PANEL } from '../shared/dialogParts'
import { TripMemberAvatar } from './TripMemberAvatar'
import { useShareLink, useTripInviteLink, useTripMembers, type SharePerm, type TripMembersState } from './useTripShare'
import { SHARE_OPTIONS, SHARE_SECTIONS, useShareLink, useTripInviteLink, useTripMembers, type TripMembersState } from './useTripShare'
interface TripShareDialogProps {
isOpen: boolean
@@ -306,13 +306,17 @@ function LinkCard({ icon, title, hint, children }: { icon: ReactNode; title: str
)
}
const SHARE_PERMS: { key: SharePerm; label: string; always?: boolean }[] = [
{ key: 'share_map', label: 'share.permMap', always: true },
{ key: 'share_bookings', label: 'share.permBookings' },
{ key: 'share_packing', label: 'share.permPacking' },
{ key: 'share_budget', label: 'share.permBudget' },
{ key: 'share_collab', label: 'share.permCollab' },
]
/** One toggle of the link, a filled pill with a tick while it is on. */
function PermChip({ on, disabled, label, hint, onToggle }: { on: boolean; disabled?: boolean; label: string; hint?: string; onToggle: () => void }) {
const chip = (
<button type="button" aria-pressed={on} disabled={disabled} onClick={onToggle}
className={`inline-flex items-center gap-1 rounded-full px-2.5 py-1 font-semibold disabled:cursor-default ${on ? 'bg-accent text-accent-text disabled:opacity-70' : 'bg-surface-card text-content-muted ring-1 ring-edge-faint hover:text-content'}`}
style={fs(11.5, 'body')}>
{on && <Check size={11} strokeWidth={2.6} />}{label}
</button>
)
return hint ? <Tooltip label={hint}>{chip}</Tooltip> : chip
}
/** The read-only public link, and which parts of the trip it shows. */
function PublicLink({ tripId }: { tripId: number }) {
@@ -322,17 +326,19 @@ function PublicLink({ tripId }: { tripId: number }) {
return (
<LinkCard icon={<Link2 size={15} strokeWidth={2.2} />} title={t('share.linkTitle')} hint={t('share.linkHint')}>
<div className="flex flex-wrap gap-1.5">
{SHARE_PERMS.map(opt => {
const on = link.perms[opt.key]
return (
<button key={opt.key} type="button" aria-pressed={on} disabled={opt.always}
onClick={() => void link.setPerm(opt.key, !on)}
className={`inline-flex items-center gap-1 rounded-full px-2.5 py-1 font-semibold disabled:cursor-default ${on ? 'bg-accent text-accent-text disabled:opacity-70' : 'bg-surface-card text-content-muted ring-1 ring-edge-faint hover:text-content'}`}
style={fs(11.5, 'body')}>
{on && <Check size={11} strokeWidth={2.6} />}{t(opt.label)}
</button>
)
})}
{SHARE_SECTIONS.map(opt => (
<PermChip key={opt.key} on={link.perms[opt.key]} disabled={opt.always} label={t(opt.label)}
onToggle={() => void link.setPerm(opt.key, !link.perms[opt.key])} />
))}
</div>
<div>
<span className={LABEL}>{t('share.options')}</span>
<div className="flex flex-wrap gap-1.5">
{SHARE_OPTIONS.map(opt => (
<PermChip key={opt.key} on={link.perms[opt.key]} label={t(opt.label)} hint={t(`${opt.label}Hint`)}
onToggle={() => void link.setPerm(opt.key, !link.perms[opt.key])} />
))}
</div>
</div>
{link.url ? (
<>
+25 -3
View File
@@ -13,10 +13,28 @@ import { copyText } from '../../utils/clipboard'
// phone opens from its More sheet. The questions ("Remove this member?") are the
// caller's: the desktop asks in a dialog of its own, the phone with the browser's.
export type SharePerm = 'share_map' | 'share_bookings' | 'share_packing' | 'share_budget' | 'share_collab'
export type SharePerm = 'share_map' | 'share_bookings' | 'share_packing' | 'share_budget' | 'share_collab' | 'share_travel_only' | 'share_hide_images'
type SharePerms = Record<SharePerm, boolean>
const DEFAULT_PERMS: SharePerms = { share_map: true, share_bookings: true, share_packing: false, share_budget: false, share_collab: false }
const DEFAULT_PERMS: SharePerms = {
share_map: true, share_bookings: true, share_packing: false, share_budget: false, share_collab: false,
share_travel_only: false, share_hide_images: false,
}
/** What the link shows, one tab each. */
export const SHARE_SECTIONS: { key: SharePerm; label: string; always?: boolean }[] = [
{ key: 'share_map', label: 'share.permMap', always: true },
{ key: 'share_bookings', label: 'share.permBookings' },
{ key: 'share_packing', label: 'share.permPacking' },
{ key: 'share_budget', label: 'share.permBudget' },
{ key: 'share_collab', label: 'share.permCollab' },
]
/** How the plan it shows is narrowed (#1712). */
export const SHARE_OPTIONS: { key: SharePerm; label: string }[] = [
{ key: 'share_travel_only', label: 'share.optTravelOnly' },
{ key: 'share_hide_images', label: 'share.optHideImages' },
]
/** Copy text and show "Copied" for two seconds, the timer restarting on every copy. */
function useCopied() {
@@ -44,7 +62,11 @@ export function useShareLink(tripId: number) {
useEffect(() => {
shareApi.getLink(tripId).then(d => {
setToken(d.token)
if (d.token) setPerms({ share_map: d.share_map ?? true, share_bookings: d.share_bookings ?? true, share_packing: d.share_packing ?? false, share_budget: d.share_budget ?? false, share_collab: d.share_collab ?? false })
if (d.token) setPerms({
share_map: d.share_map ?? true, share_bookings: d.share_bookings ?? true, share_packing: d.share_packing ?? false,
share_budget: d.share_budget ?? false, share_collab: d.share_collab ?? false,
share_travel_only: d.share_travel_only ?? false, share_hide_images: d.share_hide_images ?? false,
})
setLoading(false)
}).catch(() => setLoading(false))
}, [tripId])
@@ -8,6 +8,7 @@ import { tripsApi } from '../../api/client'
import VacayMonthCard from './VacayMonthCard'
import type { VacayEntry } from '../../types'
import { Building2, MousePointer2 } from 'lucide-react'
import { companyHolidaySets, leaveFractionFor } from './companyHolidays'
type VacayMode = 'vacation' | 'company'
type HoverTip = { date: string; top: number; left: number }
@@ -51,11 +52,8 @@ export default function VacayCalendar() {
return () => { cancelled = true }
}, [selectedYear, yearSettings])
const companyHolidaySet = useMemo(() => {
const s = new Set<string>()
companyHolidays.forEach(h => s.add(h.date))
return s
}, [companyHolidays])
// Whole company holidays close a day to leave, half ones leave half of it (#2439).
const { full: companyHolidaySet, half: companyHalfSet } = useMemo(() => companyHolidaySets(companyHolidays), [companyHolidays])
const entryMap = useMemo(() => {
const map: Record<string, VacayEntry[]> = {}
@@ -91,7 +89,7 @@ export default function VacayCalendar() {
const handleCellClick = useCallback(async (dateStr: string) => {
if (mode === 'company') {
if (!companyHolidaysEnabled) return
await toggleCompanyHoliday(dateStr)
await toggleCompanyHoliday(dateStr, halfDay ? 0.5 : 1)
return
}
if (blockWeekends && isWeekend(dateStr, weekendDays)) {
@@ -105,8 +103,9 @@ export default function VacayCalendar() {
return
}
if (companyHolidaysEnabled && companyHolidaySet.has(dateStr)) return
await toggleEntry(dateStr, selectedUserId || undefined, halfDay ? 0.5 : 1, compDay ? 'comp' : 'vacation')
}, [mode, halfDay, compDay, toggleEntry, toggleCompanyHoliday, companyHolidaySet, blockWeekends, weekendDays, companyHolidaysEnabled, selectedUserId, currentUserId, entryMap])
const fraction = companyHolidaysEnabled ? leaveFractionFor(dateStr, companyHalfSet, halfDay ? 0.5 : 1) : (halfDay ? 0.5 : 1)
await toggleEntry(dateStr, selectedUserId || undefined, fraction, compDay ? 'comp' : 'vacation')
}, [mode, halfDay, compDay, toggleEntry, toggleCompanyHoliday, companyHolidaySet, companyHalfSet, blockWeekends, weekendDays, companyHolidaysEnabled, selectedUserId, currentUserId, entryMap])
// Cells with a half day or a shared overlay report a hover, so the tooltip
// appears exactly when there's something to explain. Fixed-positioned at the
@@ -146,6 +145,7 @@ export default function VacayCalendar() {
month={month}
holidays={holidays}
companyHolidaySet={companyHolidaySet}
companyHalfSet={companyHalfSet}
companyHolidaysEnabled={companyHolidaysEnabled}
entryMap={entryMap}
sharedMap={sharedMap}
@@ -10,6 +10,8 @@ interface VacayMonthCardProps {
month: number
holidays: HolidaysMap
companyHolidaySet: Set<string>
/** Half company holidays (#2439): shown half-filled, and still open for half a day of leave. */
companyHalfSet?: Set<string>
companyHolidaysEnabled?: boolean
entryMap: Record<string, VacayEntry[]>
// Shared read-only calendars per date (#444/#667) — rendered as rings, not fills.
@@ -24,7 +26,7 @@ interface VacayMonthCardProps {
}
export default function VacayMonthCard({
year, month, holidays, companyHolidaySet, companyHolidaysEnabled = true, entryMap, sharedMap,
year, month, holidays, companyHolidaySet, companyHalfSet, companyHolidaysEnabled = true, entryMap, sharedMap,
onCellClick, onCellHover, companyMode, blockWeekends, weekendDays = [0, 6], tripDates, weekStart = 1
}: VacayMonthCardProps) {
const { t, locale } = useTranslation()
@@ -106,13 +108,14 @@ export default function VacayMonthCard({
const publicHoliday = holidayMarkers.find(h => (h.type ?? 'public_holiday') === 'public_holiday')
const schoolHolidayMarkers = holidayMarkers.filter(h => h.type === 'school_holiday')
const isCompany = companyHolidaysEnabled && companyHolidaySet.has(dateStr)
const isHalfCompany = companyHolidaysEnabled && !!companyHalfSet?.has(dateStr)
const dayEntries = entryMap[dateStr] || []
const hasEntries = dayEntries.length > 0
// A blocked weekend day that still carries an entry (#1897) stays clickable —
// the click clears the stranded day rather than logging a new one.
const isBlocked = (weekend && blockWeekends && !hasEntries) || (isCompany && !companyMode)
const isToday = dateStr === todayStr
const plain = !hasEntries && holidayMarkers.length === 0 && !isCompany
const plain = !hasEntries && holidayMarkers.length === 0 && !isCompany && !isHalfCompany
// The fill always shows WHO is off (person colour, split for several) — half
// days keep that fill and get a small corner ½ badge instead, so a half day
@@ -130,6 +133,8 @@ export default function VacayMonthCard({
// 2 people render via the diagonal overlay below, so each half can be a solid
// vacation fill or a hatched comp fill independently (#1074).
else if (dayEntries.length === 0 && isCompany) background = 'rgba(245,158,11,0.22)'
// Half the day is the company's: the fill covers half the cell (#2439).
else if (dayEntries.length === 0 && isHalfCompany) background = 'linear-gradient(135deg, rgba(245,158,11,0.3) 50%, transparent 50%)'
else if (dayEntries.length === 0 && publicHoliday) background = `color-mix(in srgb, ${publicHoliday.color} 22%, transparent)`
// A plain school-break day gets a soft wash of its calendar colour so a run of
// days reads as one gentle stretch; the rounded band below names the calendar.
@@ -0,0 +1,24 @@
// FE-VACAY-COMPANY-001 to FE-VACAY-COMPANY-003 (#2439)
import { describe, it, expect } from 'vitest'
import { companyHolidaySets, leaveFractionFor, toggledCompanyHolidays } from './companyHolidays'
describe('company holidays', () => {
it('FE-VACAY-COMPANY-001: splits whole and half days, old rows being whole', () => {
const { full, half } = companyHolidaySets([{ date: 'a' }, { date: 'b', fraction: 0.5 }, { date: 'c', fraction: 1 }])
expect([...full]).toEqual(['a', 'c'])
expect([...half]).toEqual(['b'])
})
it('FE-VACAY-COMPANY-002: adds, converts and clears by the server rule', () => {
const one = toggledCompanyHolidays([], 'x', 0.5)
expect(one).toEqual([{ date: 'x', fraction: 0.5 }])
const whole = toggledCompanyHolidays(one, 'x', 1)
expect(whole).toEqual([{ date: 'x', fraction: 1 }])
expect(toggledCompanyHolidays(whole, 'x', 1)).toEqual([])
})
it('FE-VACAY-COMPANY-003: leave on a half company holiday is half a day', () => {
expect(leaveFractionFor('x', new Set(['x']), 1)).toBe(0.5)
expect(leaveFractionFor('y', new Set(['x']), 1)).toBe(1)
})
})
@@ -0,0 +1,35 @@
/** A company holiday as the plan holds it; `fraction` 0.5 is a half day (#2439). */
export interface CompanyHoliday {
date: string
note?: string
fraction?: number | null
}
const isHalf = (h: CompanyHoliday) => (h.fraction ?? 1) < 1
/**
* The plan's company holidays split by size (#2439): a whole one closes the day to
* leave, a half one leaves half of it. Rows from before the column are whole.
*/
export function companyHolidaySets(list: CompanyHoliday[]): { full: Set<string>; half: Set<string> } {
const full = new Set<string>()
const half = new Set<string>()
for (const h of list) (isHalf(h) ? half : full).add(h.date)
return { full, half }
}
/**
* The list after a click, ahead of the server, by its rule: the same size again
* clears the day, the other size converts it.
*/
export function toggledCompanyHolidays(list: CompanyHoliday[], date: string, fraction: number): CompanyHoliday[] {
const existing = list.find(h => h.date === date)
if (!existing) return [...list, { date, fraction }]
if ((existing.fraction ?? 1) === fraction) return list.filter(h => h.date !== date)
return list.map(h => (h.date === date ? { ...h, fraction } : h))
}
/** The fraction a leave click may book on a day: half of a half company holiday, else what was asked. */
export function leaveFractionFor(date: string, half: Set<string>, asked: 0.5 | 1): 0.5 | 1 {
return half.has(date) ? 0.5 : asked
}
@@ -0,0 +1,49 @@
// FE-SHARED-MDEDIT-001 to FE-SHARED-MDEDIT-004 (#2558)
import { useState } from 'react'
import { render, screen, fireEvent } from '../../../tests/helpers/render'
import MarkdownEditable from './MarkdownEditable'
function Harness({ initial, canEdit = true }: { initial: string; canEdit?: boolean }) {
const [value, setValue] = useState(initial)
return (
<MarkdownEditable value={value} canEdit={canEdit} editLabel="Edit"
renderEditor={editor => <textarea aria-label="editor" value={value} onChange={e => setValue(e.target.value)} {...editor} />} />
)
}
describe('MarkdownEditable', () => {
it('FE-SHARED-MDEDIT-001: renders a saved value as Markdown with a working link', () => {
render(<Harness initial={'**Ferry** at [the port](https://example.com)'} />)
expect(screen.getByText('Ferry').tagName).toBe('STRONG')
const link = screen.getByRole('link', { name: 'the port' })
expect(link).toHaveAttribute('href', 'https://example.com')
// Following the link does not open the editor.
fireEvent.click(link)
expect(screen.queryByLabelText('editor')).not.toBeInTheDocument()
})
it('FE-SHARED-MDEDIT-002: a click on the text opens the editor, leaving it shows the text again', () => {
render(<Harness initial="Bring cash" />)
fireEvent.click(screen.getByText('Bring cash'))
const editor = screen.getByLabelText('editor')
expect(editor).toHaveValue('Bring cash')
fireEvent.blur(editor)
expect(screen.queryByLabelText('editor')).not.toBeInTheDocument()
fireEvent.keyDown(screen.getByRole('button'), { key: 'Enter' })
expect(screen.getByLabelText('editor')).toBeInTheDocument()
})
it('FE-SHARED-MDEDIT-003: an empty field is the editor, and stays one while it is typed into', () => {
render(<Harness initial="" />)
const editor = screen.getByLabelText('editor')
fireEvent.focus(editor)
fireEvent.change(editor, { target: { value: 'N' } })
expect(screen.getByLabelText('editor')).toHaveValue('N')
})
it('FE-SHARED-MDEDIT-004: without edit rights it is only text', () => {
render(<Harness initial="Read only" canEdit={false} />)
expect(screen.getByText('Read only')).toBeInTheDocument()
expect(screen.queryByRole('button')).not.toBeInTheDocument()
})
})
@@ -0,0 +1,46 @@
import { useState, type CSSProperties, type ReactNode } from 'react'
import MarkdownText from './MarkdownText'
import { Tooltip } from './Tooltip'
interface EditorProps {
autoFocus: boolean
/** Holds the editor open while it has focus, so typing into an empty field does not flip it to text. */
onFocus: () => void
onBlur: () => void
}
/**
* A Markdown field that reads as rendered text and turns into its textarea on a
* click (#2558): a link in a to-do description is a link, not a line of syntax.
* An empty field, or one being written, is the editor straight away. A click that
* lands on a link follows the link and leaves the text alone.
*/
export default function MarkdownEditable({ value, canEdit, editLabel, renderEditor, className = '', style }: {
value: string
canEdit: boolean
/** The hint on the rendered text, which is what opens the editor. */
editLabel: string
renderEditor: (props: EditorProps) => ReactNode
className?: string
style?: CSSProperties
}) {
const [editing, setEditing] = useState(false)
if (canEdit && (editing || !value.trim())) {
return <>{renderEditor({ autoFocus: editing, onFocus: () => setEditing(true), onBlur: () => setEditing(false) })}</>
}
if (!canEdit) return <MarkdownText className={className}>{value}</MarkdownText>
return (
<Tooltip label={editLabel} placement="top">
<div
role="button"
tabIndex={0}
onClick={e => { if (!(e.target as HTMLElement).closest('a')) setEditing(true) }}
onKeyDown={e => { if (e.key === 'Enter' && e.target === e.currentTarget) { e.preventDefault(); setEditing(true) } }}
className={`cursor-text ${className}`}
style={style}
>
<MarkdownText>{value}</MarkdownText>
</div>
</Tooltip>
)
}
@@ -2,7 +2,8 @@ import { useEffect, useLayoutEffect, useRef, useState } from 'react'
import { createPortal } from 'react-dom'
import { Navigation } from 'lucide-react'
import type { NavigationTarget } from '../Planner/placeNavigation'
import { openNavigationTarget } from '../Planner/placeNavigation'
import { navigationTargetLabel, openNavigationTarget } from '../Planner/placeNavigation'
import { useTranslation } from '../../i18n'
interface NavigationMenuProps {
targets: NavigationTarget[]
@@ -27,6 +28,7 @@ const EDGE = 8
* than the space to its right is pulled back inside.
*/
export function NavigationMenu({ targets, anchor, onClose, title }: NavigationMenuProps) {
const { t } = useTranslation()
const ref = useRef<HTMLDivElement>(null)
const [pos, setPos] = useState<{ top: number; left: number } | null>(null)
@@ -126,7 +128,7 @@ export function NavigationMenu({ targets, anchor, onClose, title }: NavigationMe
onMouseLeave={e => { e.currentTarget.style.background = 'none' }}
>
<Navigation size={13} style={{ flexShrink: 0, color: 'var(--text-faint)' }} />
<span>{target.label}</span>
<span>{navigationTargetLabel(target, t)}</span>
</button>
))}
</div>,
@@ -1,11 +1,13 @@
import { useRef, useState } from 'react'
import { Camera, Loader2, X } from 'lucide-react'
import { useEffect, useLayoutEffect, useRef, useState } from 'react'
import { createPortal } from 'react-dom'
import { Camera, Loader2, Upload, X } from 'lucide-react'
import PlaceAvatar from './PlaceAvatar'
import { Tooltip } from './Tooltip'
import { normalizeImageFile } from '../../utils/convertHeic'
import { useToast } from './Toast'
import { useTranslation, translateApiError } from '../../i18n'
import type { Place } from '../../types'
import { fetchImageAsBlob } from '../../api/authUrl'
import type { Place, TripFile } from '../../types'
interface Category {
color?: string
@@ -19,6 +21,16 @@ interface PlaceAvatarUploadProps {
onUpload: (file: File) => Promise<void>
/** Clears the custom image; the auto-fetched default thumbnail then returns (#1136). */
onRemove: () => Promise<void> | void
/** Pictures already attached to the place, offered as its image beside an upload (#1242). */
attachedImages?: TripFile[]
onPickAttached?: (fileId: number) => Promise<void>
}
const PICKABLE = /^image\/(jpeg|png|gif|webp)$/
/** The images among a place's files that can stand as its picture. */
export function pickableImages(files: TripFile[]): TripFile[] {
return files.filter(f => PICKABLE.test(f.mime_type || ''))
}
/**
@@ -26,12 +38,29 @@ interface PlaceAvatarUploadProps {
* a camera overlay with a tooltip, clicking opens the file picker, and when a custom
* upload is present a small corner button removes it (falling back to the default).
*/
export default function PlaceAvatarUpload({ place, category, size = 52, onUpload, onRemove }: PlaceAvatarUploadProps) {
export default function PlaceAvatarUpload({ place, category, size = 52, onUpload, onRemove, attachedImages = [], onPickAttached }: PlaceAvatarUploadProps) {
const { t } = useTranslation()
const toast = useToast()
const fileRef = useRef<HTMLInputElement>(null)
const anchorRef = useRef<HTMLDivElement>(null)
const [busy, setBusy] = useState(false)
const [chooserOpen, setChooserOpen] = useState(false)
const hasCustom = Boolean(place.image_url)
const offersAttached = !!onPickAttached && attachedImages.length > 0
// With pictures on the place the click asks where from; without, it goes straight to the device.
const openPicker = () => { if (busy) return; if (offersAttached) setChooserOpen(o => !o); else fileRef.current?.click() }
const pickAttached = async (fileId: number) => {
setChooserOpen(false)
setBusy(true)
try {
await onPickAttached!(fileId)
} catch (err: unknown) {
toast.error(translateApiError(t, err, 'places.imageUploadError'))
} finally {
setBusy(false)
}
}
const handleFile = async (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0]
@@ -65,10 +94,13 @@ export default function PlaceAvatarUpload({ place, category, size = 52, onUpload
<div
className="group"
style={{ position: 'relative', width: size, height: size, borderRadius: '50%', cursor: busy ? 'default' : 'pointer' }}
onClick={() => { if (!busy) fileRef.current?.click() }}
ref={anchorRef}
onClick={openPicker}
role="button"
tabIndex={0}
onKeyDown={e => { if ((e.key === 'Enter' || e.key === ' ') && !busy) { e.preventDefault(); fileRef.current?.click() } }}
aria-haspopup={offersAttached ? 'dialog' : undefined}
aria-expanded={offersAttached ? chooserOpen : undefined}
onKeyDown={e => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); openPicker() } }}
aria-label={hasCustom ? t('places.changeImage') : t('places.uploadImage')}
>
<PlaceAvatar place={place} category={category} size={size} />
@@ -106,6 +138,81 @@ export default function PlaceAvatarUpload({ place, category, size = 52, onUpload
)}
<input ref={fileRef} type="file" accept="image/jpeg,image/png,image/gif,image/webp,.heic,.heif" style={{ display: 'none' }} onChange={handleFile} />
{chooserOpen && (
<ImageChooser
anchor={anchorRef.current}
images={attachedImages}
onUpload={() => { setChooserOpen(false); fileRef.current?.click() }}
onPick={id => { void pickAttached(id) }}
onClose={() => setChooserOpen(false)}
/>
)}
</div>
)
}
/** Upload from the device, or one of the pictures already on the place (#1242). */
function ImageChooser({ anchor, images, onUpload, onPick, onClose }: {
anchor: HTMLElement | null
images: TripFile[]
onUpload: () => void
onPick: (fileId: number) => void
onClose: () => void
}) {
const { t } = useTranslation()
const ref = useRef<HTMLDivElement>(null)
const [pos, setPos] = useState<{ top: number; left: number } | null>(null)
useLayoutEffect(() => {
if (!anchor) return
const r = anchor.getBoundingClientRect()
const width = 264
setPos({ top: r.bottom + 8, left: Math.min(Math.max(12, r.left), window.innerWidth - width - 12) })
}, [anchor])
useEffect(() => {
const onDown = (e: MouseEvent) => {
const target = e.target as Node
if (!ref.current?.contains(target) && !anchor?.contains(target)) onClose()
}
const onKey = (e: KeyboardEvent) => { if (e.key === 'Escape') onClose() }
document.addEventListener('mousedown', onDown)
document.addEventListener('keydown', onKey)
return () => { document.removeEventListener('mousedown', onDown); document.removeEventListener('keydown', onKey) }
}, [anchor, onClose])
if (!pos) return null
return createPortal(
<div ref={ref} role="dialog" aria-label={t('places.chooseImage')}
className="trek-popover-enter fixed z-[var(--z-toast)] flex w-[264px] flex-col gap-2 rounded-[14px] border border-edge-secondary bg-surface-card p-2 shadow-popover"
style={{ top: pos.top, left: pos.left }}>
<button type="button" onClick={onUpload}
className="flex items-center gap-2 rounded-[10px] px-2.5 py-2 text-left text-body font-semibold text-content hover:bg-surface-hover">
<Upload size={14} strokeWidth={2.2} className="text-content-muted" />
{t('places.uploadFromDevice')}
</button>
<div className="px-2.5 text-caption font-semibold uppercase tracking-[0.04em] text-content-faint">{t('places.fromAttachedFiles')}</div>
<div className="grid grid-cols-4 gap-1.5 px-1 pb-1">
{images.map(f => <AttachedThumb key={f.id} file={f} onPick={() => onPick(f.id)} />)}
</div>
</div>,
document.body,
)
}
function AttachedThumb({ file, onPick }: { file: TripFile; onPick: () => void }) {
const [src, setSrc] = useState<string | null>(null)
useEffect(() => {
let current = true
fetchImageAsBlob(file.url).then(u => { if (current) setSrc(u) }).catch(() => {})
return () => { current = false }
}, [file.url])
return (
<Tooltip label={file.original_name} placement="top">
<button type="button" onClick={onPick} aria-label={file.original_name}
className="aspect-square overflow-hidden rounded-[9px] bg-surface-secondary ring-1 ring-edge-faint transition-shadow hover:ring-2 hover:ring-accent">
{src && <img src={src} alt="" className="h-full w-full object-cover" />}
</button>
</Tooltip>
)
}

Some files were not shown because too many files have changed in this diff Show More